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();
-
+
IDENTITY SETUPChoose the SHD.build display name other agents will see. Unicode characters are supported.
{/if}
@@ -168,7 +168,7 @@
{/if}
PUBLIC IDENTITY
DISPLAY NAME
- Visible to other agents. Unicode, spaces, and non-ASCII writing systems are supported.{#if form?.displayNameError}
{form.displayNameError}
{/if}UPDATE DISPLAY NAME →
+ Unique across SHD.build and used to create your readable public-profile address. Unicode, spaces, and non-ASCII writing systems are supported.{#if form?.displayNameError}
{form.displayNameError}
{/if}UPDATE DISPLAY NAME →
FIELD DOSSIER
PUBLIC BIO
{#if data.profile.bioHidden}
diff --git a/src/routes/account/notifications/+page.server.ts b/src/routes/account/notifications/+page.server.ts
index b967d35..1415363 100644
--- a/src/routes/account/notifications/+page.server.ts
+++ b/src/routes/account/notifications/+page.server.ts
@@ -5,16 +5,15 @@ import { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { NOTIFICATION_CATEGORIES } from '$lib/server/notifications';
import { getPermissions } from '$lib/server/auth/authorization';
import { OVERSIGHT_NOTIFICATION_CATEGORIES, OVERSIGHT_NOTIFICATION_FREQUENCIES } from '$lib/server/oversight-notifications';
+import { notificationOrderBy, parseNotificationSort, parseNotificationState } from '$lib/server/notification-sort';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
- const requestedState = String(url.searchParams.get('state') ?? 'all');
- const state = ['all', 'unread', 'read'].includes(requestedState) ? requestedState : 'all';
- const requestedSort = String(url.searchParams.get('sort') ?? 'newest');
- const sort = ['newest', 'oldest', 'unread'].includes(requestedSort) ? requestedSort : 'newest';
+ const state = parseNotificationState(url.searchParams.get('state'));
+ const sort = parseNotificationSort(url.searchParams.get('sort'));
const { search, pattern } = getRegistrySearch(url);
const conditions = ['n.user_id=?', '(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)'];
const args: unknown[] = [user.id];
@@ -27,11 +26,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
args.push(pattern, pattern, pattern);
}
const where = `WHERE ${conditions.join(' AND ')}`;
- const orderBy = {
- newest: 'n.created_at DESC, n.id DESC',
- oldest: 'n.created_at ASC, n.id ASC',
- unread: 'CASE WHEN n.read_at IS NULL THEN 0 ELSE 1 END, n.created_at DESC, n.id DESC'
- }[sort];
+ const orderBy = notificationOrderBy(sort);
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category ${where}`)).get(...args))?.total ?? 0);
const pagination = getPagination(url, total);
const notifications = await (await database.prepare(`SELECT n.* FROM notifications n
diff --git a/src/routes/agents/[id]/+page.server.ts b/src/routes/agents/[id]/+page.server.ts
index 6b29308..42a796d 100644
--- a/src/routes/agents/[id]/+page.server.ts
+++ b/src/routes/agents/[id]/+page.server.ts
@@ -9,9 +9,11 @@ import {
createUserContentReport,
recordCommunityModerationScan
} from '$lib/server/community-moderation';
-import { error, fail } from '@sveltejs/kit';
+import { error, fail, redirect } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { queueCurrentAvatarReview } from '$lib/server/avatars';
+import { agentProfileHref } from '$lib/agents';
+import { resolveAgentProfile } from '$lib/server/agent-profiles';
import type { Actions, PageServerLoad } from './$types';
const COMMENT_MAX_CHARACTERS = 2_000;
@@ -22,17 +24,25 @@ const unavailable = (row: Record | null | undefined) => {
return !row || Boolean(row.deleted_at || row.blocked_at || banned);
};
-const commentHref = (targetType: string, slug: unknown, targetId: string) => {
- if (targetType === 'profile') return `/agents/${encodeURIComponent(targetId)}#profile-comments`;
+const commentHref = (targetType: string, slug: unknown, targetId: string, targetPublicSlug: unknown) => {
+ if (targetType === 'profile') {
+ return agentProfileHref({
+ id: targetId,
+ publicSlug: typeof targetPublicSlug === 'string' ? targetPublicSlug : null
+ }, 'profile-comments');
+ }
return `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
};
export const load: PageServerLoad = async ({ params, cookies }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
+ const resolved = await resolveAgentProfile(database, params.id);
+ if (!resolved) error(404, 'Agent profile not found');
+ const agentId = resolved.id;
const agent = await (await database.prepare(`SELECT users.id,
COALESCE(profile.display_name,users.username) AS display_name,
- profile.bio_markdown,profile.is_public,profile.bio_updated_at,
+ profile.public_slug,profile.bio_markdown,profile.is_public,profile.bio_updated_at,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
status.blocked_at,status.banned_until,deleted.deleted_at
@@ -42,26 +52,34 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
LEFT JOIN user_status status ON status.user_id=users.id
LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id
- WHERE users.id=?`)).get(params.id);
- const isSelf = viewer?.id === params.id;
+ WHERE users.id=?`)).get(agentId);
+ const isSelf = viewer?.id === agentId;
if (
unavailable(agent)
|| (!Boolean(agent?.is_public) && !isSelf)
) error(404, 'Agent profile not found');
+ if (params.id !== String(agent.public_slug)) {
+ redirect(308, agentProfileHref({ id: agentId, publicSlug: String(agent.public_slug) }));
+ }
- const [buildRows, guideRows, authoredCommentRows, profileCommentRows, authoredCount, profileCommentCount, contentCounts] =
+ const [roleRows, buildRows, guideRows, authoredCommentRows, profileCommentRows, authoredCount, profileCommentCount, contentCounts] =
await Promise.all([
+ (await database.prepare(`SELECT roles.name FROM user_roles
+ JOIN roles ON roles.id=user_roles.role_id
+ WHERE user_roles.user_id=?
+ ORDER BY roles.name COLLATE NOCASE,roles.id`)).all(agentId),
(await database.prepare(`SELECT id,slug,title,COALESCE(summary,'') AS summary,updated_at
FROM builds WHERE owner_id=? AND visibility='public'
AND (guide_body IS NULL OR length(trim(guide_body))=0)
- ORDER BY updated_at DESC LIMIT 60`)).all(params.id),
+ ORDER BY updated_at DESC LIMIT 60`)).all(agentId),
(await database.prepare(`SELECT id,slug,title,COALESCE(summary,'') AS summary,updated_at
FROM builds WHERE owner_id=? AND visibility='public'
AND guide_body IS NOT NULL AND length(trim(guide_body))>0
- ORDER BY updated_at DESC LIMIT 60`)).all(params.id),
+ ORDER BY updated_at DESC LIMIT 60`)).all(agentId),
(await database.prepare(`SELECT comments.id,comments.body_markdown,comments.created_at,
thread.target_type,thread.target_id,builds.title AS build_title,builds.slug,
- COALESCE(target_profile.display_name,target_user.username) AS target_display_name
+ COALESCE(target_profile.display_name,target_user.username) AS target_display_name,
+ target_profile.public_slug AS target_public_slug
FROM comments
JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN builds ON builds.id=thread.target_id AND thread.target_type IN ('build','guide')
@@ -75,9 +93,10 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
AND target_deleted.user_id IS NULL AND target_status.blocked_at IS NULL
AND (target_status.banned_until IS NULL OR target_status.banned_until<=?))
)
- ORDER BY comments.created_at DESC LIMIT 50`)).all(params.id, new Date().toISOString()),
+ ORDER BY comments.created_at DESC LIMIT 50`)).all(agentId, new Date().toISOString()),
(await database.prepare(`SELECT comments.id,comments.author_id,comments.body_markdown,
comments.created_at,COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
+ profile.public_slug,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at
FROM comment_threads thread
JOIN comments ON comments.thread_id=thread.id AND comments.status='visible'
@@ -85,7 +104,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
WHERE thread.target_type='profile' AND thread.target_id=?
- ORDER BY comments.created_at DESC LIMIT 100`)).all(params.id),
+ ORDER BY comments.created_at DESC LIMIT 100`)).all(agentId),
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN builds ON builds.id=thread.target_id AND thread.target_type IN ('build','guide')
@@ -98,28 +117,30 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
OR (thread.target_type='profile' AND COALESCE(target_profile.is_public,0)=1
AND target_deleted.user_id IS NULL AND target_status.blocked_at IS NULL
AND (target_status.banned_until IS NULL OR target_status.banned_until<=?))
- )`)).get(params.id, new Date().toISOString()),
+ )`)).get(agentId, new Date().toISOString()),
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE thread.target_type='profile' AND thread.target_id=? AND comments.status='visible'`))
- .get(params.id),
+ .get(agentId),
(await database.prepare(`SELECT
SUM(CASE WHEN guide_body IS NULL OR length(trim(guide_body))=0 THEN 1 ELSE 0 END) AS builds,
SUM(CASE WHEN guide_body IS NOT NULL AND length(trim(guide_body))>0 THEN 1 ELSE 0 END) AS guides
- FROM builds WHERE owner_id=? AND visibility='public'`)).get(params.id)
+ FROM builds WHERE owner_id=? AND visibility='public'`)).get(agentId)
]);
return {
agent: {
id: String(agent.id),
displayName: String(agent.display_name),
+ publicSlug: String(agent.public_slug),
avatarUrl: avatarUrl(
agent.avatar_source,
agent.avatar_value,
String(agent.id),
agent.avatar_updated_at
),
- bioHtml: agent.bio_hidden ? '' : renderUserMarkdown(agent.bio_markdown)
+ bioHtml: agent.bio_hidden ? '' : renderUserMarkdown(agent.bio_markdown),
+ roles: roleRows.map((role) => String(role.name))
},
counts: {
builds: Number(contentCounts?.builds ?? 0),
@@ -152,7 +173,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
targetTitle: targetType === 'profile'
? `${String(comment.target_display_name ?? 'Agent')} profile`
: String(comment.build_title ?? 'Community content'),
- targetHref: commentHref(targetType, comment.slug, targetId)
+ targetHref: commentHref(targetType, comment.slug, targetId, comment.target_public_slug)
};
}),
profileComments: profileCommentRows.map((comment) => ({
@@ -162,6 +183,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
author: {
id: String(comment.author_id),
displayName: String(comment.display_name),
+ publicSlug: comment.public_slug ? String(comment.public_slug) : null,
avatarUrl: avatarUrl(
comment.avatar_source,
comment.avatar_value,
@@ -195,6 +217,9 @@ export const actions: Actions = {
});
}
const database = await getDatabase();
+ const owner = await resolveAgentProfile(database, params.id);
+ if (!owner) return fail(404, { profileCommentError: 'Agent profile not found.' });
+ const agentId = owner.id;
const moderationPolicy = await getCommunityModerationPolicy();
const now = new Date();
const rows = await (await database.prepare(`SELECT users.id,
@@ -204,9 +229,9 @@ export const actions: Actions = {
LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN user_status status ON status.user_id=users.id
LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id
- WHERE users.id IN (?,?)`)).all(commenter.id, params.id);
+ WHERE users.id IN (?,?)`)).all(commenter.id, agentId);
const commenterState = rows.find((row) => row.id === commenter.id);
- const ownerState = rows.find((row) => row.id === params.id);
+ const ownerState = rows.find((row) => row.id === agentId);
if (unavailable(commenterState)) {
return fail(403, { profileCommentError: 'This account cannot post comments.' });
}
@@ -218,7 +243,7 @@ export const actions: Actions = {
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.author_id=? AND comments.status IN ('visible','pending_review')
AND thread.target_type='profile' AND thread.target_id=?
- ORDER BY comments.created_at DESC LIMIT 1`)).get(commenter.id, params.id);
+ ORDER BY comments.created_at DESC LIMIT 1`)).get(commenter.id, agentId);
if (typeof recent?.created_at === 'string') {
const elapsed = (now.getTime() - new Date(recent.created_at).getTime()) / 1_000;
if (elapsed < RAPID_COMMENT_SECONDS) {
@@ -239,10 +264,10 @@ export const actions: Actions = {
const proposedThreadId = randomUUID();
await (await database.prepare(`INSERT OR IGNORE INTO comment_threads
(id,target_type,target_id,created_at) VALUES(?,'profile',?,?)`))
- .run(proposedThreadId, params.id, createdAt);
+ .run(proposedThreadId, agentId, createdAt);
const thread = await (await database.prepare(
"SELECT id FROM comment_threads WHERE target_type='profile' AND target_id=?"
- )).get(params.id);
+ )).get(agentId);
if (!thread?.id) return fail(500, { profileCommentError: 'The profile thread could not be opened.' });
const commentId = randomUUID();
await (await database.prepare(`INSERT INTO comments
@@ -255,17 +280,18 @@ export const actions: Actions = {
authorId: commenter.id,
result: moderation
});
- if (commentStatus === 'visible' && commenter.id !== params.id) {
+ if (commentStatus === 'visible' && commenter.id !== agentId) {
+ const profileLink = agentProfileHref(owner, 'profile-comments');
await createNotification({
- userId: params.id,
+ userId: agentId,
category: 'comments',
source: 'agent',
title: 'New profile comment',
body: `${commenter.displayName} posted a comment on your Agent profile.`,
- link: `/agents/${encodeURIComponent(params.id)}#profile-comments`,
+ link: profileLink,
actions: [{
label: 'VIEW COMMENT',
- href: `/agents/${encodeURIComponent(params.id)}#profile-comments`
+ href: profileLink
}]
});
}
@@ -283,10 +309,12 @@ export const actions: Actions = {
const commentId = String((await request.formData()).get('commentId') ?? '').trim();
if (!commentId) return fail(400, { profileCommentError: 'Choose a comment to remove.' });
const database = await getDatabase();
+ const owner = await resolveAgentProfile(database, params.id);
+ if (!owner) return fail(404, { profileCommentError: 'Agent profile not found.' });
const comment = await (await database.prepare(`SELECT comments.id,comments.author_id
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.id=? AND comments.status='visible'
- AND thread.target_type='profile' AND thread.target_id=?`)).get(commentId, params.id);
+ AND thread.target_type='profile' AND thread.target_id=?`)).get(commentId, owner.id);
if (!comment) return fail(404, { profileCommentError: 'That comment is no longer available.' });
if (comment.author_id !== viewer.id) {
return fail(403, { profileCommentError: 'You can remove only your own comment.' });
@@ -326,22 +354,25 @@ export const actions: Actions = {
return failure(400, 'Report details may contain up to 500 visible characters.');
}
const database = await getDatabase();
+ const owner = await resolveAgentProfile(database, params.id);
+ if (!owner) return failure(404, 'Agent profile not found.');
+ const agentId = owner.id;
if (contentType === 'comment') {
const comment = await (await database.prepare(`SELECT comments.author_id
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.id=? AND comments.status='visible'
- AND thread.target_type='profile' AND thread.target_id=?`)).get(contentId, params.id);
+ AND thread.target_type='profile' AND thread.target_id=?`)).get(contentId, agentId);
if (!comment) return failure(404, 'That comment is no longer available.');
if (String(comment.author_id) === reporter.id) return failure(400, 'You cannot report your own comment.');
} else {
- if (contentId !== params.id) return failure(400, 'That profile content does not match this Agent.');
- if (reporter.id === params.id) return failure(400, 'You cannot report your own profile content.');
+ if (contentId !== agentId) return failure(400, 'That profile content does not match this Agent.');
+ if (reporter.id === agentId) return failure(400, 'You cannot report your own profile content.');
const profile = await (await database.prepare(`SELECT profile.bio_markdown,profile.is_public,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,avatar.source AS avatar_source
FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
LEFT JOIN user_avatars avatar ON avatar.user_id=profile.user_id
- WHERE profile.user_id=?`)).get(params.id);
+ WHERE profile.user_id=?`)).get(agentId);
if (!profile?.is_public) return failure(404, 'That profile is no longer available.');
if (contentType === 'profile_bio' && (profile.bio_hidden || !String(profile.bio_markdown ?? '').trim())) {
return failure(404, 'That biography is no longer available.');
@@ -359,7 +390,7 @@ export const actions: Actions = {
});
if (!report) return failure(409, 'You already have a pending report for this content.');
if (contentType === 'avatar') {
- await queueCurrentAvatarReview(database, params.id, { origin: 'user_report' });
+ await queueCurrentAvatarReview(database, agentId, { origin: 'user_report' });
}
return {
userReportSaved: true,
diff --git a/src/routes/agents/[id]/+page.svelte b/src/routes/agents/[id]/+page.svelte
index 6df6c0e..e367fe1 100644
--- a/src/routes/agents/[id]/+page.svelte
+++ b/src/routes/agents/[id]/+page.svelte
@@ -6,12 +6,15 @@
import ModerationDisclosure from '$lib/components/ModerationDisclosure.svelte';
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
+ import RoleBadgeList from '$lib/components/RoleBadgeList.svelte';
type PublicAgent = {
id: string;
displayName: string;
+ publicSlug: string;
avatarUrl: string;
bioHtml: string;
+ roles: string[];
};
type PublicContent = {
id: string;
@@ -32,7 +35,7 @@
id: string;
bodyHtml: string;
createdAt: string;
- author: { id: string; displayName: string; avatarUrl: string };
+ author: { id: string; displayName: string; publicSlug: string | null; avatarUrl: string };
canDelete: boolean;
canReport: boolean;
};
@@ -76,7 +79,10 @@
-
+