feat: add prelaunch site controls and refresh branding
Validate and deploy production / validate (push) Successful in 23s
Validate and deploy staging / validate (push) Successful in 19s
Validate and deploy production / deploy (push) Successful in 1m0s
Validate and deploy staging / deploy (push) Successful in 36s

This commit is contained in:
JB
2026-07-27 02:24:13 -04:00
parent 7b3aff30d8
commit 5a67b252aa
37 changed files with 1738 additions and 100 deletions
+37
View File
@@ -134,6 +134,43 @@ military cosplay.
- Explicit textual states paired with icons, color, and motion. - Explicit textual states paired with icons, color, and motion.
- Responsive density from mobile touch layouts through scaled 4K workspaces. - Responsive density from mobile touch layouts through scaled 4K workspaces.
## Brand Identity
SHD.build uses the two-color Core Segments identity. The emblem connects the
orange Division signal with ISAC's role as the site's analytic system:
- Three unequal Signal Orange arc segments form the outer circle. Each segment
has square ends.
- The widest break sits in the upper-right quadrant and contains one detached
Signal Orange square, which represents a data packet.
- A Field Manual White open square forms the ISAC core at the center.
Keep the emblem in Signal Orange and Field Manual White on Operations Black,
Terminal Black, or another approved dark surface. SHD.build has no one-color
emblem. Do not close the arc gaps, equalize the segments, remove the data
packet, fill the ISAC core, add letters to the emblem, or recolor either part.
If a placement cannot reproduce both colors, use a dark holding surface or omit
the emblem.
The standard lockup pairs the emblem with the heavy angular `SHD.build`
wordmark. Set `SHD` in Field Manual White and `.build` in Signal Orange. Place
the uppercase descriptor `RESURGENCE DATA & TOOLS` below the wordmark in
restrained telemetry type. Do not substitute another descriptor.
Use the complete lockup in the 76px global header and shared footer. The header
uses a 38px emblem; keep the wordmark and descriptor compact enough to preserve
the navigation grid. Mobile headers may use the emblem alone when the complete
lockup would crowd navigation. Favicons and app icons use the emblem without
text. Small-size artwork must retain both colors, widen the arc gaps where the
pixel grid requires it, and keep the packet and open core distinct.
Treat the emblem as decorative when visible `SHD.build` text names the product.
Give an emblem-only home link the accessible name `SHD.build home`. Preserve
the approved colors and full opacity in focus, disabled, and compact contexts;
use text or control styling to communicate state instead of fading the mark.
The Field Manual White core and Signal Orange arcs must remain distinct from
their background at every delivered size.
## Colors ## Colors
The palette uses Operations Black, Terminal Graphite, Field Manual White, rare The palette uses Operations Black, Terminal Graphite, Field Manual White, rare
+18
View File
@@ -1123,3 +1123,21 @@ CREATE TABLE IF NOT EXISTS external_service_alert_deliveries (
); );
CREATE INDEX IF NOT EXISTS external_service_alert_pending_idx CREATE INDEX IF NOT EXISTS external_service_alert_pending_idx
ON external_service_alert_deliveries(delivered_at,created_at); ON external_service_alert_deliveries(delivered_at,created_at);
CREATE TABLE IF NOT EXISTS site_launch_settings (
id TEXT PRIMARY KEY CHECK(id='primary'),
coming_soon_enabled INTEGER NOT NULL DEFAULT 1 CHECK(coming_soon_enabled IN (0,1)),
account_creation_enabled INTEGER NOT NULL DEFAULT 1 CHECK(account_creation_enabled IN (0,1)),
news_enabled INTEGER NOT NULL DEFAULT 1 CHECK(news_enabled IN (0,1)),
database_enabled INTEGER NOT NULL DEFAULT 1 CHECK(database_enabled IN (0,1)),
builds_enabled INTEGER NOT NULL DEFAULT 1 CHECK(builds_enabled IN (0,1)),
guides_enabled INTEGER NOT NULL DEFAULT 1 CHECK(guides_enabled IN (0,1)),
builder_enabled INTEGER NOT NULL DEFAULT 1 CHECK(builder_enabled IN (0,1)),
updated_at TEXT NOT NULL,
updated_by TEXT REFERENCES users(id)
);
INSERT OR IGNORE INTO site_launch_settings(
id,coming_soon_enabled,account_creation_enabled,news_enabled,database_enabled,
builds_enabled,guides_enabled,builder_enabled,updated_at
) VALUES('primary',1,1,1,1,1,1,1,'2026-07-27T00:00:00.000Z');
INSERT OR IGNORE INTO permissions(id,name,description)
VALUES('permission-site-settings-manage','site.settings.manage','Manage launch mode, account creation, and public section availability');
+40
View File
@@ -121,6 +121,46 @@ existing embedded-database connection instead of opening the locked database
from a second process. The endpoint runs automated system messages, Oversight from a second process. The endpoint runs automated system messages, Oversight
consolidation, and email digest checks and is not published through a host port. consolidation, and email digest checks and is not published through a host port.
## Prelaunch operation
The launch-control migration creates `site_launch_settings` with these defaults
for a database that does not contain the row:
- Coming Soon: enabled
- Account creation: enabled
- News, Database, Builds, Guides, and Builder: enabled
The migration uses an insert-if-missing seed. Later deployments preserve the
operator's settings, and configuration changes take effect without a restart.
A full environment bootstrap copies the source row. Canonical ISAC game-data
imports leave the target environment's launch settings unchanged.
Use this operator flow after the first deployment:
1. Open `/sign-in`; the launch gate keeps that route available.
2. Sign in with an existing Administrator account.
3. Open ISAC → System Configuration → Site Controls.
4. Set account creation and public-section availability.
5. Review the complete change set, then confirm it.
6. Use `PREVIEW PAGE` to inspect `/coming-soon` with the authenticated
`ENTER FULL SITE` return action. The preview lists each section's current
availability.
7. Verify `/coming-soon` and the affected public routes in a signed-out browser
session before public release. Sign in and verify the same disabled routes
remain unavailable.
Keep Coming Soon enabled while preparing production. Section controls can be
set in advance because the launch gate takes precedence for signed-out traffic.
Set Account Creation to Disabled if prelaunch access must remain limited to
existing identities. Switch the Launch Gate to Public Launch after verification.
Disabled sections remain absent from navigation and unavailable by direct URL
for every user, including authenticated agents. Authentication bypasses only
the Coming Soon gate.
Turn Coming Soon on to restore the promotional gate. Site-control changes append
`system_configuration / site_access.updated` audit events, including the
operator and complete before/after settings.
## Initial development-data bootstrap ## Initial development-data bootstrap
Initialize the first staging release from the development database, including Initialize the first staging release from the development database, including
+108 -18
View File
@@ -1,9 +1,10 @@
# SHD.build Design System # SHD.build Design System
This document defines the visual language for SHD.build, the independent This document defines the visual language for SHD.build, an independent
Division Resurgence community for builds, guides, game data, news, and Division Resurgence data-and-tools site for builds, guides, game data, news,
discussion. New pages use the same tactical intelligence network as the admin and discussion. New pages use the same tactical intelligence network as the
dashboard. Public pages may use more space and larger editorial typography. admin dashboard. Public pages may use more space and larger editorial
typography.
## Design principles ## Design principles
@@ -16,6 +17,43 @@ dashboard. Public pages may use more space and larger editorial typography.
underlines. Communicate hover and focus through SHD accent color, borders, underlines. Communicate hover and focus through SHD accent color, borders,
brightness, and the required focus-visible outline. brightness, and the required focus-visible outline.
## Brand identity
SHD.build uses the two-color Core Segments identity. The emblem combines the
orange Division signal with ISAC's analytic role in the product:
- Three unequal Signal Orange arc segments form the outer circle. Each segment
has square ends.
- The widest break sits in the upper-right quadrant and contains one detached
Signal Orange square, which represents a data packet.
- A Field Manual White open square forms the ISAC core at the center.
Keep the emblem in Signal Orange and Field Manual White on Operations Black,
Terminal Black, or another approved dark surface. SHD.build has no one-color
emblem. Do not close the arc gaps, equalize the segments, remove the data
packet, fill the ISAC core, add letters to the emblem, or recolor either part.
Use a dark holding surface or omit the emblem when a placement cannot reproduce
both colors.
The standard lockup pairs the emblem with the heavy angular `SHD.build`
wordmark. Set `SHD` in Field Manual White and `.build` in Signal Orange. Place
the uppercase descriptor `RESURGENCE DATA & TOOLS` below the wordmark in
restrained telemetry type. Do not substitute another descriptor.
Use the complete lockup in the 76px global header and shared footer. The header
uses a 38px emblem; keep the wordmark and descriptor compact enough to preserve
the navigation grid. Mobile headers may use the emblem alone when the complete
lockup would crowd navigation. Favicons and app icons use the emblem without
text. Small-size artwork must retain both colors, widen the arc gaps where the
pixel grid requires it, and keep the packet and open core distinct.
Treat the emblem as decorative when visible `SHD.build` text names the product.
Give an emblem-only home link the accessible name `SHD.build home`. Preserve
the approved colors and full opacity in focus, disabled, and compact contexts;
use text or control styling to communicate state instead of fading the mark.
The Field Manual White core and Signal Orange arcs must remain distinct from
their background at every delivered size.
### Routed error console ### Routed error console
Expected HTTP errors and unexpected server faults use one shared SHD exception Expected HTTP errors and unexpected server faults use one shared SHD exception
@@ -64,19 +102,18 @@ from the orange links reserved for SHD navigation and legal resources. Support
and legal links form a compact horizontal rail that may wrap at intermediate and legal links form a compact horizontal rail that may wrap at intermediate
widths and becomes a two-column touch-friendly grid on narrow mobile screens. widths and becomes a two-column touch-friendly grid on narrow mobile screens.
The footer repeats the complete SHD brand lockup used by the global header: The footer repeats the complete Core Segments lockup used by the global header:
double-ring SHD mark, white `SHD`, orange `.build`, and the restrained two-color emblem, Field Manual White `SHD`, Signal Orange `.build`, and the
`RESURGENCE COMMUNITY` descriptor. Do not substitute a plain text wordmark or `RESURGENCE DATA & TOOLS` descriptor. Do not substitute a plain text wordmark,
color the complete product name orange. drop either emblem color, or color the complete product name orange.
### Community platform positioning ### Platform positioning
SHD.build is a Division Resurgence community. Primary product language and SHD.build provides Division Resurgence data and tools for players. Primary
global navigation represent five durable product language and global navigation represent five durable channels: News,
channels: News, Database, Builds, Guides, and Builder. Loadout-specific terms Database, Builds, Guides, and Builder. Loadout-specific terms remain
remain appropriate inside build routes, but the global brand descriptor, appropriate inside build routes. The global brand descriptor, homepage
homepage metadata, hero, sign-in introduction, and footer describe the broader metadata, hero, sign-in introduction, and footer describe the broader toolset.
community platform.
The homepage uses a community-channel grid to establish this information The homepage uses a community-channel grid to establish this information
architecture. Each channel has an operational code, plain-language purpose, architecture. Each channel has an operational code, plain-language purpose,
@@ -265,9 +302,10 @@ explains it.
At viewports up to 900px, replace the hidden desktop link rail and account At viewports up to 900px, replace the hidden desktop link rail and account
controls with a labeled square `MENU` control in the shared site controls with a labeled square `MENU` control in the shared site
header. Activating it opens a right-side SHD navigation panel over a dismissible header. Activating it opens a right-side SHD navigation panel over a dismissible
backdrop. The panel includes Home, Discover, Builder, Guides, Sign In or My backdrop. The panel includes Home, each enabled public destination, Sign In or
Profile, and ISAC when authorized; authenticated agents also receive identity My Profile, and ISAC when authorized. Authenticated agents also receive identity
state and Sign Out inside the panel. state and Sign Out inside the panel. Section availability controls both desktop
and mobile navigation.
Use numbered full-width navigation rows, explicit active-route text styling, Use numbered full-width navigation rows, explicit active-route text styling,
orange action indicators, and minimum 44px interaction targets. Opening moves orange action indicators, and minimum 44px interaction targets. Opening moves
@@ -294,6 +332,53 @@ squeezed beside long module headings on mobile.
- Community build cards retain the colored top rule, abstract loadout artwork, - Community build cards retain the colored top rule, abstract loadout artwork,
concise tags, agent attribution, and a full-width terminal action. concise tags, agent attribution, and a full-width terminal action.
### Prelaunch network shell
Use the standalone prelaunch shell for `/coming-soon`. It replaces the global
application shell while the public launch gate is active, so visitors see one
clear deployment message instead of navigation into gated sections. Keep the
SHD.build mark, a visible network-state block, one primary access action, the
Discord community action, the deployment manifest, and legal links. Signed-out
visitors receive `AGENT SIGN IN`; an authenticated operator previewing the page
receives `ENTER FULL SITE`.
The desktop composition pairs the launch statement with a technical network
visual. Use the shared grid background, square borders, restrained orange
emphasis, and telemetry labels. Orange identifies the primary action and active
signal paths. Green may reinforce a staged or available state; red may reinforce
an unavailable channel. Every state also needs a text label.
The deployment manifest lists News, Database, Builds, Guides, and Builder with
plain-language descriptions. A visitor who follows a disabled-section URL sees
the affected section name in a status notice. The page must state whether new
agent registration is open without implying that sign-in for existing accounts
has closed. The manifest uses the saved section controls as its source of truth
and marks unavailable sections in text. An authenticated preview follows the
same availability states.
At widths below 900px, stack the launch statement above the network visual and
collapse the manifest to one column as space requires. Preserve 44px action
targets and keep status text within the viewport. Reduce the radar sweep and
other ambient motion under `prefers-reduced-motion`; retain the static rings,
nodes, and labels.
ISAC Site Controls use three connected command panels: Launch Gate, Account
Creation, and Public Sections. Use `SegmentedChoice` for each finite state.
Display the pending-change count outside the panels and require a confirmation
modal that lists every changed setting as previous state, arrow, and proposed
state. A successful save closes the modal and refreshes the network-state
summary. Keep a `PREVIEW PAGE` link in the command toolbar so authenticated
operators can inspect the promotional page without ending their session.
Operators need text for enabled, disabled, coming-soon, and public states; color
supports those labels.
The Launch Gate controls signed-out entry to the application. Public Section
controls apply to everyone. When an operator disables News, Database, Builds,
Guides, or Builder, remove that destination from global navigation for signed-in
and signed-out users and block its route family on the server. The promotional
page identifies a disabled destination when a user follows an old bookmark or
direct URL. Do not present authentication as a way around a disabled section.
### Agent and authentication surfaces ### Agent and authentication surfaces
- Account pages should read as an agent dossier: identity and access at the top, - Account pages should read as an agent dossier: identity and access at the top,
@@ -390,6 +475,11 @@ squeezed beside long module headings on mobile.
- Authentication uses one unified access screen rather than separate sign-in and - Authentication uses one unified access screen rather than separate sign-in and
registration destinations. Verified email and first-time OAuth identities may registration destinations. Verified email and first-time OAuth identities may
create an account; returning identities sign into the matching account. create an account; returning identities sign into the matching account.
- With account creation disabled, keep returning-agent methods available, hide
the password `CREATE ACCOUNT` mode, and show a concise registration-state
notice. Provider and email controls must explain that unknown identities
cannot create an account without suggesting that existing identities lost
access.
- Password access uses an explicit `SIGN IN` / `CREATE ACCOUNT` mode switch - Password access uses an explicit `SIGN IN` / `CREATE ACCOUNT` mode switch
inside the fallback panel. Account creation separates the public display name inside the fallback panel. Account creation separates the public display name
from the private ASCII login username. from the private ASCII login username.
+41
View File
@@ -42,6 +42,47 @@ provide a persistent writable filesystem and can override the location with
`TDR_DATABASE_PATH`. This rules out ephemeral serverless filesystems unless a `TDR_DATABASE_PATH`. This rules out ephemeral serverless filesystems unless a
persistent volume is attached. persistent volume is attached.
## Site launch controls
The singleton `site_launch_settings` row stores the public launch gate, account
creation state, and availability for News, Database, Builds, Guides, and
Builder. Schema migration creates the row with Coming Soon enabled, account
creation enabled, and all five sections enabled. The application caches the row
for five seconds and clears the process cache after an operator saves changes.
Agents with `site.settings.manage` edit the row from ISAC → System
Configuration → Site Controls. The save action requires an explicit value for
all seven controls and a confirmation token. A successful update records
`system_configuration / site_access.updated` with the operator, target row, and
complete before/after snapshots.
The request hook applies the launch gate to anonymous traffic and section
availability to every request:
- With Coming Soon enabled, anonymous `GET` and `HEAD` requests redirect to
`/coming-soon`. Anonymous mutations outside exempt routes receive a JSON 404.
- Authenticated agents bypass the Coming Soon gate and enter the application.
- A disabled public section blocks its route family for every user, including
authenticated agents: `/news`, `/database`, `/builds`, `/guides`, or
`/builder`. `GET` and `HEAD` requests redirect to `/coming-soon` with the
unavailable section identified. Mutations receive a JSON 404.
- Authentication callbacks, sign-in, contact, legal pages, health endpoints,
scheduler endpoints, application assets, and the prelaunch page remain
reachable without a session.
The global navigation omits disabled sections for every user. Server checks
remain authoritative for direct URLs and mutations. An authenticated operator
may open `/coming-soon` to preview the promotional page, but the preview does
not override section availability.
Account creation uses a separate control. Closing registration blocks password
registration, a magic-link request for an unknown email, and Discord OAuth for
an unknown Discord identity. Existing password, email, and Discord identities
can still sign in. The sign-in page hides the password registration form and
explains registration closure. This guard runs on the server, so a crafted form
submission or OAuth callback cannot create an account while registration is
closed.
## Increment plan ## Increment plan
1. Product shell and anonymous local-draft builder. 1. Product shell and anonymous local-draft builder.
+11
View File
@@ -24,6 +24,7 @@
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"opentype.js": "^2.0.0",
"svelte": "^5.56.1", "svelte": "^5.56.1",
"svelte-check": "^4.6.0", "svelte-check": "^4.6.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
@@ -2878,6 +2879,16 @@
"node": ">=12.20.0" "node": ">=12.20.0"
} }
}, },
"node_modules/opentype.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz",
"integrity": "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==",
"dev": true,
"license": "MIT",
"bin": {
"ot": "bin/ot"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+2
View File
@@ -8,6 +8,7 @@
"build:app": "vite build", "build:app": "vite build",
"build:staging": "dotenvx run -f .env.staging -- vite build", "build:staging": "dotenvx run -f .env.staging -- vite build",
"build": "dotenvx run -f .env.production -- vite build", "build": "dotenvx run -f .env.production -- vite build",
"brand:render": "node scripts/render-brand-assets.mjs",
"start": "node build", "start": "node build",
"preview": "node build", "preview": "node build",
"db:migrate": "dotenvx run -f .env.development -- node scripts/db-migrate.mjs", "db:migrate": "dotenvx run -f .env.development -- node scripts/db-migrate.mjs",
@@ -60,6 +61,7 @@
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"opentype.js": "^2.0.0",
"svelte": "^5.56.1", "svelte": "^5.56.1",
"svelte-check": "^4.6.0", "svelte-check": "^4.6.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
+163
View File
@@ -0,0 +1,163 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import opentype from 'opentype.js';
import sharp from 'sharp';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const outputDirectory = path.join(root, 'static', 'brand');
const fontDirectory = path.join(root, 'node_modules', '@fontsource', 'barlow', 'files');
const heavy = opentype.parse(fs.readFileSync(path.join(fontDirectory, 'barlow-latin-900-normal.woff')).buffer);
const medium = opentype.parse(fs.readFileSync(path.join(fontDirectory, 'barlow-latin-700-normal.woff')).buffer);
fs.mkdirSync(outputDirectory, { recursive: true });
const colors = {
background: '#0a0c0d',
surface: '#111415',
border: '#303536',
grid: '#2a3031',
orange: '#f6a821',
white: '#ecebe5'
};
function trackedPath(font, value, x, baseline, size, tracking = 0) {
let cursor = x;
let data = '';
for (const character of value) {
const glyph = font.charToGlyph(character);
data += glyph.getPath(cursor, baseline, size).toPathData(2);
cursor += glyph.advanceWidth * size / font.unitsPerEm + tracking;
}
return { data, width: cursor - x - tracking };
}
function emblem(x, y, size) {
const scale = size / 100;
return `
<g transform="translate(${x} ${y}) scale(${scale})">
<g fill="none" stroke="${colors.orange}" stroke-width="12" stroke-linecap="butt">
<path d="M73 20A38 38 0 0 0 15 63"/>
<path d="M85 37A38 38 0 0 1 55 88"/>
<path d="M42 87A38 38 0 0 1 18 70"/>
</g>
<rect x="72" y="23" width="10" height="10" fill="${colors.orange}"/>
<path d="M62 35H35V65H62M68 43V57" fill="none" stroke="${colors.white}" stroke-width="9" stroke-linecap="butt" stroke-linejoin="miter"/>
</g>`;
}
async function writePng(svg, filename, width, height) {
await sharp(Buffer.from(svg))
.resize(width, height, { fit: 'fill' })
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toFile(path.join(outputDirectory, filename));
}
const transparentLogo = `
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
${emblem(72, 72, 880)}
</svg>`;
const serverIcon = `
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<rect width="1024" height="1024" fill="${colors.background}"/>
<path d="M0 192H1024M0 512H1024M0 832H1024M192 0V1024M512 0V1024M832 0V1024" stroke="${colors.grid}" stroke-width="2" opacity=".32"/>
<rect x="96" y="96" width="832" height="832" fill="none" stroke="${colors.border}" stroke-width="3"/>
${emblem(132, 132, 760)}
</svg>`;
const shd = trackedPath(heavy, 'SHD', 0, 0, 190, -3);
const build = trackedPath(heavy, '.build', 0, 0, 190, -2);
const descriptor = trackedPath(medium, 'RESURGENCE DATA & TOOLS', 0, 0, 43, 7);
const wordmarkWidth = shd.width + build.width;
const lockupX = 600;
const wordmarkBaseline = 590;
const banner = `
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
<defs>
<radialGradient id="signal" cx="34%" cy="58%" r="54%">
<stop offset="0" stop-color="#2b2416"/>
<stop offset=".44" stop-color="#141617"/>
<stop offset="1" stop-color="${colors.background}"/>
</radialGradient>
<linearGradient id="fade" x1="0" x2="1">
<stop stop-color="${colors.orange}" stop-opacity=".22"/>
<stop offset=".7" stop-color="${colors.orange}" stop-opacity="0"/>
</linearGradient>
</defs>
<rect width="1920" height="1080" fill="${colors.background}"/>
<rect y="96" width="1920" height="984" fill="url(#signal)"/>
<path d="M0 96H1920M0 864H1920M192 96V1080M1728 96V1080" stroke="${colors.border}" stroke-width="2"/>
<path d="M0 224H1920M0 352H1920M0 480H1920M0 608H1920M0 736H1920M320 96V1080M448 96V1080M576 96V1080M704 96V1080M832 96V1080M960 96V1080M1088 96V1080M1216 96V1080M1344 96V1080M1472 96V1080M1600 96V1080" stroke="${colors.grid}" stroke-width="1" opacity=".22"/>
<path d="M192 864H1260" stroke="url(#fade)" stroke-width="3"/>
<path d="M1534 210h194v194" fill="none" stroke="${colors.orange}" stroke-width="4" opacity=".42"/>
<path d="M386 756A272 272 0 0 1 386 212" fill="none" stroke="${colors.border}" stroke-width="2"/>
${emblem(188, 314, 330)}
<g transform="translate(${lockupX} ${wordmarkBaseline})">
<path d="${shd.data}" fill="${colors.white}"/>
<path d="${build.data}" fill="${colors.orange}" transform="translate(${shd.width} 0)"/>
<rect y="34" width="${Math.max(wordmarkWidth, descriptor.width)}" height="2" fill="${colors.border}"/>
<path d="${descriptor.data}" fill="${colors.white}" transform="translate(0 98)"/>
</g>
<g transform="translate(1540 820)">
<rect width="188" height="56" fill="${colors.surface}" stroke="${colors.border}" stroke-width="2"/>
<rect x="16" y="23" width="10" height="10" fill="${colors.orange}"/>
<path d="M42 28H158" stroke="${colors.orange}" stroke-width="2"/>
<path d="M166 22l8 6-8 6" fill="none" stroke="${colors.orange}" stroke-width="2"/>
</g>
</svg>`;
const inviteSplash = `
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
<defs>
<radialGradient id="left-signal" cx="8%" cy="52%" r="58%">
<stop offset="0" stop-color="#3b2d15"/>
<stop offset=".38" stop-color="#171819"/>
<stop offset="1" stop-color="${colors.background}"/>
</radialGradient>
<radialGradient id="right-signal" cx="94%" cy="45%" r="50%">
<stop offset="0" stop-color="#241e13"/>
<stop offset=".48" stop-color="#111415"/>
<stop offset="1" stop-color="${colors.background}" stop-opacity="0"/>
</radialGradient>
<linearGradient id="relay-left" x1="0" x2="1">
<stop stop-color="${colors.orange}" stop-opacity=".8"/>
<stop offset="1" stop-color="${colors.orange}" stop-opacity="0"/>
</linearGradient>
<linearGradient id="relay-right" x1="1" x2="0">
<stop stop-color="${colors.orange}" stop-opacity=".55"/>
<stop offset="1" stop-color="${colors.orange}" stop-opacity="0"/>
</linearGradient>
</defs>
<rect width="1920" height="1080" fill="${colors.background}"/>
<rect width="1920" height="1080" fill="url(#left-signal)"/>
<rect width="1920" height="1080" fill="url(#right-signal)"/>
<path d="M0 120H1920M0 960H1920M160 0V1080M1760 0V1080" stroke="${colors.border}" stroke-width="2"/>
<path d="M0 288H1920M0 456H1920M0 624H1920M0 792H1920M328 0V1080M496 0V1080M664 0V1080M832 0V1080M1000 0V1080M1168 0V1080M1336 0V1080M1504 0V1080" stroke="${colors.grid}" stroke-width="1" opacity=".16"/>
<g opacity=".92">${emblem(-92, 224, 620)}</g>
<g opacity=".66">${emblem(1465, 326, 410)}</g>
<path d="M0 170H605" stroke="url(#relay-left)" stroke-width="4"/>
<path d="M0 910H500" stroke="url(#relay-left)" stroke-width="3"/>
<path d="M1335 188H1920" stroke="url(#relay-right)" stroke-width="3"/>
<path d="M1450 892H1920" stroke="url(#relay-right)" stroke-width="4"/>
<g fill="${colors.orange}">
<rect x="560" y="164" width="12" height="12"/>
<rect x="486" y="904" width="10" height="10"/>
<rect x="1348" y="182" width="12" height="12"/>
<rect x="1436" y="886" width="12" height="12"/>
</g>
<path d="M610 170h90M1310 188h-92M540 910h118M1400 892h-112" stroke="${colors.white}" stroke-width="2" opacity=".22"/>
<rect x="650" y="120" width="620" height="840" fill="${colors.background}" opacity=".42"/>
</svg>`;
await writePng(transparentLogo, 'shd-core-segments.png', 1024, 1024);
await writePng(serverIcon, 'shd-build-discord-icon.png', 1024, 1024);
await writePng(banner, 'shd-build-discord-banner-1920x1080.png', 1920, 1080);
await writePng(inviteSplash, 'shd-build-discord-invite-background-1920x1080.png', 1920, 1080);
await sharp(path.join(outputDirectory, 'shd-build-discord-banner-1920x1080.png'))
.resize(960, 540)
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toFile(path.join(outputDirectory, 'shd-build-discord-banner-960x540.png'));
console.log(`Rendered brand assets to ${outputDirectory}`);
+210 -19
View File
File diff suppressed because one or more lines are too long
+20 -1
View File
@@ -2,6 +2,7 @@ import { json, type Handle, type HandleServerError } from '@sveltejs/kit';
import { stringify } from 'devalue'; import { stringify } from 'devalue';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { getSessionUser } from '$lib/server/auth/session'; import { getSessionUser } from '$lib/server/auth/session';
import { getSiteAccessBlock, getSiteAccessSettings } from '$lib/server/site-access';
import { DATA_MODULES, proposalActionResult, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation'; import { DATA_MODULES, proposalActionResult, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
@@ -9,6 +10,24 @@ export const handle: Handle = async ({ event, resolve }) => {
const location = `/isac${event.url.pathname.slice('/admin'.length)}${event.url.search}`; const location = `/isac${event.url.pathname.slice('/admin'.length)}${event.url.search}`;
return new Response(null, { status: 308, headers: { location } }); return new Response(null, { status: 308, headers: { location } });
} }
const siteSettings = await getSiteAccessSettings();
const sessionUser = await getSessionUser(event.cookies);
const accessBlock = getSiteAccessBlock(siteSettings, event.url.pathname, Boolean(sessionUser));
if (accessBlock) {
const target = new URL('/coming-soon', event.url);
if (accessBlock.kind === 'section') target.searchParams.set('unavailable', accessBlock.section);
if (event.request.method === 'GET' || event.request.method === 'HEAD') {
return new Response(null, { status: 303, headers: { location: `${target.pathname}${target.search}` } });
}
return json(
{
error: accessBlock.kind === 'section'
? 'This section is not available.'
: 'The public site is not available yet.'
},
{ status: 404 }
);
}
if (event.request.method === 'POST') { if (event.request.method === 'POST') {
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/); const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
const module = match?.[1] as DataModule | undefined; const module = match?.[1] as DataModule | undefined;
@@ -17,7 +36,7 @@ export const handle: Handle = async ({ event, resolve }) => {
// Publishing calculation rules is never a contributor proposal. The // Publishing calculation rules is never a contributor proposal. The
// action has its own dedicated permission and confirmation contract. // action has its own dedicated permission and confirmation contract.
if (module === 'calculations' && action === 'publishRevision') return resolve(event); if (module === 'calculations' && action === 'publishRevision') return resolve(event);
const user = await getSessionUser(event.cookies); const user = sessionUser;
const access = await shouldModerateDataMutation(user); const access = await shouldModerateDataMutation(user);
if (!access.canManage && access.canPropose && user) { if (!access.canManage && access.canPropose && user) {
if (!access.contributorEnabled) { if (!access.contributorEnabled) {
+22 -14
View File
@@ -1,23 +1,32 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import BrandLockup from '$lib/components/BrandLockup.svelte';
import PendingForm from '$lib/components/PendingForm.svelte'; import PendingForm from '$lib/components/PendingForm.svelte';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
let { user, isacAccess, notificationSummary, children }: { let { user, isacAccess, notificationSummary, siteAccess, children }: {
user: { username: string; displayName: string; role: string; avatarUrl: string } | null; user: { username: string; displayName: string; role: string; avatarUrl: string } | null;
isacAccess: boolean; isacAccess: boolean;
notificationSummary: { unread: number }; notificationSummary: { unread: number };
siteAccess: { comingSoonEnabled: boolean; sections: Record<string, boolean> };
children: Snippet; children: Snippet;
} = $props(); } = $props();
const links = [ const allLinks = [
{ href: '/news', label: 'News' }, { href: '/news', label: 'News', key: 'news' },
{ href: '/database', label: 'Database' }, { href: '/database', label: 'Database', key: 'database' },
{ href: '/builds', label: 'Builds' }, { href: '/builds', label: 'Builds', key: 'builds' },
{ href: '/guides', label: 'Guides' }, { href: '/guides', label: 'Guides', key: 'guides' },
{ href: '/builder', label: 'Builder' } { href: '/builder', label: 'Builder', key: 'builder' }
]; ];
const links = $derived(allLinks.filter((link) => siteAccess.sections[link.key]));
const launchSupportPages = ['/contact', '/dmca', '/terms', '/sign-in'];
const homeHref = $derived(
siteAccess.comingSoonEnabled && launchSupportPages.some((path) => page.url.pathname === path || page.url.pathname.startsWith(`${path}/`))
? '/coming-soon'
: '/'
);
let showCookieNotice = $state(false); let showCookieNotice = $state(false);
let mobileOpen = $state(false); let mobileOpen = $state(false);
@@ -102,8 +111,8 @@
<svelte:window onkeydown={handleNavigationKey} onclick={closeAccountFromOutside}/> <svelte:window onkeydown={handleNavigationKey} onclick={closeAccountFromOutside}/>
<header class="site-header"> <header class="site-header">
<a class="brand" href="/" aria-label="SHD.build home"> <a class="brand" href={homeHref} aria-label="SHD.build home">
<span class="brand-mark"><i></i><b>SHD</b></span><span class="brand-name">SHD<span>.build</span><small>RESURGENCE COMMUNITY</small></span> <BrandLockup />
</a> </a>
<nav aria-label="Main navigation"> <nav aria-label="Main navigation">
{#each links as link} {#each links as link}
@@ -162,8 +171,8 @@
<div class="mobile-nav-layer"> <div class="mobile-nav-layer">
<button class="mobile-nav-backdrop" type="button" aria-label="Close navigation" onclick={closeMobileNavigation}></button> <button class="mobile-nav-backdrop" type="button" aria-label="Close navigation" onclick={closeMobileNavigation}></button>
<nav id="mobile-navigation" class="mobile-navigation" aria-label="Mobile navigation" bind:this={mobilePanel}> <nav id="mobile-navigation" class="mobile-navigation" aria-label="Mobile navigation" bind:this={mobilePanel}>
<div class="mobile-nav-heading"><div><span>SHD COMMUNITY</span><strong>NAVIGATION</strong></div><b>ONLINE</b></div> <div class="mobile-nav-heading"><div><span>SHD NETWORK</span><strong>NAVIGATION</strong></div><b>ONLINE</b></div>
<a class:active={page.url.pathname === '/'} href="/"><small>00</small><span>Home</span><b>›</b></a> <a class:active={page.url.pathname === '/' || page.url.pathname === '/coming-soon'} href={homeHref}><small>00</small><span>Home</span><b>›</b></a>
{#each links as link, index} {#each links as link, index}
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}><small>{String(index + 1).padStart(2, '0')}</small><span>{link.label}</span><b>›</b></a> <a class:active={page.url.pathname.startsWith(link.href)} href={link.href}><small>{String(index + 1).padStart(2, '0')}</small><span>{link.label}</span><b>›</b></a>
{/each} {/each}
@@ -186,9 +195,8 @@
<footer class="site-footer"> <footer class="site-footer">
<div> <div>
<a class="brand footer-brand" href="/" aria-label="SHD.build home"> <a class="brand footer-brand" href={homeHref} aria-label="SHD.build home">
<span class="brand-mark"><i></i><b>SHD</b></span> <BrandLockup size="footer" />
<span class="brand-name">SHD<span>.build</span><small>RESURGENCE COMMUNITY</small></span>
</a> </a>
<p>Independent builds, guides, game data, news, and community intelligence for The Division Resurgence.</p> <p>Independent builds, guides, game data, news, and community intelligence for The Division Resurgence.</p>
</div> </div>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
let { size = '100%' }: { size?: string } = $props();
</script>
<svg class="brand-emblem" viewBox="0 0 100 100" aria-hidden="true" style:width={size} style:height={size}>
<g fill="none" stroke="#f6a821" stroke-width="12" stroke-linecap="butt">
<path d="M73 20A38 38 0 0 0 15 63"/>
<path d="M85 37A38 38 0 0 1 55 88"/>
<path d="M42 87A38 38 0 0 1 18 70"/>
</g>
<rect x="72" y="23" width="10" height="10" fill="#f6a821"/>
<path d="M62 35H35V65H62M68 43V57" fill="none" stroke="#ecebe5" stroke-width="9" stroke-linecap="butt" stroke-linejoin="miter"/>
</svg>
<style>
.brand-emblem {
display: block;
flex: 0 0 auto;
overflow: visible;
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts">
import BrandEmblem from '$lib/components/BrandEmblem.svelte';
let {
size = 'header',
descriptor = 'RESURGENCE DATA & TOOLS'
}: {
size?: 'header' | 'footer' | 'launch';
descriptor?: string;
} = $props();
</script>
<span class="brand-lockup brand-lockup--{size}">
<span class="brand-lockup__emblem"><BrandEmblem /></span>
<span class="brand-lockup__copy">
<span class="brand-lockup__name"><span>SHD</span><b>.build</b></span>
<small>{descriptor}</small>
</span>
</span>
<style>
.brand-lockup {
--brand-emblem-size: 38px;
--brand-name-size: 20px;
display: inline-flex;
align-items: center;
gap: 11px;
min-width: max-content;
}
.brand-lockup__emblem {
display: block;
width: var(--brand-emblem-size);
height: var(--brand-emblem-size);
flex: 0 0 auto;
}
.brand-lockup__copy {
display: grid;
gap: 3px;
}
.brand-lockup__name {
display: flex;
align-items: baseline;
color: var(--shd-text);
font-size: var(--brand-name-size);
font-weight: 900;
letter-spacing: -.025em;
line-height: .82;
}
.brand-lockup__name b {
color: var(--shd-orange);
font-weight: inherit;
}
.brand-lockup small {
color: var(--shd-text-muted);
font-size: 9px;
font-weight: 800;
letter-spacing: .13em;
line-height: 1;
white-space: nowrap;
}
.brand-lockup--footer {
--brand-emblem-size: 36px;
--brand-name-size: 18px;
}
.brand-lockup--launch {
--brand-emblem-size: 42px;
--brand-name-size: 22px;
}
@media (max-width: 430px) {
.brand-lockup--header,
.brand-lockup--launch {
--brand-emblem-size: 36px;
}
.brand-lockup--header .brand-lockup__copy,
.brand-lockup--launch .brand-lockup__copy {
display: none;
}
}
</style>
+197
View File
@@ -0,0 +1,197 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const databaseRow = vi.hoisted(() => ({ value: undefined as Record<string, unknown> | undefined }));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => ({
prepare: vi.fn(async () => ({
get: vi.fn(async () => databaseRow.value)
}))
}))
}));
import {
getSiteAccessBlock,
getSiteAccessSettings,
invalidateSiteAccessSettings,
isLaunchGateExempt,
siteSectionForPath,
type SiteAccessSettings,
type SiteSectionKey
} from './site-access';
function accessSettings(
overrides: Partial<Pick<SiteAccessSettings, 'comingSoonEnabled' | 'accountCreationEnabled'>> & {
sections?: Partial<SiteAccessSettings['sections']>;
} = {}
): SiteAccessSettings {
return {
comingSoonEnabled: overrides.comingSoonEnabled ?? false,
accountCreationEnabled: overrides.accountCreationEnabled ?? true,
sections: {
news: true,
database: true,
builds: true,
guides: true,
builder: true,
...overrides.sections
},
updatedAt: '2026-07-27T00:00:00.000Z'
};
}
describe('siteSectionForPath', () => {
it.each([
['/news', 'news'],
['/news/latest', 'news'],
['/database', 'database'],
['/database/weapons/first-wave-vector', 'database'],
['/builds', 'builds'],
['/builds/shared-loadout', 'builds'],
['/guides', 'guides'],
['/guides/combat-medic', 'guides'],
['/builder', 'builder'],
['/builder/loadout-id', 'builder']
])('maps %s to the %s launch section', (pathname, section) => {
expect(siteSectionForPath(pathname)).toBe(section);
});
it.each(['/', '/newsroom', '/database-tools', '/build', '/guide', '/builder-tools', '/isac'])(
'does not treat %s as a gated section',
(pathname) => {
expect(siteSectionForPath(pathname)).toBeNull();
}
);
});
describe('isLaunchGateExempt', () => {
it.each([
'/coming-soon',
'/sign-in',
'/terms',
'/dmca',
'/contact',
'/auth/discord',
'/auth/discord/callback',
'/api/health/database',
'/api/internal/notifications',
'/_app/immutable/entry/start.js',
'/favicon.svg'
])('keeps %s reachable while the launch gate is active', (pathname) => {
expect(isLaunchGateExempt(pathname)).toBe(true);
});
it.each([
'/',
'/news',
'/isac',
'/coming-soon/preview',
'/sign-in/help',
'/auth',
'/api/health',
'/api/internal',
'/favicon.png'
])('does not exempt %s', (pathname) => {
expect(isLaunchGateExempt(pathname)).toBe(false);
});
});
describe('getSiteAccessBlock', () => {
it('gives the global launch gate priority over a disabled section for anonymous visitors', () => {
const settings = accessSettings({
comingSoonEnabled: true,
sections: { builder: false }
});
expect(getSiteAccessBlock(settings, '/builder', false)).toEqual({ kind: 'launch' });
});
it('lets authenticated users bypass the global launch gate when the requested section is enabled', () => {
const settings = accessSettings({ comingSoonEnabled: true });
expect(getSiteAccessBlock(settings, '/builder', true)).toBeNull();
});
it.each([
['/news', 'news'],
['/database/weapons', 'database'],
['/builds/agent-loadout', 'builds'],
['/guides/combat-medic', 'guides'],
['/builder', 'builder']
] satisfies Array<[string, SiteSectionKey]>)(
'blocks %s for authenticated and anonymous visitors when the %s section is disabled',
(pathname, section) => {
const settings = accessSettings({
sections: { [section]: false }
});
expect(getSiteAccessBlock(settings, pathname, false)).toEqual({ kind: 'section', section });
expect(getSiteAccessBlock(settings, pathname, true)).toEqual({ kind: 'section', section });
}
);
it.each(['/coming-soon', '/sign-in', '/terms', '/dmca', '/contact', '/auth/discord/callback'])(
'keeps exempt route %s available while the global gate is active',
(pathname) => {
const settings = accessSettings({ comingSoonEnabled: true });
expect(getSiteAccessBlock(settings, pathname, false)).toBeNull();
expect(getSiteAccessBlock(settings, pathname, true)).toBeNull();
}
);
it('allows an enabled section when the global launch gate is off', () => {
const settings = accessSettings();
expect(getSiteAccessBlock(settings, '/news', false)).toBeNull();
expect(getSiteAccessBlock(settings, '/news', true)).toBeNull();
});
});
describe('getSiteAccessSettings', () => {
beforeEach(() => {
databaseRow.value = undefined;
invalidateSiteAccessSettings();
});
it('fails safely to the launch page while leaving registration and sections staged on a new database', async () => {
await expect(getSiteAccessSettings()).resolves.toEqual({
comingSoonEnabled: true,
accountCreationEnabled: true,
sections: {
news: true,
database: true,
builds: true,
guides: true,
builder: true
},
updatedAt: '2026-07-27T00:00:00.000Z'
});
});
it('normalizes persisted integer flags into the public settings shape', async () => {
databaseRow.value = {
coming_soon_enabled: 0,
account_creation_enabled: 0,
news_enabled: 1,
database_enabled: 0,
builds_enabled: 1,
guides_enabled: 0,
builder_enabled: 1,
updated_at: '2026-08-01T12:30:00.000Z'
};
await expect(getSiteAccessSettings()).resolves.toEqual({
comingSoonEnabled: false,
accountCreationEnabled: false,
sections: {
news: true,
database: false,
builds: true,
guides: false,
builder: true
},
updatedAt: '2026-08-01T12:30:00.000Z'
});
});
});
+85
View File
@@ -0,0 +1,85 @@
import { getDatabase } from '$lib/server/database';
export const SITE_SECTION_KEYS = ['news', 'database', 'builds', 'guides', 'builder'] as const;
export type SiteSectionKey = (typeof SITE_SECTION_KEYS)[number];
export type SiteAccessSettings = {
comingSoonEnabled: boolean;
accountCreationEnabled: boolean;
sections: Record<SiteSectionKey, boolean>;
updatedAt: string;
};
export type SiteAccessBlock =
| { kind: 'launch' }
| { kind: 'section'; section: SiteSectionKey };
const DEFAULT_SETTINGS: SiteAccessSettings = {
comingSoonEnabled: true,
accountCreationEnabled: true,
sections: { news: true, database: true, builds: true, guides: true, builder: true },
updatedAt: '2026-07-27T00:00:00.000Z'
};
let cached: { expiresAt: number; value: SiteAccessSettings } | null = null;
function normalized(row: Record<string, unknown> | undefined): SiteAccessSettings {
if (!row) return structuredClone(DEFAULT_SETTINGS);
return {
comingSoonEnabled: Boolean(row.coming_soon_enabled),
accountCreationEnabled: Boolean(row.account_creation_enabled),
sections: {
news: Boolean(row.news_enabled),
database: Boolean(row.database_enabled),
builds: Boolean(row.builds_enabled),
guides: Boolean(row.guides_enabled),
builder: Boolean(row.builder_enabled)
},
updatedAt: String(row.updated_at ?? DEFAULT_SETTINGS.updatedAt)
};
}
export function invalidateSiteAccessSettings() {
cached = null;
}
export async function getSiteAccessSettings() {
if (cached && cached.expiresAt > Date.now()) return cached.value;
const database = await getDatabase();
const row = await (await database.prepare("SELECT * FROM site_launch_settings WHERE id='primary'")).get();
const value = normalized(row);
cached = { value, expiresAt: Date.now() + 5_000 };
return value;
}
export function siteSectionForPath(pathname: string): SiteSectionKey | null {
for (const section of SITE_SECTION_KEYS) {
if (pathname === `/${section}` || pathname.startsWith(`/${section}/`)) return section;
}
return null;
}
export function isLaunchGateExempt(pathname: string) {
return pathname === '/coming-soon' ||
pathname === '/sign-in' ||
pathname === '/terms' ||
pathname === '/dmca' ||
pathname === '/contact' ||
pathname.startsWith('/auth/') ||
pathname.startsWith('/api/health/') ||
pathname.startsWith('/api/internal/') ||
pathname.startsWith('/_app/') ||
pathname === '/favicon.svg';
}
export function getSiteAccessBlock(
settings: SiteAccessSettings,
pathname: string,
authenticated: boolean
): SiteAccessBlock | null {
if (isLaunchGateExempt(pathname)) return null;
if (!authenticated && settings.comingSoonEnabled) return { kind: 'launch' };
const section = siteSectionForPath(pathname);
if (section && !settings.sections[section]) return { kind: 'section', section };
return null;
}
+7 -1
View File
@@ -2,8 +2,14 @@ import { getSessionUser } from '$lib/server/auth/session';
import { hasPermission } from '$lib/server/auth/authorization'; import { hasPermission } from '$lib/server/auth/authorization';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
import { notificationSummary } from '$lib/server/notifications'; import { notificationSummary } from '$lib/server/notifications';
import { getSiteAccessSettings } from '$lib/server/site-access';
export const load: LayoutServerLoad = async ({ cookies }) => { export const load: LayoutServerLoad = async ({ cookies }) => {
const user = await getSessionUser(cookies); const user = await getSessionUser(cookies);
return { user, isacAccess: await hasPermission(user, 'isac.access'), notifications: user ? await notificationSummary(user.id) : { unread: 0 } }; return {
user,
isacAccess: await hasPermission(user, 'isac.access'),
notifications: user ? await notificationSummary(user.id) : { unread: 0 },
siteAccess: await getSiteAccessSettings()
};
}; };
+6 -1
View File
@@ -9,6 +9,7 @@
import '../app.css'; import '../app.css';
import AppShell from '$lib/components/AppShell.svelte'; import AppShell from '$lib/components/AppShell.svelte';
import ToastViewport from '$lib/components/ToastViewport.svelte'; import ToastViewport from '$lib/components/ToastViewport.svelte';
import { page } from '$app/state';
let { data, children } = $props(); let { data, children } = $props();
</script> </script>
@@ -16,5 +17,9 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</svelte:head> </svelte:head>
<AppShell user={data.user} isacAccess={data.isacAccess} notificationSummary={data.notifications}>{@render children()}</AppShell> {#if page.url.pathname === '/coming-soon'}
{@render children()}
{:else}
<AppShell user={data.user} isacAccess={data.isacAccess} notificationSummary={data.notifications} siteAccess={data.siteAccess}>{@render children()}</AppShell>
{/if}
<ToastViewport/> <ToastViewport/>
+2 -2
View File
@@ -5,10 +5,10 @@
{ title: 'Status Protocol', author: 'SHD_Warden', mode: 'PVE', rating: 4.7, votes: 76, tags: ['Skill', 'Status', 'Support'], color: '#d36b5c' } { title: 'Status Protocol', author: 'SHD_Warden', mode: 'PVE', rating: 4.7, votes: 76, tags: ['Skill', 'Status', 'Support'], color: '#d36b5c' }
]; ];
</script> </script>
<svelte:head><title>SHD.build — The Division Resurgence Community</title><meta name="description" content="The Division Resurgence community for builds, guides, game data, news, and agent discussion." /></svelte:head> <svelte:head><title>SHD.build — The Division Resurgence Data & Tools</title><meta name="description" content="Builds, guides, game data, news, and agent tools for The Division Resurgence." /></svelte:head>
<main> <main>
<section class="hero"> <section class="hero">
<div class="hero-copy"><p class="eyebrow"><span></span> THE DIVISION RESURGENCE COMMUNITY</p><h1>CONNECT.<br /><em>ADAPT. RISE.</em></h1><p class="lede">Build stronger agents, learn from field-tested guides, explore the game database, follow Resurgence news, and share intelligence with the community.</p><div class="hero-actions"><a class="primary" href="/builder">CREATE A BUILD <span>+</span></a><a class="secondary" href="/news">ENTER THE NETWORK ↓</a></div><div class="metrics community-metrics"><div><strong>BUILD</strong><span>LOADOUT LAB</span></div><div><strong>LEARN</strong><span>FIELD GUIDES</span></div><div><strong>TRACK</strong><span>DATA & NEWS</span></div></div></div> <div class="hero-copy"><p class="eyebrow"><span></span> THE DIVISION RESURGENCE DATA & TOOLS</p><h1>CONNECT.<br /><em>ADAPT. RISE.</em></h1><p class="lede">Build stronger agents, learn from field-tested guides, explore the game database, follow Resurgence news, and share intelligence with the community.</p><div class="hero-actions"><a class="primary" href="/builder">CREATE A BUILD <span>+</span></a><a class="secondary" href="/news">ENTER THE NETWORK ↓</a></div><div class="metrics community-metrics"><div><strong>BUILD</strong><span>LOADOUT LAB</span></div><div><strong>LEARN</strong><span>FIELD GUIDES</span></div><div><strong>TRACK</strong><span>DATA & NEWS</span></div></div></div>
<div class="loadout-preview community-preview"><div class="scan-lines"></div><div class="preview-top"><span>COMMUNITY INTELLIGENCE</span><b>SHD // LIVE</b></div><div class="agent"><div class="orbit orbit-one"></div><div class="orbit orbit-two"></div><div class="agent-shape"><span>SHD</span></div>{#each ['BUILDS','GUIDES','DATA','NEWS'] as label,i}<a href={['/builds','/guides','/database','/news'][i]} class="gear-node n-{i}"><small>{label}</small><b>{['OPTIMIZE','LEARN','REFERENCE','UPDATES'][i]}</b></a>{/each}</div><div class="preview-name"><small>COMMUNITY // ONLINE</small><strong>RESURGENCE INTELLIGENCE HUB</strong><span>BUILT FOR AGENTS</span></div></div> <div class="loadout-preview community-preview"><div class="scan-lines"></div><div class="preview-top"><span>COMMUNITY INTELLIGENCE</span><b>SHD // LIVE</b></div><div class="agent"><div class="orbit orbit-one"></div><div class="orbit orbit-two"></div><div class="agent-shape"><span>SHD</span></div>{#each ['BUILDS','GUIDES','DATA','NEWS'] as label,i}<a href={['/builds','/guides','/database','/news'][i]} class="gear-node n-{i}"><small>{label}</small><b>{['OPTIMIZE','LEARN','REFERENCE','UPDATES'][i]}</b></a>{/each}</div><div class="preview-name"><small>COMMUNITY // ONLINE</small><strong>RESURGENCE INTELLIGENCE HUB</strong><span>BUILT FOR AGENTS</span></div></div>
</section> </section>
<section class="community-channels"> <section class="community-channels">
@@ -7,6 +7,7 @@ import { redirect } from '@sveltejs/kit';
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'; import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages'; import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
import { getSiteAccessSettings } from '$lib/server/site-access';
const STATE_COOKIE = 'shd_discord_oauth_state'; const STATE_COOKIE = 'shd_discord_oauth_state';
@@ -112,6 +113,9 @@ export const GET: RequestHandler = async ({ cookies, url, fetch, request, getCli
let created = false; let created = false;
if (!user) { if (!user) {
if (!(await getSiteAccessSettings()).accountCreationEnabled) {
redirect(303, '/sign-in?oauth_error=registration_closed');
}
const username = await uniqueUsername(); const username = await uniqueUsername();
const id = randomUUID(); const id = randomUUID();
const now = new Date().toISOString(); const now = new Date().toISOString();
+17
View File
@@ -0,0 +1,17 @@
import { getSessionUser } from '$lib/server/auth/session';
import { getSiteAccessSettings, SITE_SECTION_KEYS } from '$lib/server/site-access';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
const settings = await getSiteAccessSettings();
const unavailable = url.searchParams.get('unavailable');
const unavailableSection = SITE_SECTION_KEYS.find((section) => section === unavailable) ?? null;
if (!user && !settings.comingSoonEnabled && !unavailableSection) redirect(303, '/');
return {
settings,
authenticated: Boolean(user),
unavailable: unavailableSection
};
};
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
import BrandEmblem from '$lib/components/BrandEmblem.svelte';
import BrandLockup from '$lib/components/BrandLockup.svelte';
let { data } = $props();
type ChannelKey = 'news' | 'database' | 'builds' | 'guides' | 'builder';
const channels: Array<{ key: ChannelKey; label: string; detail: string }> = [
{ key: 'news', label: 'FIELD REPORTS', detail: 'Resurgence news and community intelligence' },
{ key: 'database', label: 'ISAC ARCHIVE', detail: 'Gear, weapons, talents, mods, and game data' },
{ key: 'builds', label: 'AGENT LOADOUTS', detail: 'Published builds from the SHD community' },
{ key: 'guides', label: 'FIELD MANUALS', detail: 'Detailed tactics, play styles, and build guides' },
{ key: 'builder', label: 'LOADOUT LAB', detail: 'A precise character and equipment builder' }
];
</script>
<svelte:head>
<title>SHD.build — Network deployment in progress</title>
<meta name="description" content="SHD.build is preparing a data and tools network for The Division Resurgence."/>
<meta name="robots" content="index,follow"/>
</svelte:head>
<main class="launch-page">
<div class="launch-grid" aria-hidden="true"></div>
<header class="launch-header">
<a class="brand" href="/coming-soon" aria-label="SHD.build coming soon">
<BrandLockup size="launch" />
</a>
<div class="launch-signal"><i></i><span>NETWORK STATUS</span><b>DEPLOYMENT IN PROGRESS</b></div>
</header>
<section class="launch-hero">
<div class="launch-copy">
<p class="eyebrow"><span></span> THE DIVISION RESURGENCE DATA & TOOLS</p>
<h1>INTELLIGENCE<br/><em>IS COMING.</em></h1>
<p class="launch-lede">SHD.build is assembling a tactical home for Resurgence agents—loadout engineering, field guides, game data, community builds, and mission-ready intelligence in one network.</p>
{#if data.unavailable}
<p class="launch-notice" role="status"><b>{data.unavailable.toUpperCase()} CHANNEL OFFLINE</b><span>This section is offline for all agents while deployment continues. Use an available channel or return later.</span></p>
{/if}
<div class="launch-actions">
<a class="primary" href={data.authenticated ? '/' : '/sign-in'}>{data.authenticated ? 'ENTER FULL SITE' : 'AGENT SIGN IN'} <span>→</span></a>
<a class="secondary" href="https://discord.gg/dhpd33VwQK">JOIN THE DISCORD ↗</a>
</div>
<p class="launch-access-note">{data.settings.accountCreationEnabled ? 'NEW AGENT REGISTRATION IS CURRENTLY OPEN.' : 'SIGN-IN IS LIMITED TO EXISTING AGENT IDENTITIES.'}</p>
</div>
<div class="launch-core" aria-label="SHD network deployment status">
<div class="launch-radar">
<div class="radar-ring ring-a"></div>
<div class="radar-ring ring-b"></div>
<div class="radar-ring ring-c"></div>
<div class="radar-sweep"></div>
<div class="radar-core"><BrandEmblem size="76%" /></div>
{#each channels.slice(0, 4) as channel, index}
<div class="radar-node node-{index}"><i></i><span>{channel.label}</span></div>
{/each}
</div>
<div class="launch-core-status"><span>ISAC NODE // NYC-01</span><b>COMMUNITY UPLINK INITIALIZING</b><small>SECURE ACCESS AVAILABLE</small></div>
</div>
</section>
<section class="launch-channels" aria-labelledby="launch-channels-title">
<header><div><p class="eyebrow"><span></span> DEPLOYMENT MANIFEST</p><h2 id="launch-channels-title">WHAT’S ENTERING THE NETWORK</h2></div><b>{channels.length.toString().padStart(2, '0')} CHANNELS</b></header>
<div>
{#each channels as channel, index}
<article>
<small>{String(index + 1).padStart(2, '0')} // {channel.label}</small>
<h3>{channel.detail}</h3>
<span class:available={data.settings.sections[channel.key]}> {data.settings.sections[channel.key] ? 'STAGED' : 'IN DEVELOPMENT'} </span>
</article>
{/each}
</div>
</section>
<footer class="launch-footer">
<p>UNOFFICIAL FAN PROJECT FOR TOM CLANCY’S THE DIVISION RESURGENCE</p>
<nav aria-label="Prelaunch support and legal"><a href="/contact">CONTACT</a><a href="/terms">TERMS & PRIVACY</a><a href="/dmca">DMCA</a></nav>
</footer>
</main>
+8 -8
View File
@@ -1,21 +1,21 @@
<svelte:head> <svelte:head>
<title>DMCA & Copyright Policy — SHD.build</title> <title>DMCA & Copyright Policy | SHD.build</title>
<meta name="description" content="SHD.build copyright, DMCA notice, counter-notice, and repeat-infringer policy."/> <meta name="description" content="SHD.build copyright, DMCA notice, counter-notice, and repeat-infringer policy."/>
</svelte:head> </svelte:head>
<main class="legal-page"> <main class="legal-page">
<header class="legal-hero"><p class="eyebrow"><span></span> SHD COPYRIGHT OPERATIONS</p><h1>DMCA & <em>COPYRIGHT.</em></h1><p>SHD.build respects intellectual-property rights and provides a documented channel for copyright owners, authorized agents, and affected users.</p></header> <header class="legal-hero"><p class="eyebrow"><span></span> SHD COPYRIGHT OPERATIONS</p><h1>DMCA & <em>COPYRIGHT.</em></h1><p>SHD.build respects intellectual-property rights. Copyright owners, their authorized agents, and affected users can use this policy to submit claims and counter-notices.</p></header>
<div class="legal-layout"> <div class="legal-layout">
<nav class="legal-index" aria-label="DMCA policy sections"> <nav class="legal-index" aria-label="DMCA policy sections">
<a href="#notice">01 · TAKEDOWN NOTICE</a><a href="#processing">02 · PROCESSING</a><a href="#counter">03 · COUNTER-NOTICE</a><a href="#repeat">04 · REPEAT INFRINGERS</a><a href="#misrepresentation">05 · ACCURACY</a><a href="#contact">06 · SUBMIT</a> <a href="#notice">01 · TAKEDOWN NOTICE</a><a href="#processing">02 · PROCESSING</a><a href="#counter">03 · COUNTER-NOTICE</a><a href="#repeat">04 · REPEAT INFRINGERS</a><a href="#misrepresentation">05 · ACCURACY</a><a href="#contact">06 · SUBMIT</a>
</nav> </nav>
<article class="legal-document"> <article class="legal-document">
<section id="notice"><span>01</span><div><h2>NOTICE OF CLAIMED INFRINGEMENT</h2><p>If you own a copyright or are authorized to act for its owner and believe material on SHD.build infringes that copyright, submit a written notice containing: your physical or electronic signature; identification of the copyrighted work or a representative list; identification and exact location of the material to remove or disable; sufficient contact information, including mailing address, telephone number, and email address; a good-faith statement that the disputed use is not authorized by the owner, its agent, or law; and a statement under penalty of perjury that the notice is accurate and you are authorized to act for the owner.</p><p>Use exact SHD.build URLs whenever possible. A notice that omits required information may delay review while we request clarification.</p></div></section> <section id="notice"><span>01</span><div><h2>NOTICE OF CLAIMED INFRINGEMENT</h2><p>If you own a copyright, or the owner has authorized you to act, and you believe material on SHD.build infringes that copyright, submit a written notice containing: your physical or electronic signature; identification of the copyrighted work or a representative list; identification and exact location of the material to remove or disable; sufficient contact information, including mailing address, telephone number, and email address; a good-faith statement that the owner, the owner’s agent, and the law do not authorize the disputed use; and a statement under penalty of perjury that the notice is accurate and you are authorized to act for the owner.</p><p>Use exact SHD.build URLs when possible. Missing information may delay our review while we request clarification.</p></div></section>
<section id="processing"><span>02</span><div><h2>REVIEW & REMOVAL</h2><p>We review sufficiently complete notices and may remove or disable access to identified material expeditiously where appropriate. We may provide the notice, including the claimant’s identity and allegations, to the user who supplied the material so that user can understand the claim and exercise applicable counter-notice rights.</p><p>Submitting a notice does not guarantee removal, constitute a legal ruling, or waive any party’s rights.</p></div></section> <section id="processing"><span>02</span><div><h2>REVIEW & REMOVAL</h2><p>We review notices that contain enough information and may remove the identified material or disable access expeditiously when appropriate. We may provide the notice, including the claimant’s identity and allegations, to the user who supplied the material. The user can then understand the claim and exercise applicable counter-notice rights.</p><p>Submitting a notice does not guarantee removal, constitute a legal ruling, or waive any party’s rights.</p></div></section>
<section id="counter"><span>03</span><div><h2>COUNTER-NOTIFICATION</h2><p>A user who believes material was removed through mistake or misidentification may submit a counter-notice identifying the removed material and its prior location; providing a physical or electronic signature and complete contact information; stating under penalty of perjury a good-faith belief that removal resulted from mistake or misidentification; consenting to the jurisdiction of the appropriate United States federal district court; and agreeing to accept service of process from the original claimant or that claimant’s agent.</p><p>Where applicable, SHD.build may restore material no sooner than 10 and no later than 14 business days after forwarding a compliant counter-notice, unless the original claimant informs us that a court action has been filed seeking to restrain the allegedly infringing activity.</p></div></section> <section id="counter"><span>03</span><div><h2>COUNTER-NOTIFICATION</h2><p>If you believe we removed material because of mistake or misidentification, you may submit a counter-notice that identifies the removed material and its prior location; provides your physical or electronic signature and complete contact information; states under penalty of perjury your good-faith belief that mistake or misidentification caused the removal; consents to the jurisdiction of the appropriate United States federal district court; and agrees to accept service of process from the original claimant or that claimant’s agent.</p><p>Where applicable, SHD.build may restore material no sooner than 10 and no later than 14 business days after we forward a compliant counter-notice, unless the original claimant informs us that it filed a court action seeking to restrain the allegedly infringing activity.</p></div></section>
<section id="repeat"><span>04</span><div><h2>REPEAT-INFRINGER POLICY</h2><p>SHD.build may restrict or terminate accounts of repeat infringers in appropriate circumstances. We consider the nature, credibility, and outcome of notices and counter-notices rather than treating every allegation as a final determination. We may also remove content or restrict accounts when infringement is otherwise established or apparent.</p></div></section> <section id="repeat"><span>04</span><div><h2>REPEAT-INFRINGER POLICY</h2><p>SHD.build may restrict or terminate accounts of repeat infringers in appropriate circumstances. We assess the nature, credibility, and outcome of notices and counter-notices without treating each allegation as a final determination. We may remove content or restrict accounts when other evidence establishes or indicates infringement.</p></div></section>
<section id="misrepresentation"><span>05</span><div><h2>ACCURACY & MISREPRESENTATION</h2><p>Knowingly making a material misrepresentation that content is infringing, or that removed content was removed through mistake or misidentification, may result in liability under 17 U.S.C. § 512(f). Consider fair use, licenses, and other lawful uses before submitting a notice. This policy is operational information and not legal advice.</p><p>For official guidance, consult the <a href="https://www.copyright.gov/512/" rel="external noreferrer">U.S. Copyright Office Section 512 resources</a>.</p></div></section> <section id="misrepresentation"><span>05</span><div><h2>ACCURACY & MISREPRESENTATION</h2><p>If you knowingly make a material misrepresentation that content infringes a copyright, or that mistake or misidentification caused us to remove content, you may incur liability under 17 U.S.C. § 512(f). Review fair use, licenses, and other lawful uses before submitting a notice. This policy explains our process and does not provide legal advice.</p><p>Consult the <a href="https://www.copyright.gov/512/" rel="external noreferrer">U.S. Copyright Office Section 512 resources</a> for official guidance.</p></div></section>
<section id="contact"><span>06</span><div><h2>SUBMIT A REQUEST</h2><p>Use the DMCA / Copyright destination on the SHD.build contact form. The form collects the elements needed for an initial takedown notice and routes the complete record to administrators. Counter-notices and follow-up documents may use the same channel.</p><a class="primary legal-primary" href="/contact?team=dmca">OPEN COPYRIGHT REQUEST →</a></div></section> <section id="contact"><span>06</span><div><h2>SUBMIT A REQUEST</h2><p>Select DMCA / Copyright on the SHD.build contact form. We use the form to collect the information required for an initial takedown notice and send the complete record to administrators. You may send counter-notices and follow-up documents through the same channel.</p><a class="primary legal-primary" href="/contact?team=dmca">OPEN COPYRIGHT REQUEST →</a></div></section>
</article> </article>
</div> </div>
</main> </main>
+1
View File
@@ -40,6 +40,7 @@
]; ];
const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission))); const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission)));
const systemSections = [ const systemSections = [
{ href: '/isac/site-controls', label: 'Site controls', permission: 'site.settings.manage' },
{ href: '/isac/images', label: 'Image library', permission: 'images.manage' }, { href: '/isac/images', label: 'Image library', permission: 'images.manage' },
{ href: '/isac/system-messages', label: 'Automated messages', permission: 'system.notifications.manage' }, { href: '/isac/system-messages', label: 'Automated messages', permission: 'system.notifications.manage' },
{ href: '/isac/metrics', label: 'System metrics', permission: 'metrics.view' } { href: '/isac/metrics', label: 'System metrics', permission: 'metrics.view' }
+1
View File
@@ -9,6 +9,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
if (permissions.includes('users.roles')) redirect(303, '/isac/roles'); if (permissions.includes('users.roles')) redirect(303, '/isac/roles');
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/isac/configuration'); if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/isac/configuration');
if (permissions.includes('audit.view')) redirect(303, '/isac/audit'); if (permissions.includes('audit.view')) redirect(303, '/isac/audit');
if (permissions.includes('site.settings.manage')) redirect(303, '/isac/site-controls');
if (permissions.includes('images.manage')) redirect(303, '/isac/images'); if (permissions.includes('images.manage')) redirect(303, '/isac/images');
if (permissions.includes('system.notifications.manage')) redirect(303, '/isac/system-messages'); if (permissions.includes('system.notifications.manage')) redirect(303, '/isac/system-messages');
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics'); if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
+7 -2
View File
@@ -30,10 +30,15 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
} }
}, 'name'); }, 'name');
const permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : filter === 'audit' ? 'audit.%' : filter === 'system' ? 'system.%' : ''; const permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : filter === 'audit' ? 'audit.%' : filter === 'system' ? 'system.%' : '';
const relatedPermissionFilter = filter === 'data'
? " OR p.name LIKE 'calculation.%'"
: filter === 'system'
? " OR p.name LIKE 'site.%' OR p.name LIKE 'metrics.%' OR p.name LIKE 'images.%'"
: '';
const conditions = [ const conditions = [
...(permissionPrefix ? [`EXISTS ( ...(permissionPrefix ? [`EXISTS (
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
WHERE rp.role_id=roles.id AND (p.name LIKE ?${filter === 'data' ? " OR p.name LIKE 'calculation.%'" : ''}) WHERE rp.role_id=roles.id AND (p.name LIKE ?${relatedPermissionFilter})
)`] : []), )`] : []),
...(search ? ["roles.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []) ...(search ? ["roles.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
]; ];
@@ -45,7 +50,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND (p.name LIKE 'data.%' OR p.name LIKE 'calculation.%')) THEN 1 ELSE 0 END) AS data, SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND (p.name LIKE 'data.%' OR p.name LIKE 'calculation.%')) THEN 1 ELSE 0 END) AS data,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'users.%') THEN 1 ELSE 0 END) AS users, SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'users.%') THEN 1 ELSE 0 END) AS users,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'audit.%') THEN 1 ELSE 0 END) AS audit SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'audit.%') THEN 1 ELSE 0 END) AS audit
,SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'system.%') THEN 1 ELSE 0 END) AS system ,SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND (p.name LIKE 'system.%' OR p.name LIKE 'site.%' OR p.name LIKE 'metrics.%' OR p.name LIKE 'images.%')) THEN 1 ELSE 0 END) AS system
FROM roles`)).get(), FROM roles`)).get(),
(await database.prepare(`SELECT COUNT(*) AS total FROM roles ${where}`)).get(...filterArgs), (await database.prepare(`SELECT COUNT(*) AS total FROM roles ${where}`)).get(...filterArgs),
(await database.prepare(`SELECT id,name,description FROM permissions (await database.prepare(`SELECT id,name,description FROM permissions
@@ -0,0 +1,83 @@
import { randomUUID } from 'node:crypto';
import { fail, error } from '@sveltejs/kit';
import { requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { recordIsacAudit } from '$lib/server/audit';
import { invalidateSiteAccessSettings } from '$lib/server/site-access';
import type { Actions, PageServerLoad } from './$types';
const SETTINGS_ID = 'primary';
const CONFIRMATION = 'apply-site-access-settings';
const settingSnapshot = async () => {
const database = await getDatabase();
return await (await database.prepare(
'SELECT * FROM site_launch_settings WHERE id=?'
)).get(SETTINGS_ID) ?? null;
};
const flagNames = [
'comingSoonEnabled', 'accountCreationEnabled', 'newsEnabled', 'databaseEnabled',
'buildsEnabled', 'guidesEnabled', 'builderEnabled'
] as const;
const enabled = (form: FormData, name: (typeof flagNames)[number]) =>
String(form.get(name)) === '1' ? 1 : 0;
export const load: PageServerLoad = async ({ cookies }) => {
await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
const database = await getDatabase();
const settings = await (await database.prepare(`SELECT settings.*,
COALESCE(profile.display_name,user.username) AS updated_by_name
FROM site_launch_settings settings
LEFT JOIN users user ON user.id=settings.updated_by
LEFT JOIN user_profiles profile ON profile.user_id=user.id
WHERE settings.id=?`)).get(SETTINGS_ID);
if (!settings) error(500, 'Site launch settings are not initialized.');
return { settings };
};
export const actions: Actions = {
save: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
const form = await request.formData();
if (String(form.get('confirmation') ?? '') !== CONFIRMATION) {
return fail(400, { siteSettingsError: 'Review and confirm the complete site access profile before applying it.' });
}
if (flagNames.some((name) => !['0', '1'].includes(String(form.get(name))))) {
return fail(400, { siteSettingsError: 'Every site access control must have an explicit enabled or disabled state.' });
}
const before = await settingSnapshot();
if (!before) return fail(404, { siteSettingsError: 'Site launch settings are not initialized.' });
const next = {
comingSoonEnabled: enabled(form, 'comingSoonEnabled'),
accountCreationEnabled: enabled(form, 'accountCreationEnabled'),
newsEnabled: enabled(form, 'newsEnabled'),
databaseEnabled: enabled(form, 'databaseEnabled'),
buildsEnabled: enabled(form, 'buildsEnabled'),
guidesEnabled: enabled(form, 'guidesEnabled'),
builderEnabled: enabled(form, 'builderEnabled')
};
const database = await getDatabase();
const updatedAt = new Date().toISOString();
await (await database.prepare(`UPDATE site_launch_settings SET
coming_soon_enabled=?,account_creation_enabled=?,news_enabled=?,database_enabled=?,
builds_enabled=?,guides_enabled=?,builder_enabled=?,updated_at=?,updated_by=?
WHERE id=?`)).run(
next.comingSoonEnabled, next.accountCreationEnabled, next.newsEnabled, next.databaseEnabled,
next.buildsEnabled, next.guidesEnabled, next.builderEnabled, updatedAt, operator.id, SETTINGS_ID
);
invalidateSiteAccessSettings();
const after = await settingSnapshot();
await recordIsacAudit({
actorId: operator.id,
category: 'system_configuration',
action: 'site_access.updated',
entityId: SETTINGS_ID,
before,
after
});
return { siteSettingsSaved: true, resultToken: randomUUID() };
}
};
+163
View File
@@ -0,0 +1,163 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
let { data, form } = $props();
const binaryOptions = [
{ value: '1', label: 'ENABLED' },
{ value: '0', label: 'DISABLED' }
];
const launchOptions = [
{ value: '1', label: 'COMING SOON' },
{ value: '0', label: 'PUBLIC LAUNCH' }
];
const sectionDefinitions = [
{ key: 'news', label: 'NEWS', description: 'Field reports, updates, and community reporting.' },
{ key: 'database', label: 'DATABASE', description: 'Public game-data registries and tactical lookup.' },
{ key: 'builds', label: 'BUILDS', description: 'Published loadout discovery and shared build views.' },
{ key: 'guides', label: 'GUIDES', description: 'Long-form field manuals and build walkthroughs.' },
{ key: 'builder', label: 'BUILDER', description: 'Loadout creation, calculation, and account saves.' }
] as const;
const settingValue = (key: string) => Number(data.settings[key]) ? '1' : '0';
let comingSoonEnabled = $state(settingValue('coming_soon_enabled'));
let accountCreationEnabled = $state(settingValue('account_creation_enabled'));
let sectionValues = $state<Record<string, string>>(Object.fromEntries(
sectionDefinitions.map((section) => [section.key, settingValue(`${section.key}_enabled`)])
));
let reviewing = $state(false);
let synchronizedAt = $state('');
let handledResultToken = $state('');
const synchronize = () => {
comingSoonEnabled = settingValue('coming_soon_enabled');
accountCreationEnabled = settingValue('account_creation_enabled');
sectionValues = Object.fromEntries(sectionDefinitions.map((section) => [
section.key,
settingValue(`${section.key}_enabled`)
]));
};
$effect(() => {
const updatedAt = String(data.settings.updated_at);
if (updatedAt === synchronizedAt) return;
synchronizedAt = updatedAt;
synchronize();
});
$effect(() => {
if (!form?.siteSettingsSaved || !form.resultToken || form.resultToken === handledResultToken) return;
handledResultToken = form.resultToken;
reviewing = false;
});
const currentValue = (key: string) => Number(data.settings[key]) ? 'ENABLED' : 'DISABLED';
const proposedValue = (value: string) => value === '1' ? 'ENABLED' : 'DISABLED';
const changes = $derived.by(() => {
const rows = [
{
label: 'LAUNCH GATE',
before: Number(data.settings.coming_soon_enabled) ? 'COMING SOON' : 'PUBLIC LAUNCH',
after: comingSoonEnabled === '1' ? 'COMING SOON' : 'PUBLIC LAUNCH'
},
{
label: 'ACCOUNT CREATION',
before: currentValue('account_creation_enabled'),
after: proposedValue(accountCreationEnabled)
},
...sectionDefinitions.map((section) => ({
label: section.label,
before: currentValue(`${section.key}_enabled`),
after: proposedValue(sectionValues[section.key] ?? '0')
}))
];
return rows.filter((row) => row.before !== row.after);
});
const date = (value: unknown) => value
? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value)))
: 'INITIAL CONFIGURATION';
</script>
<svelte:head><title>Site controls — ISAC</title></svelte:head>
<div class="module-toolbar site-control-toolbar">
<div>
<p class="eyebrow">SYSTEM CONFIGURATION / RELEASE CONTROL</p>
<h2>SITE CONTROLS</h2>
<p>Coordinate public launch state, account registration, and access to major SHD.build sections.</p>
</div>
<div class="site-control-state">
<span>NETWORK STATE</span>
<strong class:restricted={comingSoonEnabled === '1'}>{comingSoonEnabled === '1' ? 'COMING SOON' : 'PUBLIC'}</strong>
<small>LAST UPDATED {date(data.settings.updated_at)}</small>
</div>
</div>
<div class="site-control-grid">
<section class="site-control-panel launch-control">
<header><div><span>01 / LAUNCH GATE</span><h3>PUBLIC NETWORK STATE</h3></div><b class:restricted={comingSoonEnabled === '1'}>{comingSoonEnabled === '1' ? 'GATED' : 'LIVE'}</b></header>
<p>The Coming Soon gate takes priority over individual sections for signed-out visitors. Signed-in agents bypass the launch gate, but section availability still applies.</p>
<SegmentedChoice legend="LAUNCH STATE" name="launchStateDraft" options={launchOptions} bind:value={comingSoonEnabled}/>
</section>
<section class="site-control-panel account-control">
<header><div><span>02 / IDENTITY INTAKE</span><h3>ACCOUNT CREATION</h3></div><b class:restricted={accountCreationEnabled === '0'}>{accountCreationEnabled === '1' ? 'OPEN' : 'CLOSED'}</b></header>
<p>Controls creation of new accounts across supported registration methods. Existing agents retain their configured sign-in methods.</p>
<SegmentedChoice legend="REGISTRATION STATE" name="accountCreationDraft" options={binaryOptions} bind:value={accountCreationEnabled}/>
</section>
<section class="site-control-panel public-sections">
<header><div><span>03 / PUBLIC SECTIONS</span><h3>SECTION AVAILABILITY</h3></div><b>{sectionDefinitions.filter((section) => sectionValues[section.key] === '1').length} / {sectionDefinitions.length} ONLINE</b></header>
<p>Section controls apply after the Launch Gate. Disabled sections disappear from navigation and reject direct access and mutations for every agent.</p>
<div class="section-control-list">
{#each sectionDefinitions as section}
<article>
<div><strong>{section.label}</strong><small>{section.description}</small></div>
<SegmentedChoice legend={`${section.label} STATE`} name={`${section.key}StateDraft`} options={binaryOptions} bind:value={sectionValues[section.key]}/>
</article>
{/each}
</div>
</section>
</div>
{#if form?.siteSettingsError}<p class="error">{form.siteSettingsError}</p>{/if}
<div class="site-control-actions">
<div><span>CHANGE SET</span><b>{changes.length ? `${changes.length} PENDING` : 'SYNCHRONIZED'}</b><small>UPDATED BY {data.settings.updated_by_name ?? 'SYSTEM BOOTSTRAP'}</small></div>
<div class="site-control-action-buttons">
<a class="secondary" href="/coming-soon">PREVIEW PAGE ↗</a>
<button class="primary" disabled={!changes.length} onclick={() => reviewing = true}>REVIEW CHANGES →</button>
</div>
</div>
{#if reviewing}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal site-control-review" role="dialog" aria-modal="true" aria-labelledby="site-control-review-title">
<div class="editor-title">
<div><p class="eyebrow">CONFIRM SITE ACCESS PROFILE</p><h3 id="site-control-review-title">{changes.length} OPERATIONAL CHANGE{changes.length === 1 ? '' : 'S'}</h3></div>
<button type="button" aria-label="Close site control review" onclick={() => reviewing = false}>×</button>
</div>
<p>These settings change which public SHD.build services agents can reach. Review the full change set before deployment.</p>
<div class="site-control-change-list">
{#each changes as change}
<div><strong>{change.label}</strong><span>{change.before}</span><b>→</b><span class:restricted={change.after === 'DISABLED' || change.after === 'COMING SOON'}>{change.after}</span></div>
{/each}
</div>
<PendingForm action="?/save" successMessage="Site access settings updated.">
{#snippet children(pending)}
<input type="hidden" name="confirmation" value="apply-site-access-settings"/>
<input type="hidden" name="comingSoonEnabled" value={comingSoonEnabled}/>
<input type="hidden" name="accountCreationEnabled" value={accountCreationEnabled}/>
{#each sectionDefinitions as section}<input type="hidden" name={`${section.key}Enabled`} value={sectionValues[section.key]}/>{/each}
<div class="form-actions">
<button type="button" class="secondary" onclick={() => reviewing = false} disabled={pending}>CANCEL</button>
<button class="primary" disabled={pending || !changes.length} aria-busy={pending}>{pending ? 'APPLYING PROFILE…' : 'CONFIRM SITE CONTROLS →'}</button>
</div>
{/snippet}
</PendingForm>
</div>
</div>
{/if}
<style>
.site-control-toolbar{align-items:stretch}.site-control-state{display:grid;align-content:center;min-width:230px;padding:10px 14px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:var(--shd-surface)}.site-control-state span,.site-control-state small,.site-control-panel header span,.site-control-actions span,.site-control-actions small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.site-control-state strong{color:var(--shd-success);font-size:21px}.site-control-state strong.restricted,.site-control-panel b.restricted,.site-control-change-list span.restricted{color:var(--shd-danger)}.site-control-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.site-control-panel{display:grid;align-content:start;gap:10px;padding:12px;border:1px solid var(--shd-border);background:var(--shd-surface)}.site-control-panel>header{display:flex;justify-content:space-between;gap:12px;align-items:start;padding-bottom:9px;border-bottom:1px solid var(--shd-border)}.site-control-panel h3,.site-control-panel p{margin:0}.site-control-panel>p{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.site-control-panel header b{color:var(--shd-success);font-size:var(--shd-type-supporting);letter-spacing:.8px}.public-sections{grid-column:1/-1}.section-control-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.section-control-list article{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,.8fr);gap:10px;align-items:center;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.section-control-list article>div{display:grid;gap:2px}.section-control-list small{color:var(--shd-text-muted)}.site-control-actions{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:9px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.site-control-actions>div:first-child{display:grid}.site-control-action-buttons{display:flex;gap:7px}.site-control-action-buttons>a{display:flex;align-items:center}.site-control-actions b{color:var(--shd-orange)}.site-control-review>p{margin:0;padding:12px;color:var(--shd-text-muted)}.site-control-change-list{display:grid;gap:4px;padding:0 12px 12px}.site-control-change-list>div{display:grid;grid-template-columns:minmax(140px,1fr) minmax(110px,.7fr) 28px minmax(110px,.7fr);gap:7px;align-items:center;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.site-control-change-list>div>b{text-align:center;color:var(--shd-orange)}.site-control-change-list span:last-child{font-weight:900}.site-control-review .form-actions{padding:12px;border-top:1px solid var(--shd-border)}
@media(max-width:900px){.site-control-grid{grid-template-columns:1fr}.public-sections{grid-column:auto}.section-control-list{grid-template-columns:1fr}}
@media(max-width:650px){.site-control-state{min-width:0}.section-control-list article{grid-template-columns:1fr}.site-control-actions{display:grid}.site-control-action-buttons{display:grid;grid-template-columns:1fr}.site-control-action-buttons>*{width:100%;justify-content:center}.site-control-change-list>div{grid-template-columns:1fr auto 1fr}.site-control-change-list strong{grid-column:1/-1}.site-control-change-list span:last-child{text-align:right}}
</style>
+29 -8
View File
@@ -8,15 +8,23 @@ import { fail, redirect } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages'; import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
import { getSiteAccessSettings } from '$lib/server/site-access';
export const load: PageServerLoad = async ({ url }) => ({ export const load: PageServerLoad = async ({ url }) => {
emailDeliveryConfigured: emailDeliveryConfigured(), const { accountCreationEnabled } = await getSiteAccessSettings();
magicLinkSent: url.searchParams.has('magic_sent'), const oauthError = url.searchParams.get('oauth_error');
magicError: url.searchParams.get('magic_error') === 'unavailable' return {
? 'This account is not permitted to sign in.' accountCreationEnabled,
: url.searchParams.has('magic_error') ? 'That sign-in link is invalid or expired.' : null, emailDeliveryConfigured: emailDeliveryConfigured(),
oauthError: url.searchParams.has('oauth_error') ? 'Discord sign-in could not be completed.' : null magicLinkSent: url.searchParams.has('magic_sent'),
}); magicError: url.searchParams.get('magic_error') === 'unavailable'
? 'This account is not permitted to sign in.'
: url.searchParams.has('magic_error') ? 'That sign-in link is invalid or expired.' : null,
oauthError: oauthError === 'registration_closed'
? 'New account registration is currently closed. Existing Discord-linked agents can still sign in.'
: oauthError ? 'Discord sign-in could not be completed.' : null
};
};
export const actions: Actions = { export const actions: Actions = {
magicLink: async ({ request }) => { magicLink: async ({ request }) => {
@@ -38,6 +46,12 @@ export const actions: Actions = {
purpose = 'verify_email'; purpose = 'verify_email';
} }
if (!identity?.user_id) { if (!identity?.user_id) {
if (!(await getSiteAccessSettings()).accountCreationEnabled) {
return fail(403, {
magicLinkError: 'New account registration is currently closed. Enter an email already linked to an agent account.',
email
});
}
const id = randomUUID(), now = new Date().toISOString(); const id = randomUUID(), now = new Date().toISOString();
await (await database.prepare('INSERT INTO users(id,username,role,created_at) VALUES(?,?,?,?)')) await (await database.prepare('INSERT INTO users(id,username,role,created_at) VALUES(?,?,?,?)'))
.run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, 'user', now); .run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, 'user', now);
@@ -56,6 +70,13 @@ export const actions: Actions = {
return { magicLinkSent: true }; return { magicLinkSent: true };
}, },
register: async ({ request, cookies, getClientAddress }) => { register: async ({ request, cookies, getClientAddress }) => {
if (!(await getSiteAccessSettings()).accountCreationEnabled) {
return fail(403, {
registerError: 'New account registration is currently closed.',
registerDisplayName: '',
registerUsername: ''
});
}
const form = await request.formData(); const form = await request.formData();
const displayName = String(form.get('displayName') ?? '').normalize('NFKC').trim(); const displayName = String(form.get('displayName') ?? '').normalize('NFKC').trim();
const username = String(form.get('username') ?? '').normalize('NFKC').trim(); const username = String(form.get('username') ?? '').normalize('NFKC').trim();
+10 -6
View File
@@ -10,9 +10,12 @@
let passwordMode = $state<'signin' | 'create'>('signin'); let passwordMode = $state<'signin' | 'create'>('signin');
let passwordPanelOpen = $state(false); let passwordPanelOpen = $state(false);
$effect(() => { $effect(() => {
if (form?.registerError) { if (form?.registerError && data.accountCreationEnabled) {
passwordMode = 'create'; passwordMode = 'create';
passwordPanelOpen = true; passwordPanelOpen = true;
} else if (form?.registerError) {
passwordMode = 'signin';
passwordPanelOpen = true;
} else if (form?.loginError) { } else if (form?.loginError) {
passwordMode = 'signin'; passwordMode = 'signin';
passwordPanelOpen = true; passwordPanelOpen = true;
@@ -50,12 +53,13 @@
</script> </script>
<svelte:head><title>Sign in — SHD.build</title></svelte:head> <svelte:head><title>Sign in — SHD.build</title></svelte:head>
<main class="auth-page"><div class="auth-frame"><section class="auth-intel"><p class="eyebrow"><span></span> SHD COMMUNITY IDENTITY</p><h1>AGENT<br/><em>ACCESS.</em></h1><p>Authenticate to publish builds and guides, contribute game data, join discussions, and keep your SHD intelligence synchronized.</p><div class="auth-status"><span><i></i> NETWORK ONLINE</span><span>NODE NYC-01</span></div></section><section class="auth-console"><div class="console-head"><span>SECURE SIGN-IN</span><b>SHD // AUTH</b></div> <main class="auth-page"><div class="auth-frame"><section class="auth-intel"><p class="eyebrow"><span></span> SHD COMMUNITY IDENTITY</p><h1>AGENT<br/><em>ACCESS.</em></h1><p>Authenticate to publish builds and guides, contribute game data, join discussions, and keep your SHD intelligence synchronized.</p><div class="auth-status"><span><i></i> NETWORK ONLINE</span><span>NODE NYC-01</span></div></section><section class="auth-console"><div class="console-head"><span>SECURE SIGN-IN</span><b>SHD // AUTH</b></div>
<p class="auth-mode-note">Sign in to an existing account or create one with email, Discord, or local credentials.</p> <p class="auth-mode-note">{data.accountCreationEnabled ? 'Sign in to an existing account or create one with email, Discord, or local credentials.' : 'Sign in to an existing agent account with a configured authentication method.'}</p>
{#if !data.accountCreationEnabled}<p class="account-alert"><b>REGISTRATION PAUSED</b><span>New agent accounts cannot be created right now. Existing accounts retain full sign-in access.</span></p>{/if}
<button class="primary full passkey-signin" disabled={!passkeySupported || passkeyPending} aria-busy={passkeyPending} onclick={signInWithPasskey}>{passkeyPending ? 'VERIFYING PASSKEY…' : 'SIGN IN WITH A PASSKEY →'}</button> <button class="primary full passkey-signin" disabled={!passkeySupported || passkeyPending} aria-busy={passkeyPending} onclick={signInWithPasskey}>{passkeyPending ? 'VERIFYING PASSKEY…' : 'SIGN IN WITH A PASSKEY →'}</button>
{#if !passkeySupported}<p class="auth-note">This browser does not support passkeys.</p>{/if} {#if !passkeySupported}<p class="auth-note">This browser does not support passkeys.</p>{/if}
{#if passkeyError}<p class="error">{passkeyError}</p>{/if} {#if passkeyError}<p class="error">{passkeyError}</p>{/if}
<div class="divider"><span>OR EMAIL A ONE-TIME LINK</span></div> <div class="divider"><span>OR EMAIL A ONE-TIME LINK</span></div>
<PendingForm action="?/magicLink"><label class="field">VERIFIED EMAIL<input name="email" type="email" value={form?.email ?? ''} autocomplete="email" placeholder="agent@example.com" required /></label>{#if form?.magicLinkError}<p class="error">{form.magicLinkError}</p>{/if}{#if form?.magicLinkSent || data.magicLinkSent}<p class="success">Your one-time access link is on its way.</p>{/if}{#if data.magicError}<p class="error">{data.magicError}</p>{/if}<button class="secondary full" disabled={!data.emailDeliveryConfigured}>EMAIL ACCESS LINK →</button><small class="identity-note">A verified link signs you in, or creates your SHD.build account if this email is new.</small></PendingForm> <PendingForm action="?/magicLink"><label class="field">VERIFIED EMAIL<input name="email" type="email" value={form?.email ?? ''} autocomplete="email" placeholder="agent@example.com" required /></label>{#if form?.magicLinkError}<p class="error">{form.magicLinkError}</p>{/if}{#if form?.magicLinkSent || data.magicLinkSent}<p class="success">Your one-time access link is on its way.</p>{/if}{#if data.magicError}<p class="error">{data.magicError}</p>{/if}<button class="secondary full" disabled={!data.emailDeliveryConfigured}>EMAIL ACCESS LINK →</button><small class="identity-note">{data.accountCreationEnabled ? 'A verified link signs you in, or creates your SHD.build account if this email is new.' : 'Email access is available only to addresses already linked to an agent account.'}</small></PendingForm>
<div class="divider"><span>OR CONTINUE WITH</span></div> <div class="divider"><span>OR CONTINUE WITH</span></div>
<div class="signin-provider-rail" aria-label="External sign-in providers"> <div class="signin-provider-rail" aria-label="External sign-in providers">
<a class="signin-provider discord-provider" class:interaction-pending={discordPending} href="/auth/discord" aria-busy={discordPending} onclick={beginDiscordSignIn}> <a class="signin-provider discord-provider" class:interaction-pending={discordPending} href="/auth/discord" aria-busy={discordPending} onclick={beginDiscordSignIn}>
@@ -82,13 +86,13 @@
<details class="password-fallback" bind:open={passwordPanelOpen}><summary>USE USERNAME AND PASSWORD</summary> <details class="password-fallback" bind:open={passwordPanelOpen}><summary>USE USERNAME AND PASSWORD</summary>
<div class="auth-mode-switch" role="group" aria-label="Password access mode"> <div class="auth-mode-switch" role="group" aria-label="Password access mode">
<button type="button" class:active={passwordMode === 'signin'} onclick={() => passwordMode = 'signin'}>SIGN IN</button> <button type="button" class:active={passwordMode === 'signin'} onclick={() => passwordMode = 'signin'}>SIGN IN</button>
<button type="button" class:active={passwordMode === 'create'} onclick={() => passwordMode = 'create'}>CREATE ACCOUNT</button> {#if data.accountCreationEnabled}<button type="button" class:active={passwordMode === 'create'} onclick={() => passwordMode = 'create'}>CREATE ACCOUNT</button>{/if}
</div> </div>
{#if passwordMode === 'signin'} {#if passwordMode === 'signin'}
<PendingForm action="?/login"><label class="field">USERNAME<input name="username" value={form?.username ?? ''} autocomplete="username webauthn" placeholder="Private login username" required /></label><label class="field">PASSWORD<input name="password" type="password" autocomplete="current-password" placeholder="••••••••••••" required /></label>{#if form?.loginError}<p class="error">{form.loginError}</p>{/if}<button class="secondary full">SIGN IN WITH PASSWORD →</button></PendingForm> <PendingForm action="?/login"><label class="field">USERNAME<input name="username" value={form?.username ?? ''} autocomplete="username webauthn" placeholder="Private login username" required /></label><label class="field">PASSWORD<input name="password" type="password" autocomplete="current-password" placeholder="••••••••••••" required /></label>{#if form?.loginError}<p class="error">{form.loginError}</p>{/if}<button class="secondary full">SIGN IN WITH PASSWORD →</button></PendingForm>
{:else} {:else if data.accountCreationEnabled}
<PendingForm action="?/register"><label class="field">DISPLAY NAME<input name="displayName" value={form?.registerDisplayName ?? ''} autocomplete="nickname" placeholder="Public agent name" required /></label><label class="field">PRIVATE LOGIN USERNAME<input name="username" value={form?.registerUsername ?? ''} autocomplete="username" placeholder="ASCII letters, numbers, _ or -" required /></label><label class="field">PASSWORD<input name="password" type="password" autocomplete="new-password" placeholder="12–128 characters" minlength="12" maxlength="128" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" autocomplete="new-password" placeholder="Repeat password" minlength="12" maxlength="128" required /></label>{#if form?.registerError}<p class="error">{form.registerError}</p>{/if}<button class="secondary full">CREATE ACCOUNT →</button></PendingForm> <PendingForm action="?/register"><label class="field">DISPLAY NAME<input name="displayName" value={form?.registerDisplayName ?? ''} autocomplete="nickname" placeholder="Public agent name" required /></label><label class="field">PRIVATE LOGIN USERNAME<input name="username" value={form?.registerUsername ?? ''} autocomplete="username" placeholder="ASCII letters, numbers, _ or -" required /></label><label class="field">PASSWORD<input name="password" type="password" autocomplete="new-password" placeholder="12–128 characters" minlength="12" maxlength="128" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" autocomplete="new-password" placeholder="Repeat password" minlength="12" maxlength="128" required /></label>{#if form?.registerError}<p class="error">{form.registerError}</p>{/if}<button class="secondary full">CREATE ACCOUNT →</button></PendingForm>
{/if} {/if}
</details> </details>
<small>Passkeys are preferred for returning agents and can be added after account creation. Password sign-in remains optional.</small> <small>{data.accountCreationEnabled ? 'Passkeys are preferred for returning agents and can be added after account creation. Password sign-in remains optional.' : 'Existing agents can continue with a passkey, verified email, Discord, or configured username and password.'}</small>
</section></div></main> </section></div></main>
+246 -12
View File
@@ -1,13 +1,19 @@
<svelte:head> <svelte:head>
<title>Terms & Privacy — SHD.build</title> <title>Terms & Privacy | SHD.build</title>
<meta name="description" content="Terms of service, privacy practices, and fan-site disclaimer for SHD.build."/> <meta
name="description"
content="Terms of service, privacy practices, and fan-site disclaimer for SHD.build."
/>
</svelte:head> </svelte:head>
<main class="legal-page"> <main class="legal-page">
<header class="legal-hero"> <header class="legal-hero">
<p class="eyebrow"><span></span> SHD NETWORK POLICY</p> <p class="eyebrow"><span></span> SHD NETWORK POLICY</p>
<h1>TERMS & <em>PRIVACY.</em></h1> <h1>TERMS & <em>PRIVACY.</em></h1>
<p>Effective July 25, 2026. These terms describe the rules for using SHD.build and the information processed to operate and secure the service.</p> <p>
Effective July 25, 2026. These terms set the rules for using SHD.build and explain what
information we process to operate and secure the service.
</p>
</header> </header>
<div class="legal-layout"> <div class="legal-layout">
@@ -19,15 +25,243 @@
<a href="#disclaimers">09 · DISCLAIMERS</a> <a href="#disclaimers">09 · DISCLAIMERS</a>
</nav> </nav>
<article class="legal-document"> <article class="legal-document">
<section id="service"><span>01</span><div><h2>THE SERVICE</h2><p>SHD.build is an independent community service for The Division Resurgence, including loadouts, guides, community-maintained game data, news, and discussion. By accessing or using it, you agree to these terms. If you do not agree, do not use the service. Features may change, be suspended, or be discontinued as development continues.</p></div></section> <section id="service">
<section id="accounts"><span>02</span><div><h2>ACCOUNTS & SECURITY</h2><p>You are responsible for activity performed through your account and for maintaining control of your authentication methods. You must provide accurate information, may not impersonate another person, and may not attempt to access another account. We may restrict accounts to protect users, enforce these terms, or comply with law.</p><p>Available authentication methods may include passkeys, verified email magic links, local username and password credentials, Discord, and other providers added later. Third-party authentication remains subject to that provider’s terms and privacy practices.</p></div></section> <span>01</span>
<section id="content"><span>03</span><div><h2>USER CONTENT</h2><p>You retain ownership of original content you submit. You grant SHD.build a non-exclusive, worldwide, royalty-free license to host, reproduce, format, display, and distribute that content as necessary to operate the service. You represent that you have the rights required to post it.</p><p>Builds, guides, and comments may remain available after account deletion under a “Deleted Agent” attribution so community references are not broken. You may not upload unlawful, infringing, malicious, or privacy-invasive content.</p></div></section> <div>
<section id="conduct"><span>04</span><div><h2>COMMUNITY CONDUCT</h2><p>Do not harass others; publish illegal or infringing material; distribute malware; evade access restrictions; scrape or overload the service; manipulate ratings; spam; or exploit security vulnerabilities. Community Oversight may remove content, restrict access, or preserve relevant records when reasonably necessary for safety, support, abuse prevention, or legal obligations.</p></div></section> <h2>THE SERVICE</h2>
<section id="privacy"><span>05</span><div><h2>INFORMATION WE PROCESS</h2><p>Account data may include internal account identifiers, private login username, public display name, password hash, verified email, linked-provider identifiers and profile details, passkey public credentials, avatar, roles, permissions, builds, guides, favorites, ratings, comments, notification messages, read state, and delivery preferences. Passwords are stored only as salted Argon2id hashes. Private passkey material remains on your authenticator.</p><p>For login security and support, events may include the time, success status, authentication provider, IP address, user-agent string, normalized browser, operating system and device type, approximate country/region/city when supplied by the hosting proxy, failure reason, automated risk flags, provider or passkey credential reference, associated session identifier, and session end time or reason. We do not sell this information. It is used for authentication, abuse prevention, troubleshooting, moderation, and account support.</p><p>Contact submissions include the sender’s name, reply email, destination, subject, message, account identifier when signed in, and submission time. Copyright requests may additionally include claimant contact details, identified works and URLs, attestations, and electronic signature. These records are shared only with authorized recipients and retained as reasonably necessary to respond, document action, resolve disputes, enforce policy, and meet legal obligations.</p><p>Browser and email notification delivery is optional except that operational ISAC notices remain available in the in-app inbox. Email frequency is configurable by notification category and defaults to off. OAuth and email providers process information under their own policies.</p></div></section> <p>
<section id="cookies"><span>06</span><div><h2>ESSENTIAL COOKIES</h2><p>SHD.build currently uses only first-party cookies that are strictly necessary to provide authentication and protect sign-in flows. We do not currently use analytics, advertising, behavioral-tracking, or personalization cookies. These essential cookies cannot be disabled through a preference control. You may prevent cookies through your browser, but account sign-in will not function correctly.</p><div class="legal-table-wrap"><table class="legal-table"><thead><tr><th>COOKIE</th><th>PURPOSE</th><th>DURATION</th><th>PROTECTION</th></tr></thead><tbody><tr><td><code>shd_session</code></td><td>Keeps you securely signed in and associates requests with your server-side session.</td><td>Up to 30 days, or until sign-out or session invalidation.</td><td>HTTP-only, SameSite=Lax, Secure in production.</td></tr><tr><td><code>shd_discord_oauth_state</code></td><td>Validates a Discord sign-in or linking response and helps prevent request forgery.</td><td>Up to 10 minutes; removed when the callback is processed.</td><td>HTTP-only, SameSite=Lax, Secure in production; limited to the Discord authentication path.</td></tr></tbody></table></div><p>The informational notice remembers its dismissal in browser local storage under <code>shd-essential-cookie-notice</code>. It is not sent with web requests and can be removed through your browser’s site-data controls. OAuth state remains in a short-lived HTTP-only cookie because it must be securely validated by the server and unavailable to browser scripts.</p><p>If non-essential technologies are introduced later, this policy and the site’s consent controls will be updated before those technologies are enabled where consent is required.</p></div></section> SHD.build is an independent community service for The Division Resurgence. It
<section id="retention"><span>07</span><div><h2>RETENTION & DELETION</h2><p>IP addresses, user-agent strings, normalized device information, and credential references in login history are retained for no more than 30 days, then automatically removed from the event. Non-sensitive facts such as event time, provider, outcome, failure category, and whether a session was created may remain for security and support history.</p><p>Deleting your account removes active sessions, credentials, verified email, provider profiles, passkeys, permissions, and uploaded avatar. You may also select immediate purging of retained login-event history during self-deletion. Public contributions may remain attributed to “Deleted Agent.” Backups and records required for fraud prevention, dispute resolution, or law may persist for a limited period where applicable.</p></div></section> includes loadouts, guides, community-maintained game data, news, and discussion.
<section id="third-parties"><span>08</span><div><h2>THIRD-PARTY SERVICES</h2><p>SHD.build may connect to Discord, email delivery services, and future identity providers. Their services are governed by their own terms. Links to other sites do not imply endorsement. You authorize the minimum provider data requested when choosing a linked sign-in method.</p></div></section> By accessing or using SHD.build, you agree to these terms. If you do not agree, do
<section id="disclaimers"><span>09</span><div><h2>FAN-SITE & LEGAL DISCLAIMER</h2><p>SHD.build is an unofficial fan-created service and is not affiliated with, endorsed by, sponsored by, or approved by Ubisoft Entertainment or its affiliates.</p><p>Tom Clancy’s The Division, Tom Clancy’s The Division Resurgence, Ubisoft, and related names, trademarks, logos, characters, artwork, game data, and other intellectual property are owned by Ubisoft and/or its licensors. Their use is for identification, commentary, and community reference. No ownership is claimed.</p><p>The service and community information are provided “as is” and “as available,” without warranties of accuracy, availability, fitness for a particular purpose, or non-infringement to the extent permitted by law. Build data may become outdated after game updates. SHD.build is not responsible for third-party services or decisions made using community content.</p><p>Copyright owners and authorized agents may use our <a href="/dmca">DMCA & Copyright Policy</a> and <a href="/contact?team=dmca">copyright request form</a>. Other questions may be sent through <a href="/contact">Contact Us</a>.</p><p>These terms may be updated as the service changes. Material revisions will be identified by a new effective date. Nothing here limits rights that cannot legally be waived.</p><p>For authoritative information about Ubisoft intellectual property, consult Ubisoft’s official <a href="https://www.ubisoft.com/legal/documents/termsofuse/en-US" rel="external">Terms of Use</a> and the official <a href="https://www.ubisoft.com/game/the-division/resurgence" rel="external">The Division Resurgence site</a>.</p></div></section> not use the service. We may change, suspend, or discontinue features as development
continues.
</p>
</div>
</section>
<section id="accounts">
<span>02</span>
<div>
<h2>ACCOUNTS & SECURITY</h2>
<p>
You are responsible for activity through your account and for keeping control of
your authentication methods. You must provide accurate information. You may not
impersonate another person or attempt to access another account. We may restrict
accounts to protect users, enforce these terms, or comply with law.
</p>
<p>
SHD.build may offer passkeys, verified email magic links, local username and
password credentials, Discord, and other authentication providers added later.
Each third-party provider applies its own terms and privacy practices.
</p>
</div>
</section>
<section id="content">
<span>03</span>
<div>
<h2>USER CONTENT</h2>
<p>
You retain ownership of original content you submit. You grant SHD.build a
non-exclusive, worldwide, royalty-free license to host, reproduce, format, display,
and distribute that content as necessary to operate the service. You represent that
you have the rights required to post it.
</p>
<p>
SHD.build may keep builds, guides, and comments available after account deletion
under a “Deleted Agent” attribution to preserve community references. You may not
upload unlawful, infringing, malicious, or privacy-invasive content.
</p>
</div>
</section>
<section id="conduct">
<span>04</span>
<div>
<h2>COMMUNITY CONDUCT</h2>
<p>
Do not harass others; publish illegal or infringing material; distribute malware;
evade access restrictions; scrape or overload the service; manipulate ratings; spam;
or exploit security vulnerabilities. Community Oversight may remove content,
restrict access, or preserve relevant records when reasonably necessary for safety,
support, abuse prevention, or legal obligations.
</p>
</div>
</section>
<section id="privacy">
<span>05</span>
<div>
<h2>INFORMATION WE PROCESS</h2>
<p>
We may process internal account identifiers, private login username, public display
name, password hash, verified email, linked-provider identifiers and profile
details, passkey public credentials, avatar, roles, permissions, builds, guides,
favorites, ratings, comments, notification messages, read state, and delivery
preferences. SHD.build stores passwords only as salted Argon2id hashes. Private
passkey material remains on your authenticator.
</p>
<p>
For login security and support, we may record the time, success status,
authentication provider, IP address, user-agent string, normalized browser,
operating system and device type, approximate country, region, and city when the
hosting proxy supplies them, failure reason, automated risk flags, provider or
passkey credential reference, associated session identifier, and session end time
or reason. We do not sell this information. We use it for authentication, abuse
prevention, troubleshooting, moderation, and account support.
</p>
<p>
When you contact us, we collect the sender’s name, reply email, destination,
subject, message, account identifier when signed in, and submission time. Copyright
requests may also include claimant contact details, identified works and URLs,
attestations, and electronic signature. We share these records only with authorized
recipients and keep them as long as reasonably necessary to respond, document
action, resolve disputes, enforce policy, and meet legal obligations.
</p>
<p>
Browser and email notification delivery is optional. Operational ISAC notices
remain available in the in-app inbox. You can configure email frequency by
notification category, and email delivery defaults to off. OAuth and email
providers process information under their own policies.
</p>
</div>
</section>
<section id="cookies">
<span>06</span>
<div>
<h2>ESSENTIAL COOKIES</h2>
<p>
SHD.build currently uses only first-party cookies that are strictly necessary for
authentication and sign-in protection. We do not currently use analytics,
advertising, behavioral-tracking, or personalization cookies. Because these cookies
are essential, SHD.build does not offer a preference control for disabling them.
You may block cookies through your browser, but account sign-in will not function
without them.
</p>
<div class="legal-table-wrap">
<table class="legal-table">
<thead>
<tr>
<th>COOKIE</th>
<th>PURPOSE</th>
<th>DURATION</th>
<th>PROTECTION</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>shd_session</code></td>
<td>
Maintains your sign-in and associates requests with your server-side
session.
</td>
<td>Up to 30 days, or until sign-out or session invalidation.</td>
<td>HTTP-only, SameSite=Lax, Secure in production.</td>
</tr>
<tr>
<td><code>shd_discord_oauth_state</code></td>
<td>
Validates a Discord sign-in or linking response and helps prevent
request forgery.
</td>
<td>Up to 10 minutes; SHD.build removes it after processing the callback.</td>
<td>
HTTP-only, SameSite=Lax, Secure in production; limited to the Discord
authentication path.
</td>
</tr>
</tbody>
</table>
</div>
<p>
Your browser stores dismissal of the informational notice in local storage under
<code>shd-essential-cookie-notice</code>. Your browser does not send this value with
web requests. You can remove it through your browser’s site-data controls. We keep
OAuth state in a short-lived HTTP-only cookie so the server can validate it without
exposing it to browser scripts.
</p>
<p>
If we introduce non-essential technologies, we will update this policy and the
site’s consent controls before enabling those technologies where consent is
required.
</p>
</div>
</section>
<section id="retention">
<span>07</span>
<div>
<h2>RETENTION & DELETION</h2>
<p>
We retain IP addresses, user-agent strings, normalized device information, and
credential references in login history for no more than 30 days. After 30 days, we
remove those fields from the event. We may retain non-sensitive facts such as event
time, provider, outcome, failure category, and whether the event created a session
for security and support history.
</p>
<p>
When you delete your account, SHD.build removes active sessions, credentials,
verified email, provider profiles, passkeys, permissions, and uploaded avatar. You
may also select an immediate purge of retained login-event history during
self-deletion. SHD.build may keep public contributions under a “Deleted Agent”
attribution. SHD.build may retain backups and records for a limited period where
applicable when fraud prevention, dispute resolution, or law requires them.
</p>
</div>
</section>
<section id="third-parties">
<span>08</span>
<div>
<h2>THIRD-PARTY SERVICES</h2>
<p>
SHD.build may connect to Discord, email delivery services, and future identity
providers. Each provider applies its own terms. Links to other sites do not imply
endorsement. When you choose a linked sign-in method, you authorize SHD.build to
request the minimum provider data needed for that method.
</p>
</div>
</section>
<section id="disclaimers">
<span>09</span>
<div>
<h2>FAN-SITE & LEGAL DISCLAIMER</h2>
<p>
SHD.build is an unofficial fan-created service with no affiliation to Ubisoft
Entertainment or its affiliates. Ubisoft does not endorse, sponsor, or approve
SHD.build.
</p>
<p>
Ubisoft and/or its licensors own Tom Clancy’s The Division, Tom Clancy’s The
Division Resurgence, Ubisoft, and related names, trademarks, logos, characters,
artwork, game data, and other intellectual property. SHD.build uses them for
identification, commentary, and community reference and does not claim ownership.
</p>
<p>
SHD.build provides the service and community information “as is” and “as
available,” without warranties of accuracy, availability, fitness for a particular
purpose, or non-infringement to the extent permitted by law. Game updates may make
build data outdated. SHD.build is not responsible for third-party services or for
decisions that users make based on community content.
</p>
<p>
Copyright owners and authorized agents may use our
<a href="/dmca">DMCA & Copyright Policy</a> and
<a href="/contact?team=dmca">copyright request form</a>. Send other questions
through <a href="/contact">Contact Us</a>.
</p>
<p>
SHD.build may update these terms as the service changes. We will identify material
revisions with a new effective date. Nothing here limits rights that cannot legally
be waived.
</p>
<p>
For authoritative information about Ubisoft intellectual property, consult
Ubisoft’s official
<a href="https://www.ubisoft.com/legal/documents/termsofuse/en-US" rel="external"
>Terms of Use</a
>
and the official
<a href="https://www.ubisoft.com/game/the-division/resurgence" rel="external"
>The Division Resurgence site</a
>.
</p>
</div>
</section>
</article> </article>
</div> </div>
</main> </main>
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-labelledby="title">
<title id="title">SHD.build Core Segments emblem</title>
<g fill="none" stroke="#f6a821" stroke-width="12" stroke-linecap="butt">
<path d="M73 20A38 38 0 0 0 15 63"/>
<path d="M85 37A38 38 0 0 1 55 88"/>
<path d="M42 87A38 38 0 0 1 18 70"/>
</g>
<rect x="72" y="23" width="10" height="10" fill="#f6a821"/>
<path d="M62 35H35V65H62M68 43V57" fill="none" stroke="#ecebe5" stroke-width="9" stroke-linecap="butt" stroke-linejoin="miter"/>
</svg>

After

Width:  |  Height:  |  Size: 562 B

+12 -8
View File
@@ -1,9 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
<rect width="64" height="64" rx="14" fill="#0a0c0d"/> <title id="title">SHD.build</title>
<circle cx="32" cy="32" r="24" fill="none" stroke="#f6a821" stroke-width="2"/> <rect width="64" height="64" rx="8" fill="#0a0c0d"/>
<circle cx="32" cy="32" r="19" fill="none" stroke="#f6a821" stroke-opacity=".38" stroke-width="1"/> <g transform="translate(7 7) scale(.5)">
<path d="M32 4v7M32 53v7M4 32h7M53 32h7" stroke="#f6a821" stroke-width="2"/> <g fill="none" stroke="#f6a821" stroke-width="13" stroke-linecap="butt">
<circle cx="46" cy="18" r="3" fill="#f6a821"/> <path d="M73 20A38 38 0 0 0 15 63"/>
<circle cx="18" cy="46" r="3" fill="#f6a821"/> <path d="M85 37A38 38 0 0 1 55 88"/>
<text x="32" y="36" fill="#f6a821" font-family="Arial,Helvetica,sans-serif" font-size="11" font-weight="900" text-anchor="middle" letter-spacing=".5">SHD</text> <path d="M42 87A38 38 0 0 1 18 70"/>
</g>
<rect x="72" y="23" width="11" height="11" fill="#f6a821"/>
<path d="M62 35H35V65H62M68 43V57" fill="none" stroke="#ecebe5" stroke-width="10" stroke-linecap="butt" stroke-linejoin="miter"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 640 B

After

Width:  |  Height:  |  Size: 638 B