diff --git a/AGENTS.md b/AGENTS.md index d4a526c..da577cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,23 @@ search-oriented option sets. Preserve native form semantics, keyboard access, focus-visible, selected, disabled, pressed, responsive, and reduced-motion behavior in shared controls. +## Development server lifecycle + +After changing source code, styles, configuration, dependencies, or routes, +restart the development server before handing work back to the project owner. + +1. Inspect running processes and stop only this project's development-server + process tree. +2. Start one hidden `npm run dev` process from the repository root. Do not leave + duplicate project development servers running. +3. Confirm that the server accepts an HTTP request before completing the task. +4. Leave the verified development server running so the project owner can + review the changes. + +Database maintenance may require the server to remain stopped while the +embedded database is locked or changing. Restart and verify the server after +the maintenance and validation steps finish. + ## Prototype compatibility Treat application data and client payloads as prototype formats until the diff --git a/database/schema.sql b/database/schema.sql index 301a131..591e13c 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -3,12 +3,16 @@ CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, username TEXT NOT NULL UN CREATE TABLE IF NOT EXISTS user_profiles ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, display_name TEXT NOT NULL, + display_name_key TEXT, + public_slug TEXT, bio_markdown TEXT NOT NULL DEFAULT '', is_public INTEGER NOT NULL DEFAULT 1 CHECK(is_public IN (0, 1)), bio_updated_at TEXT, updated_at TEXT NOT NULL ); INSERT OR IGNORE INTO user_profiles(user_id,display_name,updated_at) SELECT id,username,created_at FROM users; +CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_public_slug_idx ON user_profiles(public_slug); +CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_display_name_key_idx ON user_profiles(display_name_key); CREATE TABLE IF NOT EXISTS profile_moderation ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, bio_hidden INTEGER NOT NULL DEFAULT 0 CHECK(bio_hidden IN (0, 1)), @@ -331,6 +335,7 @@ CREATE TABLE IF NOT EXISTS stat_equipment_types (stat_id TEXT NOT NULL REFERENCE CREATE TABLE IF NOT EXISTS audit_log (id TEXT PRIMARY KEY, actor_id TEXT NOT NULL REFERENCES users(id), entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, action TEXT NOT NULL, before_json TEXT, after_json TEXT, created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS roles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT); +CREATE TABLE IF NOT EXISTS deleted_roles (role_id TEXT PRIMARY KEY, deleted_at TEXT NOT NULL, deleted_by TEXT NOT NULL REFERENCES users(id)); CREATE TABLE IF NOT EXISTS permissions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT); CREATE TABLE IF NOT EXISTS user_roles (user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, PRIMARY KEY(user_id, role_id)); CREATE TABLE IF NOT EXISTS role_permissions (role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, permission_id TEXT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, scope_json TEXT, PRIMARY KEY(role_id, permission_id)); diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index dee141c..9b806c4 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -449,8 +449,11 @@ direct URL. Do not present authentication as a way around a disabled section. - On small screens, the split frame becomes a single column and account controls move before archival content. - Public display names and private login usernames are distinct. Display names - support normalized Unicode and relaxed visible characters; login usernames - remain unique, ASCII-only credentials and must never appear on public-facing + support normalized Unicode and relaxed visible characters. They are unique + under the product's normalized identity key and generate readable, + collision-safe public-profile slugs. New accounts receive the next available + `Agent N` placeholder until the owner chooses a name. Login usernames remain + unique, ASCII-only credentials and must never appear on public-facing community surfaces. Authorized ISAC user operations may show both. ### Public Agent profiles @@ -458,11 +461,17 @@ direct URL. Do not present authentication as a way around a disabled section. Public Agent profiles are community dossiers, not account-control screens. Lead with the shared `AgentIdentityLink` in its large state: circular portrait, display name, explicit `SHD.BUILD AGENT PROFILE` context, and a directional -link cue. The profile owner may receive an `EDIT MY PROFILE` action in the -header. Keep public content totals in a compact telemetry strip below the -identity header, then place the field bio, public builds, published guides, and -recent discussion in the primary column. Profile messages occupy the narrower -communications column and may remain sticky where the viewport has room. +link cue. Place assigned site-role badges beneath the identity as a labeled +semantic list. Show role names only, in alphabetical order, and omit the list +when the Agent has no assigned roles. Badges use the shared raised surface, +explicit role text, and a small orange diamond; they wrap without truncating +long or multiple role names. Never expose role descriptions, permissions, +direct grants, or access scopes on public profiles. The profile owner may +receive an `EDIT MY PROFILE` action in the header. Keep public content totals +in a compact telemetry strip below the identity header, then place the field +bio, public builds, published guides, and recent discussion in the primary +column. Profile messages occupy the narrower communications column and may +remain sticky where the viewport has room. Use `AgentIdentityLink` for author attribution on builds, guides, comments, and other community records. Its compact and default states combine the circular @@ -471,6 +480,11 @@ cue. Hover and focus-visible states may turn the name and cue orange. The portrait remains circular at every size; selectors may use square tactical thumbnails only where the avatar rules explicitly allow them. +Public-profile links use the stored readable slug, URL-encoded as one path +segment. A display-name change updates the canonical slug. Legacy UUID routes +remain valid and redirect to the canonical address; UUIDs remain the internal +identity for mutations, relationships, and audit evidence. + Public biographies and community comments use the shared `MarkdownEditor` and `MarkdownContent` pair. The editor supplies a small formatting toolbar, remaining-character count, concise help, a readable editorial typeface, and a @@ -663,10 +677,18 @@ error, or danger indicators. - Roles is a separate User Operations registry. Role definitions compose discrete capabilities through a dense permission catalogue with an explicit selected count and textual `GRANTED` / `NOT GRANTED` states. The - built-in Admin role is visible but system-protected. Delegated access managers - must never be able to grant `system.admin`; both option visibility and - server-side validation enforce this boundary. Role mutations are recorded in - the audit log with their previous and resulting permission sets. + core Admin role is deletion-protected because it provides the system's + unrestricted access mechanism. Full administrators may edit every role and + delete seeded operational roles; deletion records a persistent tombstone so + migrations do not recreate the role. Delegated access managers may edit + seeded operational roles but may delete only custom roles. Every deletion + requires an exact-name confirmation that states how many agents will + immediately lose the role. Keep Delete separated from Edit and use the danger + treatment only inside this confirmed path. + Delegated access managers must never be able to grant `system.admin`; both + option visibility and server-side validation enforce this boundary. Role + mutations are recorded in the audit log with their previous and resulting + permission and assignment evidence. - Audit History belongs to a separate `SYSTEM OVERSIGHT` navigation domain. Audit records are append-only operational evidence and show the operator, categorical action, target identifier, timestamp, and expandable before/after @@ -1532,10 +1554,12 @@ Public Gear cards use the same rail for compatible Brand Sets and available Talents so players can identify actual options instead of reading a count. Each icon retains its accessible name and hover title, and missing artwork uses the shared placeholder without suppressing the relationship. Relationship -icons are buttons: activating one opens a modal quick view using the same card -facts as that record's public database category. The overlay closes through -its explicit close action, backdrop, or Escape key. Do not render an empty -Talent rail on item types that cannot equip Talents. +icons are buttons: activating one opens a modal quick view with the shared +database card component and the same facts, tier hierarchy, mechanic rows, and +actions shown in that record's public registry. The overlay traps keyboard +focus, returns focus to its trigger when closed, and closes through its explicit +close action, backdrop, or Escape key. Do not render an empty Talent rail on +item types that cannot equip Talents. ### Public game-database registries @@ -1740,16 +1764,26 @@ them below a reliably readable width. Talent registry cards treat the two tier descriptions as their primary content; never replace them with configured/missing summary facts. Render Tier -I and Tier II as distinct structured data blocks, then place optional cooldown -metadata below both descriptions. Do not use the serif, quote-like prose -treatment for tier effects. Use compact fixed tier labels, sans-serif effect -text, and engineered borders. Tier I is the neutral baseline; Tier II uses the -orange rail, label, and subtly elevated surface to communicate that it is the -improved version. Duration and stack limits may remain compact badges. The -top-right header status uses the applicable Backpack or Body Armor icon for -gear talents and the SMG icon as the generic Weapon Talent symbol. Weapon -Talent headers pair that icon with an explicit `1 · TOP` or `2 · BOTTOM` -status badge; never rely on the slot number alone. +I and Tier II as distinct structured data blocks, then place mechanics metadata +below both descriptions. Use 16px semibold sans-serif text for tier effects so +players can scan them before supporting facts. Do not use the serif, +quote-like prose treatment. Use compact fixed tier labels and engineered +borders. Tier I is the neutral baseline; Tier II uses the orange rail, label, +and subtly elevated surface to communicate that it is the improved version. +Render duration and stack limits as detail rows with activation and cooldown +data. Keep calculation effects out of public Talent cards because the tier +descriptions already state the player-facing values. Omit a stack limit of one +because a single active instance is the default. The top-right header status +uses the applicable Backpack or Body Armor icon for gear talents and the SMG +icon as the generic Weapon Talent symbol. Weapon Talent headers pair that icon +with an explicit `1 · TOP` or `2 · BOTTOM` status badge; never rely on the slot +number alone. + +End each Talent card with a compatibility action. Gear Talent actions navigate +to the Gear registry with the Talent filter selected. Weapon Talent actions +navigate to the Weapons registry with the Talent Pool filter selected. Keep +this action in shared cards and relationship quick views so both contexts use +the same destination and label. When a public registry needs only one small bounded filter vocabulary, use the compact facet-panel form: one full-width group with all options visible in a @@ -2265,4 +2299,8 @@ such as quality, focus, ability, or Talent category. Operators may rename, recategorize, and add tags to the library record. Replacing the icon from its game-data editor keeps that metadata and refreshes the context tags and file facts. Removing the source icon removes its linked library record. -General library uploads retain the `ICON` or `IMAGE` state. +Each library card offers `DOWNLOAD`, `EDIT`, and `DELETE` in a connected action +rail. `DELETE` opens a focused confirmation that names the asset and states that +the change is permanent. For assigned icons, the confirmation names the owning +entity type. ISAC clears that source icon assignment before it removes the +shared file. General library uploads retain the `ICON` or `IMAGE` state. diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 75e62a7..5f52639 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -9,6 +9,20 @@ const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Arra const hasExactKeys = (value, keys) => isRecord(value) && Object.keys(value).length === keys.length && keys.every((key) => key in value); const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value); +const displayNameKey = (displayName) => String(displayName ?? '').normalize('NFKC').toLocaleLowerCase('en-US'); +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const publicProfileSlugBase = (displayName, userId) => { + const normalized = String(displayName ?? '') + .normalize('NFKC') + .toLocaleLowerCase('en-US') + .replace(/[^\p{L}\p{N}\p{M}]+/gu, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64) + .replace(/-+$/g, ''); + const fallback = `agent-${String(userId).replaceAll('-', '').slice(0, 8).toLowerCase()}`; + if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback; + return uuidPattern.test(normalized) ? `agent-${normalized}` : normalized; +}; const isRoll = (value) => hasExactKeys(value, ['statId', 'value']) && typeof value.statId === 'string' && isFiniteNumber(value.value); const isRollPair = (value) => Array.isArray(value) && value.length === 2 && value.every(isRoll); @@ -147,6 +161,14 @@ if (userProfilesTable) { if (!profileColumns.some((column) => column.name === 'bio_updated_at')) { await database.exec('ALTER TABLE user_profiles ADD COLUMN bio_updated_at TEXT'); } + if (!profileColumns.some((column) => column.name === 'public_slug')) { + console.log('Adding readable public profile slugs.'); + await database.exec('ALTER TABLE user_profiles ADD COLUMN public_slug TEXT'); + } + if (!profileColumns.some((column) => column.name === 'display_name_key')) { + console.log('Adding normalized display-name identity keys.'); + await database.exec('ALTER TABLE user_profiles ADD COLUMN display_name_key TEXT'); + } } const commentsDefinition = await (await database.prepare( @@ -359,9 +381,63 @@ for (const statement of statements) { if (statement.startsWith('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx')) continue; if (statement.startsWith('CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx')) continue; if (statement.startsWith('CREATE INDEX IF NOT EXISTS conditional_effect_clauses_event_idx')) continue; + if (statement.startsWith('CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_public_slug_idx')) continue; + if (statement.startsWith('CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_display_name_key_idx')) continue; await database.exec(statement); } +const deletedRoleTable = await (await database.prepare( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='deleted_roles'" +)).get(); +if (deletedRoleTable) { + const deletedRoleRows = await (await database.prepare('SELECT role_id FROM deleted_roles')).all(); + for (const deletedRole of deletedRoleRows) { + const roleId = String(deletedRole.role_id); + await (await database.prepare('DELETE FROM user_roles WHERE role_id=?')).run(roleId); + await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(roleId); + await (await database.prepare('DELETE FROM roles WHERE id=?')).run(roleId); + } +} + +const profilesForIdentityMigration = await (await database.prepare(`SELECT user_id,display_name,display_name_key,public_slug + FROM user_profiles ORDER BY user_id`)).all(); +const claimedProfileSlugs = new Set( + profilesForIdentityMigration + .filter((profile) => profile.public_slug) + .map((profile) => String(profile.public_slug)) +); +const claimedDisplayNameKeys = new Set(); +for (const profile of profilesForIdentityMigration) { + const userId = String(profile.user_id); + let displayName = String(profile.display_name); + let key = displayNameKey(displayName); + if (claimedDisplayNameKeys.has(key)) { + for (let suffixLength = 5; suffixLength <= 12; suffixLength += 1) { + const candidate = `${displayName} ${userId.replaceAll('-', '').slice(0, suffixLength).toLowerCase()}`; + const candidateKey = displayNameKey(candidate); + if (!claimedDisplayNameKeys.has(candidateKey)) { + displayName = candidate; + key = candidateKey; + break; + } + } + } + claimedDisplayNameKeys.add(key); + let slug = profile.public_slug ? String(profile.public_slug) : ''; + if (!slug || displayName !== String(profile.display_name)) { + const base = publicProfileSlugBase(displayName, userId); + slug = claimedProfileSlugs.has(base) + ? `${base.slice(0, 55).replace(/-+$/g, '')}~${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}` + : base; + } + claimedProfileSlugs.add(slug); + await (await database.prepare(`UPDATE user_profiles + SET display_name=?,display_name_key=?,public_slug=? WHERE user_id=?`)) + .run(displayName, key, slug, userId); +} +await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_public_slug_idx ON user_profiles(public_slug)'); +await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_display_name_key_idx ON user_profiles(display_name_key)'); + const skillProfileDefinition = await (await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='skill_attribute_calculation_profiles'" )).get(); diff --git a/src/app.css b/src/app.css index 9783351..79374be 100644 --- a/src/app.css +++ b/src/app.css @@ -132,7 +132,6 @@ .site-footer{position:relative;z-index:2;min-height:150px;display:grid;grid-template-columns:1fr auto minmax(300px,.9fr);align-items:center;gap:28px;padding:28px max(24px,5vw);border-top:1px solid var(--shd-border);background:#080a0b}.footer-brand{width:max-content}.site-footer>div>p{margin:8px 0 0;color:var(--shd-text-dim);font:11px var(--shd-font-editorial)}.footer-links{display:grid;gap:8px}.footer-links a{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.fan-disclaimer{margin:0;color:#555d5f;font-size:var(--shd-type-telemetry);line-height:1.6;letter-spacing:.55px}.legal-page{max-width:1320px;margin:auto;padding:55px clamp(18px,5vw,72px) 90px}.legal-hero{padding-bottom:30px;border-bottom:1px solid var(--shd-border)}.legal-hero h1{margin:18px 0 14px;font-size:clamp(48px,7vw,82px);line-height:.9;letter-spacing:-3px}.legal-hero h1 em{color:var(--shd-orange);font-style:normal}.legal-hero>p:last-child{max-width:720px;color:var(--shd-text-muted);font:15px/1.6 var(--shd-font-editorial)}.legal-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:28px;margin-top:24px}.legal-index{position:sticky;top:95px;height:max-content;display:grid;margin:0;border:1px solid var(--shd-border)}.legal-index a{padding:11px;border-top:1px solid var(--shd-border-soft);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.legal-index a:first-child{border-top:0}.legal-index a:hover{color:var(--shd-orange);background:#171b1c}.legal-document{display:grid}.legal-document section{scroll-margin-top:90px;display:grid;grid-template-columns:42px 1fr;gap:14px;padding:24px 0;border-top:1px solid var(--shd-border)}.legal-document section:first-child{border-top:0;padding-top:0}.legal-document section>span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.legal-document h2{font-size:18px;letter-spacing:.6px}.legal-document p{color:#a1a7a8;font:13px/1.7 var(--shd-font-editorial)}.legal-document a{color:var(--shd-orange)}@media(max-width:800px){.site-footer{grid-template-columns:1fr}.legal-layout{grid-template-columns:1fr}.legal-index{position:static;grid-template-columns:1fr 1fr}.login-investigation-list>div{grid-template-columns:1fr 1fr}}@media(max-width:500px){.legal-index,.login-investigation-list>div{grid-template-columns:1fr}} .legal-primary{width:max-content;color:#111!important;margin-top:8px}.contact-page{width:100%;max-width:1500px;margin:auto;padding:34px clamp(18px,4vw,58px) 52px}.contact-hero{position:relative;height:auto;min-height:230px;display:grid;grid-template-columns:minmax(0,1fr) 310px;align-items:center;gap:40px;padding:18px 0 30px;border-bottom:1px solid var(--shd-border);background:transparent}.contact-hero>div{min-width:0}.contact-hero h1{margin:18px 0 14px;font-size:clamp(52px,7vw,88px);line-height:.85;letter-spacing:-4px}.contact-hero h1 em{color:var(--shd-orange);font-style:normal}.contact-hero>div>p:last-child{max-width:690px;margin-bottom:0;color:var(--shd-text-muted);font:15px/1.6 var(--shd-font-editorial)}.discord-contact{min-height:82px;display:grid;grid-template-columns:1fr auto;align-content:center;gap:4px 10px;padding:15px 17px;border:1px solid #6571dc;background:#171a32;color:#aeb6ff}.discord-contact span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.2px}.discord-contact strong{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.discord-contact b{grid-column:2;grid-row:1/3;align-self:center;font-size:18px}.discord-contact:hover,.discord-contact:focus-visible{border-color:#929bff;background:#202446;color:#fff}.contact-layout{display:grid;grid-template-columns:minmax(0,1fr) 320px;align-items:start;gap:12px;margin-top:12px}.contact-console,.contact-guidance{border:1px solid var(--shd-border);background:var(--shd-surface)}.contact-console{padding:16px}.contact-console-head{min-height:45px;display:flex;align-items:start;justify-content:space-between;padding-bottom:10px;margin-bottom:13px;border-bottom:1px solid var(--shd-border)}.contact-console-head>div{display:grid;gap:4px}.contact-console-head span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:1.2px}.contact-console-head strong{font-size:13px}.contact-console-head>b{color:var(--shd-success);font-size:var(--shd-type-telemetry);letter-spacing:1px}.contact-console .segmented-choice>div{height:40px}.contact-console .field{margin:10px 0}.contact-console textarea{line-height:1.5}.contact-identity{display:grid;grid-template-columns:1fr 1fr;gap:9px}.contact-honeypot{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}.dmca-contact-fields{margin-top:15px;padding:12px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#131108}.dmca-contact-fields>header{height:auto;display:flex;align-items:center;justify-content:space-between;padding:0 0 9px;border-bottom:1px solid #493a22;background:transparent}.dmca-contact-fields>header>div{display:grid;gap:4px}.dmca-contact-fields>header span{color:#82755f;font-size:var(--shd-type-telemetry);letter-spacing:1px}.dmca-contact-fields>header strong{color:var(--quality-high-end);font-size:var(--shd-type-telemetry)}.dmca-contact-fields>header a{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.dmca-contact-fields>p{margin:10px 0;color:#9b927f;font:var(--shd-type-supporting)/1.55 var(--shd-font-editorial)}.legal-attestation{min-height:46px;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:9px;margin-top:7px;padding:8px;border:1px solid #3c382e;background:#0d0f0d;color:#b5b0a3;font-size:var(--shd-type-telemetry);line-height:1.45;cursor:pointer}.legal-attestation input{width:auto;margin:0;accent-color:var(--shd-orange)}.legal-attestation:has(input:checked){border-color:#7c5c29;background:#1b170d}.contact-guidance{height:max-content;display:grid}.contact-guidance>div{position:relative;min-height:126px;padding:16px 16px 14px 50px;border-top:1px solid var(--shd-border)}.contact-guidance>div:first-child{border-top:0}.contact-guidance>div>span{position:absolute;left:15px;top:17px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.contact-guidance h2{font-size:13px;letter-spacing:.8px}.contact-guidance p{margin:0;color:var(--shd-text-muted);font:var(--shd-type-supporting)/1.55 var(--shd-font-editorial)}.contact-guidance a{display:inline-block;margin-top:10px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.contact-guidance>small{padding:14px;border-top:1px solid var(--shd-border);color:var(--shd-danger);font-size:var(--shd-type-telemetry);line-height:1.5;letter-spacing:.6px}.contact-success{min-height:300px;display:flex;align-items:center;justify-content:center;gap:16px;text-align:left}.contact-success>span{width:48px;height:48px;display:grid;place-items:center;border:1px solid var(--shd-success);color:var(--shd-success);font-size:18px}.contact-success strong{font-size:13px;letter-spacing:1px}.contact-success p{max-width:430px;margin:7px 0 0;color:var(--shd-text-muted);font:13px/1.55 var(--shd-font-editorial)}.contact-message-page{width:100%;max-width:1200px;min-height:calc(100vh - var(--shd-header-height));margin:auto;padding:36px clamp(18px,4vw,55px) 70px}.contact-back{display:inline-block;margin-bottom:12px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.contact-message{border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)}.contact-message>header{position:static;height:62px;display:flex;justify-content:space-between;padding:0 17px;border-bottom:1px solid var(--shd-border);background:#0d1011}.contact-message>header>div{display:grid;gap:4px}.contact-message>header span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:1px}.contact-message>header strong{font-size:var(--shd-type-telemetry)}.contact-message>header time{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.contact-message-title{padding:28px clamp(20px,5vw,55px);border-bottom:1px solid var(--shd-border)}.contact-message-title h1{margin:12px 0;font-size:clamp(30px,5vw,48px)}.contact-message-title>p:last-child{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.contact-message-title a,.contact-message section a{color:var(--shd-orange)}.contact-message>section{padding:24px clamp(20px,5vw,55px);border-bottom:1px solid var(--shd-border)}.contact-message>section h2{font-size:var(--shd-type-telemetry);letter-spacing:1px}.contact-message>section>p{margin:12px 0 0;color:#c0c4c5;font:14px/1.7 var(--shd-font-editorial);white-space:pre-wrap}.contact-dmca-record dl{display:grid;gap:10px}.contact-dmca-record dl>div{display:grid;grid-template-columns:160px 1fr;gap:12px;padding-top:10px;border-top:1px solid var(--shd-border-soft)}.contact-dmca-record dt{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.contact-dmca-record dd{margin:0;color:#c0c4c5;font:12px/1.6 var(--shd-font-editorial);white-space:pre-wrap}.contact-message>footer{display:flex;justify-content:flex-end;padding:14px}.contact-message>footer .primary{padding:13px 16px}@media(max-width:900px){.contact-hero,.contact-layout{grid-template-columns:1fr}.contact-hero{min-height:0;align-items:start;gap:20px;padding-top:8px}.discord-contact{width:100%}}@media(max-width:600px){.contact-page{padding-top:24px;padding-bottom:36px}.contact-hero h1{letter-spacing:-2.5px}.contact-identity{grid-template-columns:1fr}.contact-console .segmented-choice>div{height:auto;grid-template-columns:1fr}.contact-console .segmented-choice span{min-height:44px}.dmca-contact-fields>header{align-items:flex-start;flex-direction:column;gap:8px}.contact-dmca-record dl>div{grid-template-columns:1fr}.contact-message>footer .primary{width:100%}} @media(max-width:1100px){.effect-editor{grid-template-columns:1fr}.selection-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-identity,.gear-ranges{grid-template-columns:1fr 1fr}}@media(max-width:560px){.effect-band{grid-template-columns:48px 1fr}.effect-band .field:last-child{grid-column:2}.split-actions{align-items:stretch}.split-actions,.split-actions>div{width:100%}.split-actions>div:last-child button{flex:1}.gear-identity,.gear-ranges,.selection-grid{grid-template-columns:1fr}.range-band{grid-template-columns:48px 1fr}.range-band .field:last-child{grid-column:2}} -.dashboard-row>.role-summary{display:flex;flex-wrap:wrap;align-items:center;gap:3px}.role-summary b{flex:0 0 auto;width:max-content;padding:3px 5px;border:1px solid #6e5226;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.6px} .game-icon{display:block;width:24px;height:24px;object-fit:contain}.weapon-editor{border-top-color:#d07a2c}.weapon-identity-grid{display:grid;grid-template-columns:1.6fr repeat(5,minmax(0,1fr));gap:8px}.weapon-fixed-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}.damage-type-picker{min-width:0;margin:0;padding:0;border:0}.damage-type-picker legend{color:#858b8d;font-size:var(--shd-type-telemetry);font-weight:bold;letter-spacing:1px}.damage-type-picker>div{height:32px;display:grid;grid-template-columns:repeat(3,1fr);margin-top:5px;border:1px solid #363b3c;background:#0d0f10}.damage-type-picker label{min-width:0;border-left:1px solid #303536;cursor:pointer}.damage-type-picker label:first-child{border-left:0}.damage-type-picker input{position:absolute;opacity:0;pointer-events:none}.damage-type-picker span{height:100%;display:flex;align-items:center;justify-content:center;gap:3px;color:#737a7c}.damage-type-picker .game-icon{width:17px;height:17px;opacity:.62}.damage-type-picker b{overflow:hidden;font-size:var(--shd-type-telemetry);letter-spacing:.25px;text-overflow:ellipsis}.damage-type-picker label:hover span{background:#171b1c;color:#ecebe5}.damage-type-picker label:active span{transform:translateY(1px);filter:brightness(.88)}.damage-type-picker input:checked+span{background:#211a0e;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.damage-type-picker input:checked+span .game-icon{opacity:1}.damage-type-picker input:focus-visible+span{outline:2px solid #f6a821;outline-offset:2px}.exotic-toggle>span{min-height:32px;margin-top:5px;padding:0 9px;display:flex;align-items:center;gap:7px;border:1px solid #363b3c;background:#0d0f10;color:#d3d5d1;font-size:var(--shd-type-telemetry)}.exotic-toggle input{width:auto;margin:0;accent-color:#f6a821}.weapon-tier-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:9px}.weapon-tier-card{min-width:0;margin:0;padding:7px;border:1px solid #3b4142;background:#101314}.weapon-tier-card legend{padding:0 7px;color:#f6a821;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.2px}.weapon-range-row{display:grid;grid-template-columns:65px 1fr 1fr;gap:5px;align-items:end;padding:5px;border-top:1px solid #282e2f}.weapon-range-row:first-of-type{border-top:0}.weapon-range-row>strong{align-self:center;display:flex;align-items:center;gap:4px;color:#d7d9d5;font-size:var(--shd-type-telemetry)}.weapon-range-row>strong .game-icon{width:20px;height:20px}.weapon-range-row>strong small{display:block;color:#596164;font-size:var(--shd-type-telemetry);letter-spacing:.5px}.weapon-range-row label{color:#737a7c;font-size:var(--shd-type-telemetry);letter-spacing:.7px}.weapon-range-row input{margin-top:3px;padding:7px;font-size:var(--shd-type-telemetry)}.weapon-talent-pools{display:grid;grid-template-columns:1fr 1fr;gap:9px}.compact-selection{grid-template-columns:repeat(2,minmax(0,1fr));max-height:210px;overflow:auto}.weapon-base-bonuses,.weapon-augment-effects{display:grid;gap:5px;margin-top:7px}.base-bonus-row,.augment-effect{display:grid;grid-template-columns:35px minmax(180px,1fr) 130px 30px;gap:6px;align-items:center;padding:5px;border:1px solid #303536;background:#101314}.base-bonus-row>strong,.augment-effect>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:#f6a821;font-size:var(--shd-type-telemetry)}.base-bonus-row .field,.augment-effect .field{margin:0}.base-bonus-row button,.augment-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.base-bonus-row button:disabled{opacity:.3;cursor:not-allowed}.augment-effect:not(:has(>button)){grid-template-columns:35px minmax(180px,1fr) 130px}.augment-sequence{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:5px;margin-top:7px}.augment-sequence>div{min-height:42px;display:grid;place-items:center;padding:5px;border:1px solid #303536;background:#0d1011}.augment-sequence b{color:#f6a821;font-size:var(--shd-type-telemetry)}.augment-sequence span{color:#727a7c;font-size:var(--shd-type-telemetry)}.augment-sequence .exotic-step{border-color:#875d25;background:#211a0e}.weapon-row{min-width:1180px;grid-template-columns:1.3fr .7fr 1.2fr .6fr .6fr .5fr .5fr .3fr}.weapon-row>span small{color:#596164}.weapon-damage-label{display:flex!important;align-items:center;gap:4px}.weapon-damage-label .game-icon{width:16px;height:16px}.weapon-dashboard-table{margin-top:10px} .assignment-picker{border:1px solid #303536;background:#0d1011}.assignment-selected{display:grid;grid-template-columns:70px 1fr;min-height:45px;border-bottom:1px solid #303536}.assignment-summary{display:grid;place-items:center;background:#15191a}.assignment-summary span{color:#687073;font-size:var(--shd-type-telemetry);letter-spacing:1px}.assignment-summary b{color:#f6a821;font-size:14px}.assignment-chips{display:flex;align-items:center;gap:5px;overflow:auto;padding:6px}.assignment-chips>button{flex:0 0 auto;display:flex;align-items:center;gap:7px;padding:6px 8px;border:1px solid #6b5128;background:#211a0e;color:#e2cda7;font-size:var(--shd-type-telemetry);cursor:pointer}.assignment-chips>button:hover{border-color:#f6a821;color:#f6a821}.assignment-chips>button:active{transform:translateY(1px)}.assignment-chips small{color:#596164;font-size:var(--shd-type-telemetry)}.assignment-search{display:block;padding:7px 8px;color:#70787a;font-size:var(--shd-type-telemetry);letter-spacing:1px}.assignment-search input{height:32px;margin-top:5px;padding:0 9px;font-size:var(--shd-type-telemetry)}.assignment-results-head{height:27px;display:flex;align-items:center;justify-content:space-between;padding:0 9px;border-top:1px solid #262b2c;border-bottom:1px solid #262b2c;color:#687073;font-size:var(--shd-type-telemetry);letter-spacing:1px}.assignment-results-head b{color:#8a672b}.assignment-results{max-height:210px;overflow:auto}.assignment-results>button{width:100%;min-height:34px;display:flex;align-items:center;justify-content:space-between;padding:6px 9px;border:0;border-top:1px solid #24292a;background:#111415;color:#a4aaac;text-align:left;font-size:var(--shd-type-telemetry);cursor:pointer}.assignment-results>button:first-child{border-top:0}.assignment-results>button:hover,.assignment-results>button:focus-visible{background:#171b1c;color:#fff;box-shadow:inset 2px 0 #f6a821}.assignment-results>button.selected{background:#211a0e;color:#f6a821;box-shadow:inset 2px 0 #f6a821}.assignment-results>button b{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.assignment-results>button:active{transform:translateY(1px);filter:brightness(.88)}.assignment-results>p{margin:0;padding:25px;text-align:center;color:#596164;font-size:var(--shd-type-telemetry);letter-spacing:1px} .assignment-picker.compact{position:relative;z-index:1;display:grid;grid-template-columns:minmax(120px,.85fr) minmax(135px,1fr);min-width:0}.assignment-picker.compact:focus-within,.assignment-picker.compact.open{z-index:20;border-color:#8a672b}.assignment-picker.compact .assignment-selected{grid-template-columns:42px minmax(0,1fr);min-width:0;min-height:40px;border-right:1px solid var(--shd-border);border-bottom:0}.assignment-picker.compact .assignment-summary span{font-size:var(--shd-type-telemetry)}.assignment-picker.compact .assignment-summary b{font-size:var(--shd-type-telemetry)}.assignment-picker.compact .assignment-chips{min-width:0;padding:4px}.assignment-picker.compact .assignment-chips>button{max-width:100%;padding:5px 6px}.assignment-picker.compact .assignment-chips>button span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.assignment-picker.compact .assignment-search{display:grid;align-content:center;padding:4px}.assignment-picker.compact .assignment-search>span{display:none}.assignment-picker.compact .assignment-search input{height:32px;margin:0}.assignment-picker.compact .assignment-results-head,.assignment-picker.compact .assignment-results{display:none;position:absolute;left:-1px;right:-1px;background:#0d1011}.assignment-picker.compact.open .assignment-results-head{top:100%;display:flex;border:1px solid #8a672b;border-bottom-color:var(--shd-border)}.assignment-picker.compact.open .assignment-results{top:calc(100% + 27px);display:block;border:1px solid #8a672b;border-top:0;box-shadow:0 12px 24px #000b}.assignment-picker.compact .assignment-results>button{min-height:38px}@media(max-width:600px){.assignment-picker.compact{grid-template-columns:1fr}.assignment-picker.compact .assignment-selected{border-right:0;border-bottom:1px solid var(--shd-border)}} @@ -226,6 +225,31 @@ .database-item-icon-groups{display:grid;gap:8px;margin:8px 12px 0}.database-item-icon-groups>div{display:grid;grid-template-columns:110px minmax(0,1fr);align-items:center;gap:9px;padding-top:8px;border-top:1px solid var(--shd-border)}.database-item-icon-groups>div>span{color:#626a6c;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px}.item-icon-rail{min-width:0;display:flex;flex-wrap:wrap;gap:5px}.item-icon-rail>button{width:34px;height:34px;display:grid;place-items:center;padding:0;border:1px solid #394041;background:#0d1011;cursor:pointer}.item-icon-rail>button:hover,.item-icon-rail>button:focus-visible{border-color:var(--shd-orange);background:#211a0e}.item-icon-rail>button:active{transform:translateY(1px);filter:brightness(.82)}.item-icon-rail img{width:30px;height:30px;object-fit:contain}.item-icon-rail small{color:#687073;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.55px}.database-preview-backdrop{position:fixed;z-index:1200;inset:0;display:grid;place-items:center;padding:20px;background:rgba(3,5,5,.82);backdrop-filter:blur(3px)}.database-preview-dialog{width:min(520px,100%);max-height:calc(100vh - 40px);overflow:auto;border:1px solid #4b5254;border-top:2px solid var(--shd-orange);background:#0b0e0f;box-shadow:0 18px 60px #000}.database-preview-dialog>header{height:auto;display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--shd-border);background:#15191a}.database-preview-dialog>header>div{display:grid;gap:3px}.database-preview-dialog>header small{color:#697174;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.database-preview-dialog>header strong{font-size:var(--shd-type-telemetry);letter-spacing:.45px}.database-preview-dialog>header button{width:32px;height:32px;padding:0;border:1px solid #434a4c;background:#101314;color:#aab0b2;font-size:20px;line-height:1;cursor:pointer}.database-preview-dialog>header button:hover,.database-preview-dialog>header button:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}.database-preview-dialog>.database-item-card{margin:10px;border-top-color:#404748} @media(max-width:1200px){.database-category-nav{grid-template-columns:repeat(4,1fr)}.database-category-nav>a:nth-child(5){border-left:0}.database-category-nav>a:nth-child(n+5){border-top:1px solid var(--shd-border)}.database-facet-groups{grid-template-columns:1fr 1fr}.database-facet-group:first-child{grid-column:1/-1}.database-item-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:700px){.database-category-command{align-items:stretch;flex-direction:column}.database-category-command>div:last-child{justify-items:start}.database-category-nav{grid-template-columns:repeat(2,1fr)}.database-category-nav>a:nth-child(odd){border-left:0}.database-category-nav>a:nth-child(n+3){border-top:1px solid var(--shd-border)}.database-facet-panel>header{align-items:flex-start;flex-direction:column}.database-facet-groups{grid-template-columns:1fr}.database-facet-group:first-child{grid-column:auto}.database-facet-group>div,.database-facet-groups.compact .database-facet-group>div{grid-template-columns:repeat(2,minmax(0,1fr))}.database-item-grid{grid-template-columns:1fr}.database-item-card>header{grid-template-columns:52px minmax(0,1fr)}.database-item-icon{width:52px;height:52px}.database-item-icon :is(img,.game-icon){width:46px;height:46px}}@media(prefers-reduced-motion:reduce){.database-registry-grid>a{transition:none}} +/* Public talent cards share one readable tier hierarchy in registries and quick views. */ +.database-item-card.talent-card>header{min-height:86px} +.database-item-card.talent-card h2{font-size:18px;line-height:1.1} +.database-item-card.talent-card>header small,.database-item-card.talent-card .database-item-lines span{color:var(--shd-text-muted)} +.database-talent-tiers{display:grid;gap:var(--shd-gap-sm);margin:10px 12px 0} +.database-talent-tiers>section{display:grid;grid-template-columns:54px minmax(0,1fr);align-items:start;border:1px solid var(--shd-border);background:var(--shd-bg)} +.database-talent-tiers>section>span{align-self:stretch;display:grid;place-items:start center;padding:11px 7px;border-right:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px} +.database-talent-tiers>section>p{margin:0;padding:12px 14px;color:var(--shd-text);font:600 var(--shd-type-body)/1.5 var(--shd-font-sans)} +.database-talent-tiers>section:last-child{border-left:2px solid var(--shd-orange);background:var(--shd-surface-raised)} +.database-talent-tiers>section:last-child>span{color:var(--shd-orange)} +.talent-card .database-item-lines.after-description{margin-top:10px} +.talent-card .database-item-lines.after-description>div{grid-template-columns:132px minmax(0,1fr);padding:9px 2px} +.talent-card .database-item-lines.after-description b{color:var(--shd-text);font-size:var(--shd-type-supporting);font-weight:600;line-height:1.45} +.talent-card .database-item-actions{margin-top:auto} +.database-preview-dialog:has(.talent-card){width:min(620px,100%)} +@media(max-width:700px){ + .item-icon-rail>button{width:44px;height:44px} + .database-preview-dialog>header button{width:44px;height:44px} + .talent-card .database-item-lines.after-description>div{grid-template-columns:1fr;gap:5px} +} +@media(max-width:480px){ + .database-talent-tiers>section{grid-template-columns:1fr} + .database-talent-tiers>section>span{place-items:center start;padding:7px 10px;border-right:0;border-bottom:1px solid var(--shd-border)} +} + /* Authenticated desktop identity and account actions */ .desktop-agent-menu{position:relative}.site-header .desktop-agent-trigger{display:grid;grid-template-columns:36px minmax(110px,1fr) auto 12px;align-items:center;gap:10px;min-width:218px;min-height:50px;padding:6px 9px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text);text-align:left}.site-header .desktop-agent-trigger:hover,.site-header .desktop-agent-trigger:focus-visible,.site-header .desktop-agent-trigger[aria-expanded=true]{border-color:var(--shd-orange);background:#171b1c;color:var(--shd-text)}.desktop-agent-trigger>img,.desktop-agent-panel header>img,.mobile-agent-state>img{width:36px;height:36px;border:1px solid var(--shd-border);background:#191d1e;object-fit:cover}.desktop-agent-trigger>span{display:grid;min-width:0;line-height:1}.desktop-agent-trigger>span small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:1px}.desktop-agent-trigger>span b{overflow:hidden;margin-top:5px;font-size:var(--shd-type-telemetry);letter-spacing:1px;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}.desktop-agent-trigger>em{display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:22px;padding:0 5px;border:1px solid #a86d16;background:#241b0d;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-style:normal;font-weight:900}.desktop-agent-trigger>strong{color:var(--shd-orange);font:800 14px/1 monospace;text-align:center}.desktop-agent-panel{position:absolute;z-index:80;top:calc(100% + 10px);right:0;width:min(290px,calc(100vw - 32px));border:1px solid #4a5051;background:#0f1213;box-shadow:0 18px 46px #000a}.desktop-agent-panel:before{position:absolute;top:-6px;right:18px;width:10px;height:10px;border-top:1px solid var(--shd-orange);border-left:1px solid var(--shd-orange);background:#111415;content:"";transform:rotate(45deg)}.desktop-agent-panel header{height:auto;min-height:68px;display:grid;grid-template-columns:44px minmax(0,1fr);align-items:center;gap:12px;padding:11px 12px;border-bottom:1px solid var(--shd-border);background:linear-gradient(90deg,#281e0e66,transparent 70%),#111415}.desktop-agent-panel header>img{width:44px;height:44px}.desktop-agent-panel header>div{display:grid;min-width:0;gap:3px}.desktop-agent-panel header small,.desktop-agent-panel header span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:1px}.desktop-agent-panel header b{overflow:hidden;font-size:var(--shd-type-telemetry);letter-spacing:1px;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}.desktop-agent-panel>a{min-height:53px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;padding:8px 12px;border-bottom:1px solid var(--shd-border-soft);color:var(--shd-text)}.desktop-agent-panel>a>span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.desktop-agent-panel>a>b{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.desktop-agent-panel>a:hover,.desktop-agent-panel>a:focus-visible,.desktop-agent-panel>a.active{background:#171b1c;color:var(--shd-orange)}.desktop-agent-panel>a:hover>b,.desktop-agent-panel>a:focus-visible>b,.desktop-agent-panel>a.active>b{color:var(--shd-orange)}.desktop-agent-panel form,.desktop-agent-panel fieldset{margin:0;padding:0;border:0}.site-header .desktop-agent-panel .desktop-agent-sign-out{width:100%;min-height:45px;padding:8px 12px;border:0;background:#111415;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px;text-align:right;cursor:pointer}.site-header .desktop-agent-panel .desktop-agent-sign-out:hover,.site-header .desktop-agent-panel .desktop-agent-sign-out:focus-visible{background:#211514;color:var(--shd-danger)}.site-header .desktop-agent-panel button:disabled{cursor:wait;opacity:.62}@media(max-width:900px){.mobile-agent-state{grid-template-columns:36px minmax(0,1fr);column-gap:10px}.mobile-agent-state>img{grid-row:1/3;align-self:center}.mobile-agent-state>span,.mobile-agent-state>strong{grid-column:2}}@media(prefers-reduced-motion:reduce){.desktop-agent-trigger,.desktop-agent-panel a,.desktop-agent-panel button{transition:none}} @@ -1122,18 +1146,37 @@ width:104px; height:104px } -.registry-pagination:has(+ .image-library-grid){ - grid-template-columns:minmax(360px,.45fr) auto 1fr auto -} -.registry-pagination:has(+ .image-library-grid) .pagination-search{ - width:100%; - grid-template-columns:minmax(220px,1fr) auto auto -} -@media(max-width:1100px){ - .registry-pagination:has(+ .image-library-grid){ - grid-template-columns:1fr 1fr - } -} +.registry-pagination:has(+ .image-library-grid){margin-bottom:var(--shd-gap-sm);border-bottom:1px solid var(--shd-border)} +.image-library-grid{margin-top:0} +.image-library-grid article{display:flex;flex-direction:column} +.image-library-grid article:hover{border-color:var(--shd-border);background:var(--shd-surface)} +.image-library-grid article.icon:hover{border-top-color:var(--shd-orange)} +.image-library-grid header strong{display:-webkit-box;overflow:hidden;font-size:var(--shd-type-supporting);line-height:1.15;text-overflow:clip;white-space:normal;-webkit-box-orient:vertical;-webkit-line-clamp:2} +.image-library-grid header b.assigned{color:var(--shd-info)} +.image-library-tags{flex:1} +.image-library-facts{min-height:34px} +.image-library-facts span:last-child{text-align:right} +.image-library-actions{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--shd-border);background:var(--shd-bg)} +.image-library-actions .table-action{min-width:0;min-height:36px;display:grid;place-items:center;padding:0 8px;border:0;border-left:1px solid var(--shd-border);background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px;cursor:pointer;text-decoration:none} +.image-library-actions .table-action:first-child{border-left:0} +.image-library-actions .table-action.edit{color:var(--shd-orange)} +.image-library-actions .table-action.destructive{color:var(--shd-danger)} +.image-library-actions .table-action:hover,.image-library-actions .table-action:focus-visible{background:var(--shd-surface-active);color:var(--shd-text)} +.image-library-actions .table-action.edit:hover,.image-library-actions .table-action.edit:focus-visible{color:var(--shd-orange-hover)} +.image-library-actions .table-action.destructive:hover,.image-library-actions .table-action.destructive:focus-visible{background:color-mix(in srgb,var(--shd-danger) 12%,var(--shd-bg));color:var(--shd-danger)} +.image-library-actions .table-action:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-focus-ring);outline-offset:2px} +.image-library-delete-dialog{width:min(560px,100%);border-top-color:var(--shd-danger)} +.image-library-delete-summary{display:grid;grid-template-columns:104px minmax(0,1fr);align-items:center;gap:var(--shd-gap-md);margin-top:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-border);background:var(--shd-bg)} +.image-library-delete-preview{width:104px;height:104px;display:grid;place-items:center;padding:8px;border:1px solid var(--shd-border);background:var(--shd-surface)} +.image-library-delete-preview img{display:block;max-width:100%;max-height:100%;object-fit:contain} +.image-library-delete-summary>div:last-child{display:grid;gap:var(--shd-gap-xs)} +.image-library-delete-summary b{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.8px} +.image-library-delete-summary p{margin:0;color:var(--shd-text-muted);font:var(--shd-type-supporting)/1.45 var(--shd-font-editorial)} +.image-library-delete-summary small{color:var(--shd-danger);font-size:var(--shd-type-telemetry)} +.image-library-delete-actions{display:grid;grid-template-columns:1fr 1.35fr;gap:var(--shd-gap-sm);margin-top:var(--shd-gap-md)} +.image-library-delete-actions form,.image-library-delete-actions fieldset,.image-library-delete-actions button{width:100%;height:100%} +.image-library-delete-actions .secondary,.image-library-delete-actions .danger{min-height:40px;padding:0 10px;font-size:var(--shd-type-telemetry)} +.image-library-delete-actions .danger{border:1px solid var(--shd-danger);background:var(--shd-danger);color:var(--shd-bg)} @media(max-width:700px){ .image-library-upload .image-drop-input{ grid-template-columns:96px minmax(0,1fr); @@ -1152,18 +1195,10 @@ grid-column:1/-1; grid-row:2 } - .registry-pagination:has(+ .image-library-grid){ - grid-template-columns:1fr - } -} -@media(max-width:560px){ - .registry-pagination:has(+ .image-library-grid) .pagination-search{ - grid-template-columns:minmax(0,1fr) auto - } - .registry-pagination:has(+ .image-library-grid) .pagination-search>a{ - grid-column:1/3 - } + .image-library-actions .table-action{min-height:44px} + .image-library-delete-actions .secondary,.image-library-delete-actions .danger{min-height:44px} } +@media(max-width:520px){.image-library-delete-summary{grid-template-columns:72px minmax(0,1fr)}.image-library-delete-preview{width:72px;height:72px}.image-library-delete-actions{grid-template-columns:1fr}} /* Specialization operations: dense identity editing and a horizontally stable focus-tree composer. */ .specialization-toolbar{ diff --git a/src/lib/agents.test.ts b/src/lib/agents.test.ts new file mode 100644 index 0000000..5ee6c47 --- /dev/null +++ b/src/lib/agents.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { + agentProfileHref, + displayNameKey, + newAgentDisplayName, + publicProfileSlugBase +} from './agents.js'; + +describe('display names', () => { + it('uses one normalized key for uniqueness checks', () => { + expect(displayNameKey('Aewyn')).toBe(displayNameKey('Aewyn')); + expect(displayNameKey('E\u0301lite')).toBe(displayNameKey('Élite')); + }); + + it('creates readable short-UID placeholders', () => { + expect(newAgentDisplayName(47)).toBe('Agent 47'); + }); +}); + +describe('publicProfileSlugBase', () => { + it('creates readable normalized slugs', () => { + expect(publicProfileSlugBase(' Aewyn Prime ', '12345678-0000-4000-8000-000000000000')) + .toBe('aewyn-prime'); + expect(publicProfileSlugBase('Élite 装備', '12345678-0000-4000-8000-000000000000')) + .toBe('élite-装備'); + }); + + it('uses a stable fallback when a name has no letters or numbers', () => { + expect(publicProfileSlugBase('⚡️', '12345678-0000-4000-8000-000000000000')) + .toBe('agent-12345678'); + }); + + it('keeps UUID-shaped display names out of the legacy UUID namespace', () => { + expect(publicProfileSlugBase( + '57a1160e-632f-4695-927f-7df03e20753f', + '12345678-0000-4000-8000-000000000000' + )).toBe('agent-57a1160e-632f-4695-927f-7df03e20753f'); + }); +}); + +describe('agentProfileHref', () => { + it('prefers the public slug and safely encodes it', () => { + expect(agentProfileHref({ id: 'uuid', publicSlug: 'élite 装備' }, 'profile-comments')) + .toBe('/agents/%C3%A9lite%20%E8%A3%85%E5%82%99#profile-comments'); + }); + + it('falls back to the UUID for unmigrated records', () => { + expect(agentProfileHref({ id: 'uuid' })).toBe('/agents/uuid'); + }); +}); diff --git a/src/lib/agents.ts b/src/lib/agents.ts new file mode 100644 index 0000000..f460dec --- /dev/null +++ b/src/lib/agents.ts @@ -0,0 +1,36 @@ +export type AgentProfileReference = { + id: string; + publicSlug?: string | null; +}; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function displayNameKey(displayName: string): string { + return displayName.normalize('NFKC').toLocaleLowerCase('en-US'); +} + +export function newAgentDisplayName(sequence: number): string { + return `Agent ${Math.max(1, Math.trunc(sequence))}`; +} + +export function publicProfileSlugBase(displayName: string, userId: string): string { + const normalized = displayName + .normalize('NFKC') + .toLocaleLowerCase('en-US') + .replace(/[^\p{L}\p{N}\p{M}]+/gu, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64) + .replace(/-+$/g, ''); + const fallback = `agent-${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`; + if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback; + return UUID_PATTERN.test(normalized) ? `agent-${normalized}` : normalized; +} + +export function agentProfileHref( + agent: AgentProfileReference, + fragment = '' +): string { + const identifier = agent.publicSlug || agent.id; + const suffix = fragment ? `#${fragment.replace(/^#/, '')}` : ''; + return `/agents/${encodeURIComponent(identifier)}${suffix}`; +} diff --git a/src/lib/components/AgentIdentityLink.svelte b/src/lib/components/AgentIdentityLink.svelte index 0cb0023..b58a504 100644 --- a/src/lib/components/AgentIdentityLink.svelte +++ b/src/lib/components/AgentIdentityLink.svelte @@ -1,4 +1,6 @@ {#if linked} - + {agent.displayName} diff --git a/src/lib/components/DatabaseItemCard.svelte b/src/lib/components/DatabaseItemCard.svelte index 0da81de..74f3b5c 100644 --- a/src/lib/components/DatabaseItemCard.svelte +++ b/src/lib/components/DatabaseItemCard.svelte @@ -13,7 +13,7 @@ } = $props(); -
+
{#if item.gameIcon}{:else}{/if} @@ -41,8 +41,15 @@ {/each}
{/if} - {#if item.description}

{item.description}

{/if} - {#if item.secondaryDescription}

{item.secondaryDescription}

{/if} + {#if item.variant === 'talent'} +
+
TIER I

{item.description ?? 'Description is not available.'}

+
TIER II

{item.secondaryDescription ?? 'Description is not available.'}

+
+ {:else} + {#if item.description}

{item.description}

{/if} + {#if item.secondaryDescription}

{item.secondaryDescription}

{/if} + {/if} {#if item.afterLines?.length}
{#each item.afterLines as line}
{line.label}{line.value}
{/each}
{/if} diff --git a/src/lib/components/ItemIconRail.svelte b/src/lib/components/ItemIconRail.svelte index 770db18..e637084 100644 --- a/src/lib/components/ItemIconRail.svelte +++ b/src/lib/components/ItemIconRail.svelte @@ -1,6 +1,7 @@ - { if (event.key === 'Escape') selected = null; }}/> + { if (event.key === 'Escape' && selected) void closePreview(); }}/>
item.name).join(', ') : emptyLabel}`}> {#if items.length} {#each items as item} - {/each} @@ -30,22 +62,10 @@
{#if selected?.preview} -