Add public agent profiles and community moderation

This commit is contained in:
JB
2026-07-27 15:38:27 -04:00
parent efb0ccdc7e
commit 6b76110b76
28 changed files with 2101 additions and 64 deletions
+59 -12
View File
@@ -1,6 +1,6 @@
{ {
"schemaVersion": 2, "schemaVersion": 2,
"generatedAt": "2026-07-26T17:47:28.721Z", "generatedAt": "2026-07-27T19:09:41.393Z",
"title": "Design System: SHD.build", "title": "Design System: SHD.build",
"extensions": { "extensions": {
"colorMeta": { "colorMeta": {
@@ -34,6 +34,12 @@
"canonical": "#15191a", "canonical": "#15191a",
"tonalRamp": ["#080a0a", "#15191a", "#252a2b", "#3e4445", "#626a6c", "#909799", "#c8cccb", "#f2f3f0"] "tonalRamp": ["#080a0a", "#15191a", "#252a2b", "#3e4445", "#626a6c", "#909799", "#c8cccb", "#f2f3f0"]
}, },
"modeled-data-surface": {
"role": "neutral",
"displayName": "Modeled Data Surface",
"canonical": "#0d1213",
"tonalRamp": ["#050708", "#0d1213", "#1c2527", "#334043", "#526165", "#7d8b8e", "#bac2c2", "#eff1ef"]
},
"structural-line": { "structural-line": {
"role": "neutral", "role": "neutral",
"displayName": "Structural Line", "displayName": "Structural Line",
@@ -46,6 +52,18 @@
"canonical": "#272c2d", "canonical": "#272c2d",
"tonalRamp": ["#0b0d0d", "#181b1c", "#272c2d", "#43494a", "#656d6f", "#8e9597", "#c2c6c5", "#eff0ed"] "tonalRamp": ["#0b0d0d", "#181b1c", "#272c2d", "#43494a", "#656d6f", "#8e9597", "#c2c6c5", "#eff0ed"]
}, },
"structural-line-strong": {
"role": "neutral",
"displayName": "Structural Line Strong",
"canonical": "#424849",
"tonalRamp": ["#111415", "#252a2b", "#424849", "#5d6567", "#7c8587", "#a0a7a8", "#c9cdcc", "#f1f2ef"]
},
"modeled-data-line": {
"role": "neutral",
"displayName": "Modeled Data Line",
"canonical": "#3b4648",
"tonalRamp": ["#0e1314", "#20292b", "#3b4648", "#566366", "#758184", "#9aa3a5", "#c6cbca", "#f0f2ef"]
},
"field-manual-white": { "field-manual-white": {
"role": "neutral", "role": "neutral",
"displayName": "Field Manual White", "displayName": "Field Manual White",
@@ -195,7 +213,7 @@
"refersTo": "button-primary", "refersTo": "button-primary",
"description": "The decisive action within a task group.", "description": "The decisive action within a task group.",
"html": "<button class=\"ds-btn-primary\">SAVE CHANGES <span aria-hidden=\"true\">→</span></button>", "html": "<button class=\"ds-btn-primary\">SAVE CHANGES <span aria-hidden=\"true\">→</span></button>",
"css": ".ds-btn-primary{min-height:40px;border:0;border-radius:0;padding:12px 22px;background:var(--shd-orange,#f6a821);color:#111;font:900 11px/1 Barlow,Arial,sans-serif;letter-spacing:1.6px;text-transform:uppercase;cursor:pointer;transition:transform .12s ease,filter .18s ease,background .18s ease}.ds-btn-primary:hover{background:var(--shd-orange-hover,#ffc45d)}.ds-btn-primary:focus-visible{outline:1px solid var(--shd-focus-ring,#f6a821);outline-offset:2px}.ds-btn-primary:active{transform:translateY(2px) scale(.985);filter:brightness(.9)}.ds-btn-primary:disabled{cursor:wait;opacity:.58}" "css": ".ds-btn-primary{min-height:40px;border:0;border-radius:0;padding:12px 22px;background:var(--shd-orange,#f6a821);color:#111;font:900 12px/1 Barlow,Arial,sans-serif;letter-spacing:1.6px;text-transform:uppercase;cursor:pointer;transition:transform .12s ease,filter .18s ease,background .18s ease}.ds-btn-primary:hover{background:var(--shd-orange-hover,#ffc45d)}.ds-btn-primary:focus-visible{outline:1px solid var(--shd-focus-ring,#f6a821);outline-offset:2px}.ds-btn-primary:active{transform:translateY(2px) scale(.985);filter:brightness(.9)}.ds-btn-primary:disabled{cursor:wait;opacity:.58}"
}, },
{ {
"name": "Secondary Action", "name": "Secondary Action",
@@ -203,7 +221,7 @@
"refersTo": "button-secondary", "refersTo": "button-secondary",
"description": "A supporting or cancel action with equal geometric discipline.", "description": "A supporting or cancel action with equal geometric discipline.",
"html": "<button class=\"ds-btn-secondary\">CANCEL</button>", "html": "<button class=\"ds-btn-secondary\">CANCEL</button>",
"css": ".ds-btn-secondary{min-height:40px;border:1px solid var(--shd-border,#303536);border-radius:0;padding:12px 22px;background:transparent;color:var(--shd-text,#ecebe5);font:900 11px/1 Barlow,Arial,sans-serif;letter-spacing:1.6px;text-transform:uppercase;cursor:pointer;transition:transform .12s ease,filter .18s ease,border-color .18s ease}.ds-btn-secondary:hover{border-color:var(--shd-orange,#f6a821);color:var(--shd-orange-hover,#ffc45d)}.ds-btn-secondary:focus-visible{outline:1px solid var(--shd-focus-ring,#f6a821);outline-offset:2px}.ds-btn-secondary:active{transform:translateY(2px) scale(.985);filter:brightness(.9)}" "css": ".ds-btn-secondary{min-height:40px;border:1px solid var(--shd-border,#303536);border-radius:0;padding:12px 22px;background:transparent;color:var(--shd-text,#ecebe5);font:900 12px/1 Barlow,Arial,sans-serif;letter-spacing:1.6px;text-transform:uppercase;cursor:pointer;transition:transform .12s ease,filter .18s ease,border-color .18s ease}.ds-btn-secondary:hover{border-color:var(--shd-orange,#f6a821);color:var(--shd-orange-hover,#ffc45d)}.ds-btn-secondary:focus-visible{outline:1px solid var(--shd-focus-ring,#f6a821);outline-offset:2px}.ds-btn-secondary:active{transform:translateY(2px) scale(.985);filter:brightness(.9)}"
}, },
{ {
"name": "Operational Field", "name": "Operational Field",
@@ -211,7 +229,7 @@
"refersTo": "input", "refersTo": "input",
"description": "A recessed data-entry field with explicit label and precise focus.", "description": "A recessed data-entry field with explicit label and precise focus.",
"html": "<label class=\"ds-field\"><span>ATTRIBUTE NAME</span><input value=\"Critical Hit Damage\" /></label>", "html": "<label class=\"ds-field\"><span>ATTRIBUTE NAME</span><input value=\"Critical Hit Damage\" /></label>",
"css": ".ds-field{display:grid;gap:6px;color:var(--shd-text-muted,#858b8d);font:900 8px/1 Barlow,Arial,sans-serif;letter-spacing:1.2px;text-transform:uppercase}.ds-field input{width:100%;min-height:40px;border:1px solid #363b3c;border-radius:0;padding:11px;background:var(--shd-bg-field,#0d0f10);color:var(--shd-text,#ecebe5);font:400 14px/1.3 Barlow,Arial,sans-serif}.ds-field input:focus{border-color:var(--shd-orange,#f6a821);outline:none}.ds-field input:focus-visible{outline:1px solid var(--shd-orange,#f6a821);outline-offset:1px}" "css": ".ds-field{display:grid;gap:6px;color:var(--shd-text-muted,#858b8d);font:900 12px/1 Barlow,Arial,sans-serif;letter-spacing:1.2px;text-transform:uppercase}.ds-field input{width:100%;min-height:40px;border:1px solid #363b3c;border-radius:0;padding:11px;background:var(--shd-bg-field,#0d0f10);color:var(--shd-text,#ecebe5);font:400 16px/1.3 Barlow,Arial,sans-serif}.ds-field input:focus{border-color:var(--shd-orange,#f6a821);outline:none}.ds-field input:focus-visible{outline:1px solid var(--shd-orange,#f6a821);outline-offset:1px}"
}, },
{ {
"name": "Segmented Choice", "name": "Segmented Choice",
@@ -219,7 +237,7 @@
"refersTo": "status-chip", "refersTo": "status-chip",
"description": "A bounded radio-style choice rendered as one connected instrument.", "description": "A bounded radio-style choice rendered as one connected instrument.",
"html": "<div class=\"ds-segments\" role=\"radiogroup\" aria-label=\"Tier\"><button role=\"radio\" aria-checked=\"false\">T0</button><button role=\"radio\" aria-checked=\"true\">T1</button><button role=\"radio\" aria-checked=\"false\">T2</button></div>", "html": "<div class=\"ds-segments\" role=\"radiogroup\" aria-label=\"Tier\"><button role=\"radio\" aria-checked=\"false\">T0</button><button role=\"radio\" aria-checked=\"true\">T1</button><button role=\"radio\" aria-checked=\"false\">T2</button></div>",
"css": ".ds-segments{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--shd-border,#303536);background:var(--shd-bg,#0a0c0d)}.ds-segments button{min-height:44px;border:0;border-left:1px solid var(--shd-border,#303536);border-radius:0;background:var(--shd-surface,#111415);color:var(--shd-text-muted,#858b8d);font:900 9px/1 Barlow,Arial,sans-serif;letter-spacing:1px;cursor:pointer}.ds-segments button:first-child{border-left:0}.ds-segments button:hover,.ds-segments button:focus-visible{background:var(--shd-surface-active,#15191a);color:var(--shd-text,#ecebe5)}.ds-segments button:focus-visible{outline:1px solid var(--shd-orange,#f6a821);outline-offset:-2px}.ds-segments button[aria-checked=true]{background:#2b2111;color:var(--shd-orange,#f6a821);box-shadow:inset 0 -2px var(--shd-orange,#f6a821)}.ds-segments button:active{filter:brightness(.82)}" "css": ".ds-segments{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--shd-border,#303536);background:var(--shd-bg,#0a0c0d)}.ds-segments button{min-height:44px;border:0;border-left:1px solid var(--shd-border,#303536);border-radius:0;background:var(--shd-surface,#111415);color:var(--shd-text-muted,#858b8d);font:900 12px/1 Barlow,Arial,sans-serif;letter-spacing:1px;cursor:pointer}.ds-segments button:first-child{border-left:0}.ds-segments button:hover,.ds-segments button:focus-visible{background:var(--shd-surface-active,#15191a);color:var(--shd-text,#ecebe5)}.ds-segments button:focus-visible{outline:1px solid var(--shd-orange,#f6a821);outline-offset:-2px}.ds-segments button[aria-checked=true]{background:#2b2111;color:var(--shd-orange,#f6a821);box-shadow:inset 0 -2px var(--shd-orange,#f6a821)}.ds-segments button:active{filter:brightness(.82)}"
}, },
{ {
"name": "Database Record Card", "name": "Database Record Card",
@@ -227,26 +245,34 @@
"refersTo": "panel", "refersTo": "panel",
"description": "A public data card built from identity, metadata, and compact fact regions.", "description": "A public data card built from identity, metadata, and compact fact regions.",
"html": "<article class=\"ds-record\"><header><div class=\"ds-record-icon\" aria-hidden=\"true\">SHD</div><div><small>WEAPON // SMG</small><h3>FIRST WAVE VECTOR</h3></div><b>T2</b></header><dl><div><dt>DAMAGE</dt><dd>12,480–15,920</dd></div><div><dt>RPM</dt><dd>1,200</dd></div></dl><p>Level 50 operational values calculated from authoritative game data.</p></article>", "html": "<article class=\"ds-record\"><header><div class=\"ds-record-icon\" aria-hidden=\"true\">SHD</div><div><small>WEAPON // SMG</small><h3>FIRST WAVE VECTOR</h3></div><b>T2</b></header><dl><div><dt>DAMAGE</dt><dd>12,480–15,920</dd></div><div><dt>RPM</dt><dd>1,200</dd></div></dl><p>Level 50 operational values calculated from authoritative game data.</p></article>",
"css": ".ds-record{border:1px solid var(--shd-border,#303536);border-top:2px solid #404748;border-radius:0;background:var(--shd-surface,#111415);color:var(--shd-text,#ecebe5);font-family:Barlow,Arial,sans-serif}.ds-record header{min-height:78px;display:grid;grid-template-columns:58px minmax(0,1fr) auto;align-items:center;gap:12px;padding:11px 13px;border-bottom:1px solid var(--shd-border,#303536)}.ds-record-icon{width:58px;height:58px;display:grid;place-items:center;border:1px solid #343a3b;background:#0c0f10;color:var(--shd-orange,#f6a821);font-size:9px;font-weight:900}.ds-record small,.ds-record dt{color:#697174;font-size:6px;font-weight:900;letter-spacing:.8px}.ds-record h3{margin:5px 0 0;font-size:14px;line-height:1.1}.ds-record header>b{padding:4px 6px;border:1px solid #765426;background:#21180d;color:var(--shd-orange,#f6a821);font-size:7px}.ds-record dl{display:grid;grid-template-columns:1fr 1fr;margin:10px 12px 0;border:1px solid var(--shd-border,#303536)}.ds-record dl>div{padding:9px;border-left:1px solid var(--shd-border,#303536)}.ds-record dl>div:first-child{border-left:0}.ds-record dd{margin:5px 0 0;font-size:11px;font-weight:900}.ds-record>p{margin:9px 12px 12px;padding:9px;border-left:1px solid var(--shd-orange,#f6a821);background:#0d1011;color:#a2a8aa;font:10px/1.55 Source Serif 4,Georgia,serif}" "css": ".ds-record{border:1px solid var(--shd-border,#303536);border-top:2px solid #404748;border-radius:0;background:var(--shd-surface,#111415);color:var(--shd-text,#ecebe5);font-family:Barlow,Arial,sans-serif}.ds-record header{min-height:78px;display:grid;grid-template-columns:58px minmax(0,1fr) auto;align-items:center;gap:12px;padding:11px 13px;border-bottom:1px solid var(--shd-border,#303536)}.ds-record-icon{width:58px;height:58px;display:grid;place-items:center;border:1px solid #343a3b;background:#0c0f10;color:var(--shd-orange,#f6a821);font-size:12px;font-weight:900}.ds-record small,.ds-record dt{color:#697174;font-size:12px;font-weight:900;letter-spacing:.8px}.ds-record h3{margin:5px 0 0;font-size:16px;line-height:1.1}.ds-record header>b{padding:4px 6px;border:1px solid #765426;background:#21180d;color:var(--shd-orange,#f6a821);font-size:12px}.ds-record dl{display:grid;grid-template-columns:1fr 1fr;margin:10px 12px 0;border:1px solid var(--shd-border,#303536)}.ds-record dl>div{padding:9px;border-left:1px solid var(--shd-border,#303536)}.ds-record dl>div:first-child{border-left:0}.ds-record dd{margin:5px 0 0;font-size:14px;font-weight:900}.ds-record>p{margin:9px 12px 12px;padding:9px;border-left:1px solid var(--shd-orange,#f6a821);background:#0d1011;color:#a2a8aa;font:14px/1.55 Source Serif 4,Georgia,serif}"
}, },
{ {
"name": "ISAC Module Navigation", "name": "ISAC Module Navigation",
"kind": "nav", "kind": "nav",
"description": "A dense indexed registry rail with one explicit active module.", "description": "A dense indexed registry rail with one explicit active module.",
"html": "<nav class=\"ds-isac-nav\" aria-label=\"ISAC modules\"><a href=\"#\"><small>01</small><span>ATTRIBUTES</span><b>›</b></a><a class=\"active\" href=\"#\" aria-current=\"page\"><small>02</small><span>TALENTS</span><b>›</b></a><a href=\"#\"><small>03</small><span>GEAR</span><b>›</b></a></nav>", "html": "<nav class=\"ds-isac-nav\" aria-label=\"ISAC modules\"><a href=\"#\"><small>01</small><span>ATTRIBUTES</span><b>›</b></a><a class=\"active\" href=\"#\" aria-current=\"page\"><small>02</small><span>TALENTS</span><b>›</b></a><a href=\"#\"><small>03</small><span>GEAR</span><b>›</b></a></nav>",
"css": ".ds-isac-nav{display:flex;flex-direction:column;border:1px solid #2c3132;background:#0f1213;font-family:Barlow,Arial,sans-serif}.ds-isac-nav a{min-height:49px;display:grid;grid-template-columns:33px 1fr auto;align-items:center;border-bottom:1px solid #272c2d;color:#858c8e;text-decoration:none}.ds-isac-nav a small{height:100%;display:grid;place-items:center;background:#141819;color:#535a5c;font-size:7px;letter-spacing:.6px}.ds-isac-nav a span{padding-left:11px;font-size:10px;font-weight:700}.ds-isac-nav a b{padding-right:12px;color:var(--shd-orange,#f6a821);font-size:17px}.ds-isac-nav a:hover,.ds-isac-nav a:focus-visible,.ds-isac-nav a.active{background:#151819;color:#fff}.ds-isac-nav a:focus-visible{outline:1px solid var(--shd-orange,#f6a821);outline-offset:-2px}.ds-isac-nav a.active{box-shadow:inset 2px 0 var(--shd-orange,#f6a821)}.ds-isac-nav a.active small{background:#2b2111;color:var(--shd-orange,#f6a821)}" "css": ".ds-isac-nav{display:flex;flex-direction:column;border:1px solid #2c3132;background:#0f1213;font-family:Barlow,Arial,sans-serif}.ds-isac-nav a{min-height:49px;display:grid;grid-template-columns:33px 1fr auto;align-items:center;border-bottom:1px solid #272c2d;color:#858c8e;text-decoration:none}.ds-isac-nav a small{height:100%;display:grid;place-items:center;background:#141819;color:#858b8d;font-size:12px;letter-spacing:.6px}.ds-isac-nav a span{padding-left:11px;font-size:12px;font-weight:700}.ds-isac-nav a b{padding-right:12px;color:var(--shd-orange,#f6a821);font-size:17px}.ds-isac-nav a:hover,.ds-isac-nav a:focus-visible,.ds-isac-nav a.active{background:#151819;color:#fff}.ds-isac-nav a:focus-visible{outline:1px solid var(--shd-orange,#f6a821);outline-offset:-2px}.ds-isac-nav a.active{box-shadow:inset 2px 0 var(--shd-orange,#f6a821)}.ds-isac-nav a.active small{background:#2b2111;color:var(--shd-orange,#f6a821)}"
}, },
{ {
"name": "System Toast", "name": "System Toast",
"kind": "custom", "kind": "custom",
"description": "Detached semantic feedback with a concise operational title and dismissal.", "description": "Detached semantic feedback with a concise operational title and dismissal.",
"html": "<aside class=\"ds-toast\" role=\"status\"><i></i><div><span>ISAC // COMPLETE</span><strong>ATTRIBUTE SAVED</strong><p>The registry has been updated.</p></div><button aria-label=\"Dismiss\">×</button></aside>", "html": "<aside class=\"ds-toast\" role=\"status\"><i></i><div><span>ISAC // COMPLETE</span><strong>ATTRIBUTE SAVED</strong><p>The registry has been updated.</p></div><button aria-label=\"Dismiss\">×</button></aside>",
"css": ".ds-toast{--ds-tone:var(--shd-success,#75c08c);min-height:72px;display:grid;grid-template-columns:4px minmax(0,1fr) 34px;overflow:hidden;border:1px solid #414849;border-top:2px solid var(--ds-tone);border-radius:0;background:linear-gradient(105deg,#171b1cee,#0c0f10f7);box-shadow:0 12px 30px #000b;color:var(--shd-text,#ecebe5);font-family:Barlow,Arial,sans-serif}.ds-toast>i{background:var(--ds-tone)}.ds-toast>div{display:grid;align-content:center;gap:4px;padding:11px 10px}.ds-toast span{color:var(--ds-tone);font-size:6px;font-weight:900;letter-spacing:1px}.ds-toast strong{font-size:9px;line-height:1.3}.ds-toast p{margin:0;color:#91999b;font-size:7px;line-height:1.45}.ds-toast button{width:34px;height:34px;margin:4px 3px 0 0;border:0;background:transparent;color:#70787a;font-size:17px;cursor:pointer}.ds-toast button:hover,.ds-toast button:focus-visible{background:#ffffff09;color:var(--ds-tone)}.ds-toast button:focus-visible{outline:1px solid var(--ds-tone);outline-offset:-2px}.ds-toast button:active{transform:translateY(1px);filter:brightness(.8)}" "css": ".ds-toast{--ds-tone:var(--shd-success,#75c08c);min-height:72px;display:grid;grid-template-columns:4px minmax(0,1fr) 34px;overflow:hidden;border:1px solid #414849;border-top:2px solid var(--ds-tone);border-radius:0;background:linear-gradient(105deg,#171b1cee,#0c0f10f7);box-shadow:0 12px 30px #000b;color:var(--shd-text,#ecebe5);font-family:Barlow,Arial,sans-serif}.ds-toast>i{background:var(--ds-tone)}.ds-toast>div{display:grid;align-content:center;gap:4px;padding:11px 10px}.ds-toast span{color:var(--ds-tone);font-size:12px;font-weight:900;letter-spacing:1px}.ds-toast strong{font-size:13px;line-height:1.3}.ds-toast p{margin:0;color:#91999b;font-size:12px;line-height:1.45}.ds-toast button{width:34px;height:34px;margin:4px 3px 0 0;border:0;background:transparent;color:#70787a;font-size:17px;cursor:pointer}.ds-toast button:hover,.ds-toast button:focus-visible{background:#ffffff09;color:var(--ds-tone)}.ds-toast button:focus-visible{outline:1px solid var(--ds-tone);outline-offset:-2px}.ds-toast button:active{transform:translateY(1px);filter:brightness(.8)}"
},
{
"name": "Modeled Authoring Assist",
"kind": "custom",
"refersTo": "assistant-panel",
"description": "A permission-aware draft workspace for mechanic and calculation authoring.",
"html": "<section class=\"ds-assist\"><header><div><span>ISAC AUTHORING ASSIST</span><strong>AI MECHANIC DRAFTING</strong></div><b>AUTHORIZED</b><button type=\"button\" aria-expanded=\"false\">OPEN ASSISTANT</button></header><p>Convert in-game wording into a reviewable draft. Manual authoring remains available.</p></section>",
"css": ".ds-assist{border:1px solid var(--shd-model-border,#3b4648);border-left:2px solid var(--shd-info,#70b9d6);border-radius:0;background:var(--shd-model-surface,#0d1213);color:var(--shd-text,#ecebe5);font-family:Barlow,Arial,sans-serif}.ds-assist header{min-height:46px;display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:8px;padding:7px 9px;border-bottom:1px solid var(--shd-border,#303536);background:var(--shd-surface-raised,#141718)}.ds-assist header div{display:grid;gap:2px}.ds-assist span,.ds-assist b{color:var(--shd-info,#70b9d6);font-size:12px;font-weight:900;letter-spacing:.9px}.ds-assist b{padding:5px 7px;border:1px solid var(--shd-model-border,#3b4648)}.ds-assist button{min-height:40px;padding:0 11px;border:1px solid var(--shd-info,#70b9d6);border-radius:0;background:transparent;color:var(--shd-info,#70b9d6);font-size:12px;font-weight:900;letter-spacing:.7px;cursor:pointer}.ds-assist button:hover{background:#152225}.ds-assist button:focus-visible{outline:1px solid var(--shd-orange,#f6a821);outline-offset:2px}.ds-assist button:active{transform:translateY(1px);filter:brightness(.84)}.ds-assist p{margin:0;padding:10px 11px;color:var(--shd-text-muted,#858b8d);font-size:14px;line-height:1.45}"
} }
], ],
"narrative": { "narrative": {
"northStar": "The Strategic Operations Grid", "northStar": "The Strategic Operations Grid",
"overview": "SHD.build behaves like a dependable intelligence network shared by active agents: precise, restrained, tactical, and structured around trustworthy data. The interface communicates through alignment, density, operational labels, and visible state rather than spectacle. Public surfaces can breathe and use larger editorial hierarchy, while ISAC compresses the same identity into an efficient control surface.\n\nThe world is technical without becoming sterile. Fine structural lines, tonally layered black and graphite surfaces, cropped game iconography, clipped silhouettes, and tactile state changes create its character. Theme language supports comprehension; it never becomes military cosplay.", "overview": "SHD.build serves active agents through a dependable intelligence network: precise, restrained, tactical, and built around trustworthy data. Alignment, density, operational labels, and visible state carry the interface. Public surfaces use space and larger editorial hierarchy. ISAC compresses the same identity into an efficient control surface.\n\nFine structural lines, layered black and graphite surfaces, cropped game iconography, clipped silhouettes, and tactile state changes give the interface its technical character. Theme language supports comprehension and avoids military cosplay.",
"keyCharacteristics": [ "keyCharacteristics": [
"Dark, high-contrast operational canvas with restrained tonal layering.", "Dark, high-contrast operational canvas with restrained tonal layering.",
"Square engineered geometry, except circular agent avatars and the SHD mark.", "Square engineered geometry, except circular agent avatars and the SHD mark.",
@@ -258,17 +284,22 @@
"rules": [ "rules": [
{ {
"name": "The Orange Discipline Rule", "name": "The Orange Discipline Rule",
"body": "Signal Orange identifies action and emphasis; it does not cover large decorative areas or compete with semantic state colors.", "body": "Use Signal Orange for action and emphasis. Keep it out of large decorative areas and away from semantic state colors.",
"section": "colors" "section": "colors"
}, },
{ {
"name": "The Redundant Meaning Rule", "name": "The Redundant Meaning Rule",
"body": "Color always travels with text, position, shape, or an icon. No status or quality can depend on hue alone.", "body": "Pair color with text, position, shape, or an icon. A status or quality cannot depend on hue alone.",
"section": "colors" "section": "colors"
}, },
{ {
"name": "The Two-Voice Rule", "name": "The Two-Voice Rule",
"body": "Sans-serif operates the system; serif explains it. Never use monospace merely to make an interface look technical.", "body": "Sans-serif operates the system; serif explains it. Do not use monospace to make an interface look technical.",
"section": "typography"
},
{
"name": "The Telemetry Restraint Rule",
"body": "Uppercase tracked labels are short navigational or state-bearing signals, not paragraph typography.",
"section": "typography" "section": "typography"
}, },
{ {
@@ -276,15 +307,30 @@
"body": "Use borders, shared edges, and aligned tracks to show relationships. Do not wrap every subsection in another card.", "body": "Use borders, shared edges, and aligned tracks to show relationships. Do not wrap every subsection in another card.",
"section": "layout" "section": "layout"
}, },
{
"name": "The Operational Density Rule",
"body": "Public discovery may breathe; ISAC and data workbenches prioritize scanability and efficient repeated actions.",
"section": "layout"
},
{ {
"name": "The Structural Layering Rule", "name": "The Structural Layering Rule",
"body": "Borders and tones establish permanent hierarchy; shadows communicate temporary detachment.", "body": "Borders and tones establish permanent hierarchy; shadows communicate temporary detachment.",
"section": "elevation" "section": "elevation"
}, },
{
"name": "The No Ambient Card Rule",
"body": "Ordinary panels and cards remain shadowless at rest.",
"section": "elevation"
},
{ {
"name": "The Engineered Edge Rule", "name": "The Engineered Edge Rule",
"body": "Square is the default and rounding must have semantic justification.", "body": "Square is the default and rounding must have semantic justification.",
"section": "shapes" "section": "shapes"
},
{
"name": "The Identity Circle Rule",
"body": "Circular crops belong to people, the SHD emblem, and radial information, not arbitrary controls.",
"section": "shapes"
} }
], ],
"dos": [ "dos": [
@@ -293,6 +339,7 @@
"Do pair every state color and icon with explicit text.", "Do pair every state color and icon with explicit text.",
"Do use reusable segmented choices for bounded sets and searchable assignments for large relational sets.", "Do use reusable segmented choices for bounded sets and searchable assignments for large relational sets.",
"Do preserve tactile pressed, focus-visible, pending, disabled, error, success, and reduced-motion behavior.", "Do preserve tactile pressed, focus-visible, pending, disabled, error, success, and reduced-motion behavior.",
"Do keep AI-generated data inside an explicit draft-and-review boundary while preserving manual authoring controls.",
"Do let public editorial surfaces breathe while keeping ISAC dense and task-oriented." "Do let public editorial surfaces breathe while keeping ISAC dense and task-oriented."
], ],
"donts": [ "donts": [
+51 -24
View File
@@ -7,8 +7,11 @@ colors:
terminal-surface: "#111415" terminal-surface: "#111415"
terminal-graphite: "#141718" terminal-graphite: "#141718"
active-graphite: "#15191a" active-graphite: "#15191a"
modeled-data-surface: "#0d1213"
structural-line: "#303536" structural-line: "#303536"
structural-line-soft: "#272c2d" structural-line-soft: "#272c2d"
structural-line-strong: "#424849"
modeled-data-line: "#3b4648"
field-manual-white: "#ecebe5" field-manual-white: "#ecebe5"
telemetry-muted: "#858b8d" telemetry-muted: "#858b8d"
telemetry-dim: "#5d6567" telemetry-dim: "#5d6567"
@@ -100,6 +103,11 @@ components:
textColor: "{colors.field-manual-white}" textColor: "{colors.field-manual-white}"
rounded: "{rounded.engineered}" rounded: "{rounded.engineered}"
padding: "{spacing.panel}" padding: "{spacing.panel}"
assistant-panel:
backgroundColor: "{colors.modeled-data-surface}"
textColor: "{colors.field-manual-white}"
rounded: "{rounded.engineered}"
padding: "{spacing.md}"
status-chip: status-chip:
backgroundColor: "{colors.active-graphite}" backgroundColor: "{colors.active-graphite}"
textColor: "{colors.telemetry-muted}" textColor: "{colors.telemetry-muted}"
@@ -114,11 +122,11 @@ components:
**Creative North Star: "The Strategic Operations Grid"** **Creative North Star: "The Strategic Operations Grid"**
SHD.build serves active agents as a dependable intelligence network: precise, SHD.build serves active agents through a dependable intelligence network:
restrained, tactical, and built around trustworthy data. Alignment, density, precise, restrained, tactical, and built around trustworthy data. Alignment,
operational labels, and visible state carry the interface. Public surfaces use density, operational labels, and visible state carry the interface. Public
space and larger editorial hierarchy; ISAC uses the same identity in an surfaces use space and larger editorial hierarchy. ISAC compresses the same
efficient control surface. identity into an efficient control surface.
Fine structural lines, layered black and graphite surfaces, cropped game Fine structural lines, layered black and graphite surfaces, cropped game
iconography, clipped silhouettes, and tactile state changes give the interface iconography, clipped silhouettes, and tactile state changes give the interface
@@ -178,35 +186,40 @@ Signal Orange, and semantic system and item-quality signals.
### Primary ### Primary
- **Signal Orange:** Primary actions, current navigation, focus, selected - **Signal Orange** (`#f6a821`): Primary actions, current navigation, focus, selected
controls, important values, and SHD identity details. controls, important values, and SHD identity details.
- **Signal Orange Hover:** Confirms that an orange action accepts input; never - **Signal Orange Hover** (`#ffc45d`): Confirms that an orange action accepts input; never
use it as a decorative accent. use it as a decorative accent.
### Secondary ### Secondary
- **System Success:** Completed, approved, online, and healthy states. - **System Success** (`#75c08c`): Completed, approved, online, and healthy states.
- **System Danger:** Failed, rejected, blocked, destructive, and fault states. - **System Danger** (`#df8069`): Failed, rejected, blocked, destructive, and fault states.
- **System Info:** Informational system state that is neither action nor - **System Info** (`#70b9d6`): Informational system state that is neither action nor
success. success.
### Tertiary ### Tertiary
- **Quality Standard, Specialized, Superior, and High-end:** Stable - **Quality Standard** (`#69b66f`), **Specialized** (`#4f9ed8`),
game-semantic quality colors. They identify item quality and never replace a **Superior** (`#9b68d4`), and **High-end** (`#e4bd3f`): Stable
textual quality label. game-semantic quality colors. They identify item quality and never replace
a textual quality label.
### Neutral ### Neutral
- **Operations Black:** The persistent application canvas. - **Operations Black** (`#0a0c0d`): The persistent application canvas.
- **Terminal Black:** Recessed fields and data wells. - **Terminal Black** (`#0d0f10`): Recessed fields and data wells.
- **Terminal Surface:** Navigation, registry, and low-level panel surfaces. - **Terminal Surface** (`#111415`): Navigation, registry, and low-level panel surfaces.
- **Terminal Graphite:** Raised working panels and cards. - **Terminal Graphite** (`#141718`): Raised working panels and cards.
- **Active Graphite:** Hovered, selected, and locally emphasized surfaces. - **Active Graphite** (`#15191a`): Hovered, selected, and locally emphasized surfaces.
- **Structural Line / Structural Line Soft:** Primary and subordinate - **Modeled Data Surface** (`#0d1213`): Recessed mechanic programs, calculation revisions,
generated script, and AI-assisted drafting workspaces.
- **Structural Line** (`#303536`) / **Structural Line Soft** (`#272c2d`): Primary and subordinate
separators that create hierarchy without elevation. separators that create hierarchy without elevation.
- **Field Manual White:** Primary readable content. - **Structural Line Strong** (`#424849`) / **Modeled Data Line** (`#3b4648`): Interactive boundaries and
- **Telemetry Muted / Telemetry Dim:** Secondary labels and low-priority blue-railed modeled-data panels that need more separation from the registry.
- **Field Manual White** (`#ecebe5`): Primary readable content.
- **Telemetry Muted** (`#858b8d`) / **Telemetry Dim** (`#5d6567`): Secondary labels and low-priority
operational metadata. operational metadata.
**The Orange Discipline Rule.** Use Signal Orange for action and emphasis. Keep **The Orange Discipline Rule.** Use Signal Orange for action and emphasis. Keep
@@ -240,7 +253,7 @@ operational data distinct from editorial knowledge.
long passages stay near 65–75 characters per line. long passages stay near 65–75 characters per line.
- **Editorial** (400, 1rem, 1.65 line-height): Explanations, introductions, and - **Editorial** (400, 1rem, 1.65 line-height): Explanations, introductions, and
long-form descriptions. long-form descriptions.
- **Label** (900, 0.625rem, 0.1em tracking): Uppercase telemetry, field labels, - **Label** (900, 0.75rem, 0.08em tracking): Uppercase telemetry, field labels,
statuses, metadata, and compact actions. statuses, metadata, and compact actions.
**The Two-Voice Rule.** Sans-serif operates the system; serif explains it. Do **The Two-Voice Rule.** Sans-serif operates the system; serif explains it. Do
@@ -392,13 +405,25 @@ Large or relational option sets use a searchable assignment instrument with a
selected summary, removable assignments, live match count, and an explicit selected summary, removable assignments, live match count, and an explicit
result action. Pending records remain usable and visibly attributed. result action. Pending records remain usable and visibly attributed.
### Modeled Authoring Assist
Mechanic and calculation drafting helpers use a compact Modeled Data Surface
with a blue information rail. Their headers pair a plain-language task name
with an explicit `AUTHORIZED` or `PERMISSION REQUIRED` state. Authors without
permission retain the complete manual editor.
The expanded workspace keeps source wording or calculation requirements
visible while it presents one result state: pending analysis, clarification,
capability gap, failure, or draft preview. A generated draft never changes the
form until the author confirms Apply. Save, proposal, publication, and
activation remain separate actions in the owning editor.
### Registry and Database Cards ### Registry and Database Cards
Registry rows prioritize names, durable statuses, relationships, and edit Registry rows prioritize names, durable statuses, relationships, and edit
actions within consistent columns. Public database cards use an identity actions within consistent columns. Public database cards use an identity
header, icon, metadata, compact fact matrices, and optional relationship icon header, icon, metadata, compact fact matrices, and optional relationship icon
rails. Facts disappear when they do not apply rather than displaying empty rails. Facts with no applicable value stay hidden.
placeholders.
### Toasts and Dialogs ### Toasts and Dialogs
@@ -421,6 +446,8 @@ entrance animation.
assignments for large relational sets. assignments for large relational sets.
- **Do** preserve tactile pressed, focus-visible, pending, disabled, error, - **Do** preserve tactile pressed, focus-visible, pending, disabled, error,
success, and reduced-motion behavior. success, and reduced-motion behavior.
- **Do** keep AI-generated data inside an explicit draft-and-review boundary
while preserving manual authoring controls.
- **Do** let public editorial surfaces breathe while keeping ISAC dense and - **Do** let public editorial surfaces breathe while keeping ISAC dense and
task-oriented. task-oriented.
+36 -1
View File
@@ -3,9 +3,19 @@ CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, username TEXT NOT NULL UN
CREATE TABLE IF NOT EXISTS user_profiles ( CREATE TABLE IF NOT EXISTS user_profiles (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
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 updated_at TEXT NOT NULL
); );
INSERT OR IGNORE INTO user_profiles(user_id,display_name,updated_at) SELECT id,username,created_at FROM users; INSERT OR IGNORE INTO user_profiles(user_id,display_name,updated_at) SELECT id,username,created_at FROM users;
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)),
reason TEXT,
moderated_at TEXT,
moderated_by TEXT REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS user_avatars ( CREATE TABLE IF NOT EXISTS user_avatars (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
source TEXT NOT NULL CHECK(source IN ('preset', 'upload')), source TEXT NOT NULL CHECK(source IN ('preset', 'upload')),
@@ -127,7 +137,28 @@ CREATE INDEX IF NOT EXISTS avatar_reviews_user_submitted_idx ON avatar_reviews(u
CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility, updated_at DESC); CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility, updated_at DESC);
CREATE TABLE IF NOT EXISTS build_items (id TEXT PRIMARY KEY, build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, slot_id TEXT NOT NULL, item_definition_id TEXT NOT NULL, configuration_json TEXT NOT NULL); CREATE TABLE IF NOT EXISTS build_items (id TEXT PRIMARY KEY, build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, slot_id TEXT NOT NULL, item_definition_id TEXT NOT NULL, configuration_json TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS ratings (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, value INTEGER NOT NULL CHECK(value BETWEEN 1 AND 5), PRIMARY KEY(user_id, build_id)); CREATE TABLE IF NOT EXISTS ratings (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, value INTEGER NOT NULL CHECK(value BETWEEN 1 AND 5), PRIMARY KEY(user_id, build_id));
CREATE TABLE IF NOT EXISTS comments (id TEXT PRIMARY KEY, build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id), body TEXT NOT NULL, created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS comment_threads (
id TEXT PRIMARY KEY,
target_type TEXT NOT NULL CHECK(target_type IN ('build', 'guide', 'profile')),
target_id TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(target_type, target_id)
);
CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE,
author_id TEXT NOT NULL REFERENCES users(id),
parent_id TEXT REFERENCES comments(id),
body_markdown TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'visible' CHECK(status IN ('visible', 'removed')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
removed_at TEXT,
removed_by TEXT REFERENCES users(id),
removal_reason TEXT
);
CREATE INDEX IF NOT EXISTS comments_thread_status_created_idx ON comments(thread_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS comments_author_status_created_idx ON comments(author_id, status, created_at DESC);
CREATE TABLE IF NOT EXISTS favorites (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, created_at TEXT NOT NULL, PRIMARY KEY(user_id, build_id)); CREATE TABLE IF NOT EXISTS favorites (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, created_at TEXT NOT NULL, PRIMARY KEY(user_id, build_id));
CREATE TABLE IF NOT EXISTS equipment_types (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, category TEXT NOT NULL); CREATE TABLE IF NOT EXISTS equipment_types (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, category TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS stat_definitions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, value_kind TEXT NOT NULL, min_value REAL, max_value REAL, unit TEXT, active INTEGER NOT NULL DEFAULT 1); CREATE TABLE IF NOT EXISTS stat_definitions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, value_kind TEXT NOT NULL, min_value REAL, max_value REAL, unit TEXT, active INTEGER NOT NULL DEFAULT 1);
@@ -242,6 +273,8 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-metric
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-system-notifications-manage','system.notifications.manage','View and edit automated ISAC system notification messages'); INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-system-notifications-manage','system.notifications.manage','View and edit automated ISAC system notification messages');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-images-manage','images.manage','View, upload, rename, tag, and categorize managed image assets'); INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-images-manage','images.manage','View, upload, rename, tag, and categorize managed image assets');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-avatars-review','avatars.review','Review and remove custom user avatars'); INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-avatars-review','avatars.review','Review and remove custom user avatars');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-community-comments-moderate','community.comments.moderate','Review and moderate public build, guide, and agent-profile comments');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-community-profiles-moderate','community.profiles.moderate','Review and hide public agent-profile biographies');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-calculation-publish','calculation.publish','Activate combat calculation revisions used by every loadout'); INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-calculation-publish','calculation.publish','Activate combat calculation revisions used by every loadout');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-ai-mechanic-generate','ai.mechanic.generate','Use the OpenAI-assisted mechanic drafting workflow'); INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-ai-mechanic-generate','ai.mechanic.generate','Use the OpenAI-assisted mechanic drafting workflow');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-ai-calculation-generate','ai.calculation.generate','Use the OpenAI-assisted combat calculation revision drafting workflow'); INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-ai-calculation-generate','ai.calculation.generate','Use the OpenAI-assisted combat calculation revision drafting workflow');
@@ -277,6 +310,8 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-audit
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-auditor','permission-metrics-view'); INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-auditor','permission-metrics-view');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-isac-access'); INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-isac-access');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-avatars-review'); INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-avatars-review');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-community-comments-moderate');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-community-profiles-moderate');
CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS data_change_proposals ( CREATE TABLE IF NOT EXISTS data_change_proposals (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
+56
View File
@@ -445,6 +445,62 @@ direct URL. Do not present authentication as a way around a disabled section.
remain unique, ASCII-only credentials and must never appear on public-facing remain unique, ASCII-only credentials and must never appear on public-facing
community surfaces. Authorized ISAC user operations may show both. community surfaces. Authorized ISAC user operations may show both.
### Public Agent profiles
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.
Use `AgentIdentityLink` for author attribution on builds, guides, comments, and
other community records. Its compact and default states combine the circular
portrait, public display name, optional contextual metadata, and a clear link
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 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
minimum 44px touch target for toolbar actions on narrow screens. It supports
headings, lists, quotes, links, emphasis, and inline code. Raw HTML is escaped.
Only HTTP and HTTPS links are active; rendered links open separately with UGC,
nofollow, and opener-isolation attributes. `MarkdownContent` must receive HTML
from the server renderer, never raw user input or client-rendered Markdown.
Rendered biography copy uses the editorial typeface and a restrained reading
measure. Compact comment rendering uses the same hierarchy at a denser scale.
Comment cards identify the author and date before the message. Activity cards
identify the content type and title, render a useful excerpt, and provide a
specific action such as `OPEN DISCUSSION`. Empty states state which public
record is absent without implying an error.
Profile visibility is an explicit public or hidden account setting. A hidden
profile is excluded from public discovery and cannot receive new profile
messages. Hiding it does not remove the Agent's display name or portrait from
public builds, guides, or comments they authored. State this consequence beside
the visibility control so the setting does not imply retroactive anonymity.
Community forms use `PendingForm` to block duplicate submissions. Pending
actions retain their label context, validation errors preserve entered text,
and success or error feedback appears beside the affected form. All editor,
link, and button controls require the shared orange focus-visible treatment.
Message removal is a two-step, in-card confirmation: the first action reveals a
red danger panel, then separate Cancel and Confirm Remove actions complete or
abandon the request. Only an authorized author receives the removal control.
Below 1000px, move profile communications beneath the primary content and stop
using the sticky position. Below 650px, stack the identity header, telemetry,
section headers, and content cards into one column. Expand owner actions,
submission controls, and deletion controls to 44px touch targets. Keep the
Cancel and Confirm Remove actions side by side when both labels fit. Respect
`prefers-reduced-motion` without removing static hover, focus, pending, success,
error, or danger indicators.
### Third-party identity buttons ### Third-party identity buttons
- Follow the identity provider's current official brand and sign-in guidelines. - Follow the identity provider's current official brand and sign-in guidelines.
+65
View File
@@ -131,6 +131,71 @@ if (usersTable) {
} }
} }
const userProfilesTable = await (await database.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='user_profiles'"
)).get();
if (userProfilesTable) {
const profileColumns = await (await database.prepare("PRAGMA table_info('user_profiles')")).all();
if (!profileColumns.some((column) => column.name === 'bio_markdown')) {
console.log('Adding Markdown biographies to public agent profiles.');
await database.exec("ALTER TABLE user_profiles ADD COLUMN bio_markdown TEXT NOT NULL DEFAULT ''");
}
if (!profileColumns.some((column) => column.name === 'is_public')) {
console.log('Adding public profile visibility controls.');
await database.exec('ALTER TABLE user_profiles ADD COLUMN is_public INTEGER NOT NULL DEFAULT 1 CHECK(is_public IN (0, 1))');
}
if (!profileColumns.some((column) => column.name === 'bio_updated_at')) {
await database.exec('ALTER TABLE user_profiles ADD COLUMN bio_updated_at TEXT');
}
}
const commentsDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='comments'"
)).get();
if (typeof commentsDefinition?.sql === 'string' && commentsDefinition.sql.includes('build_id')) {
console.log('Normalizing build comments into reusable community comment threads.');
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec('ALTER TABLE comments RENAME TO comments_legacy');
await database.exec(`CREATE TABLE comment_threads (
id TEXT PRIMARY KEY,
target_type TEXT NOT NULL CHECK(target_type IN ('build', 'guide', 'profile')),
target_id TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(target_type, target_id)
)`);
await database.exec(`CREATE TABLE comments (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE,
author_id TEXT NOT NULL REFERENCES users(id),
parent_id TEXT REFERENCES comments(id),
body_markdown TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'visible' CHECK(status IN ('visible', 'removed')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
removed_at TEXT,
removed_by TEXT REFERENCES users(id),
removal_reason TEXT
)`);
await database.exec(`INSERT INTO comment_threads(id,target_type,target_id,created_at)
SELECT 'build:' || build_id,'build',build_id,MIN(created_at)
FROM comments_legacy GROUP BY build_id`);
await database.exec(`INSERT INTO comments(
id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at
)
SELECT id,'build:' || build_id,user_id,NULL,body,'visible',created_at,created_at
FROM comments_legacy`);
await database.exec('DROP TABLE comments_legacy');
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
// The project schema deliberately avoids semicolons inside string literals. // The project schema deliberately avoids semicolons inside string literals.
const statements = schema const statements = schema
.split(';') .split(';')
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,44 @@
<script lang="ts">
let {
agent,
size = 'default',
meta = '',
showId = false,
linked = true
}: {
agent: { id: string; displayName: string; avatarUrl: string };
size?: 'compact' | 'default' | 'large';
meta?: string;
showId?: boolean;
linked?: boolean;
} = $props();
</script>
{#if linked}
<a class="agent-identity" class:compact={size === 'compact'} class:large={size === 'large'} href={`/agents/${encodeURIComponent(agent.id)}`}>
<img src={agent.avatarUrl} alt="" />
<span>
<strong>{agent.displayName}</strong>
{#if meta}<small>{meta}</small>{:else if showId}<small>AGENT {agent.id.slice(0,8).toUpperCase()}</small>{/if}
</span>
<i aria-hidden="true">›</i>
</a>
{:else}
<div class="agent-identity unavailable" class:compact={size === 'compact'} class:large={size === 'large'}>
<img src={agent.avatarUrl} alt="" />
<span><strong>{agent.displayName}</strong><small>PROFILE HIDDEN</small></span>
</div>
{/if}
<style>
.agent-identity{min-width:0;display:grid;grid-template-columns:38px minmax(0,1fr) auto;align-items:center;gap:9px;color:var(--shd-text)}
.agent-identity img{width:38px;height:38px;border:1px solid var(--shd-orange);border-radius:50%;background:var(--shd-surface);object-fit:cover}
.agent-identity span{min-width:0;display:grid;gap:3px}.agent-identity strong,.agent-identity small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.agent-identity strong{font-size:var(--shd-type-supporting)}.agent-identity small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.agent-identity i{color:var(--shd-text-dim);font-size:18px;font-style:normal}.agent-identity:hover strong,.agent-identity:focus-visible strong,.agent-identity:hover i,.agent-identity:focus-visible i{color:var(--shd-orange)}
.agent-identity.compact{grid-template-columns:30px minmax(0,1fr) auto;gap:7px}.agent-identity.compact img{width:30px;height:30px}.agent-identity.compact strong{font-size:var(--shd-type-telemetry)}
.agent-identity.large{grid-template-columns:74px minmax(0,1fr) auto;gap:15px}.agent-identity.large img{width:74px;height:74px;box-shadow:0 0 0 5px var(--shd-bg),0 0 0 6px color-mix(in srgb,var(--shd-orange) 35%,transparent)}.agent-identity.large strong{font-size:clamp(1.5rem,3vw,2.6rem);line-height:1}.agent-identity.large small{font-size:var(--shd-type-supporting)}
.agent-identity.unavailable{grid-template-columns:38px minmax(0,1fr)}.agent-identity.unavailable strong{color:var(--shd-text-muted)}
.agent-identity.unavailable.compact{grid-template-columns:30px minmax(0,1fr)}.agent-identity.unavailable.large{grid-template-columns:74px minmax(0,1fr)}
@media(max-width:600px){.agent-identity{min-height:44px}.agent-identity.large{grid-template-columns:58px minmax(0,1fr) auto}.agent-identity.large img{width:58px;height:58px}.agent-identity.large strong{font-size:1.6rem}.agent-identity.unavailable.large{grid-template-columns:58px minmax(0,1fr)}}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
let {
html = '',
emptyMessage = 'No field notes have been added.',
compact = false
}: {
html?: string;
emptyMessage?: string;
compact?: boolean;
} = $props();
</script>
{#if html.trim()}
<div class="markdown-content" class:compact>{@html html}</div>
{:else}
<p class="markdown-empty">{emptyMessage}</p>
{/if}
<style>
.markdown-content{max-width:76ch;color:var(--shd-text);font-family:var(--shd-font-editorial);font-size:var(--shd-type-body);line-height:1.7;overflow-wrap:anywhere}
.markdown-content.compact{font-size:var(--shd-type-supporting);line-height:1.55}
.markdown-content :global(h3),.markdown-content :global(h4),.markdown-content :global(h5){margin:1.5rem 0 .45rem;color:var(--shd-text);font-family:var(--shd-font-sans);line-height:1.15}
.markdown-content :global(h3){font-size:1.55rem}.markdown-content :global(h4){font-size:1.3rem}.markdown-content :global(h5){font-size:1.1rem}
.markdown-content :global(p),.markdown-content :global(ul),.markdown-content :global(ol),.markdown-content :global(blockquote){margin:.7em 0}
.markdown-content :global(h3 + p),.markdown-content :global(h4 + p),.markdown-content :global(h5 + p),.markdown-content :global(h3 + ul),.markdown-content :global(h4 + ul),.markdown-content :global(h5 + ul),.markdown-content :global(h3 + ol),.markdown-content :global(h4 + ol),.markdown-content :global(h5 + ol),.markdown-content :global(h3 + blockquote),.markdown-content :global(h4 + blockquote),.markdown-content :global(h5 + blockquote){margin-top:0}
.markdown-content :global(ul),.markdown-content :global(ol){padding-left:1.4rem}
.markdown-content :global(li+li){margin-top:.3em}
.markdown-content :global(a){color:var(--shd-orange);font-family:var(--shd-font-sans);font-weight:700}
.markdown-content :global(a:hover),.markdown-content :global(a:focus-visible){color:var(--shd-orange-hover)}
.markdown-content :global(blockquote){padding:.2em 0 .2em 1em;border-left:2px solid var(--shd-orange);color:var(--shd-text-muted)}
.markdown-content :global(code){padding:2px 4px;border:1px solid var(--shd-border);background:#0d0f10;color:var(--shd-text);font-family:var(--shd-font-mono);font-size:.9em}
.markdown-content :global(:first-child){margin-top:0}
.markdown-content :global(:last-child){margin-bottom:0}
.markdown-empty{margin:0;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);line-height:1.55}
</style>
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
import { renderUserMarkdown } from '$lib/markdown';
import { tick } from 'svelte';
let {
value = $bindable(''),
name,
label = 'MARKDOWN',
help = 'Use Markdown for headings, lists, emphasis, quotes, and links.',
maxLength = 4000,
rows = 9,
required = false,
disabled = false,
id = `${name}-markdown-editor`
}: {
value?: string;
name: string;
label?: string;
help?: string;
maxLength?: number;
rows?: number;
required?: boolean;
disabled?: boolean;
id?: string;
} = $props();
let textarea = $state<HTMLTextAreaElement>();
let previewing = $state(false);
const remaining = $derived(maxLength - value.length);
const previewHtml = $derived(renderUserMarkdown(value));
async function replaceSelection(
before: string,
after = '',
placeholder = 'text',
linePrefix = false
) {
if (!textarea || disabled) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
if (linePrefix) {
const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1;
value = `${value.slice(0, lineStart)}${before}${value.slice(lineStart)}`.slice(0, maxLength);
await tick();
textarea.focus();
textarea.setSelectionRange(start + before.length, end + before.length);
return;
}
const selected = value.slice(start, end) || placeholder;
const insert = `${before}${selected}${after}`;
value = `${value.slice(0, start)}${insert}${value.slice(end)}`.slice(0, maxLength);
await tick();
textarea.focus();
const selectionStart = start + before.length;
textarea.setSelectionRange(selectionStart, Math.min(selectionStart + selected.length, value.length));
}
</script>
<div class="markdown-editor">
<div class="markdown-editor-head">
<label for={id}>{label}</label>
<div class="markdown-editor-meta">
<div class="markdown-view-tabs" aria-label={`${label} view`}>
<button type="button" class:active={!previewing} aria-pressed={!previewing} onclick={() => previewing = false}>EDIT</button>
<button type="button" class:active={previewing} aria-pressed={previewing} onclick={() => previewing = true}>PREVIEW</button>
</div>
<span class:near-limit={remaining < Math.min(200, maxLength * .1)}>{remaining.toLocaleString()} REMAINING</span>
</div>
</div>
{#if previewing}
<input type="hidden" {name} value={value}/>
<div class="markdown-preview" aria-live="polite"><MarkdownContent html={previewHtml} emptyMessage="Nothing to preview yet."/></div>
{:else}
<div class="markdown-toolbar" aria-label={`${label} formatting controls`}>
<button type="button" disabled={disabled} aria-label="Bold selected text" onclick={() => replaceSelection('**','**','bold text')}><b>B</b></button>
<button type="button" disabled={disabled} aria-label="Italicize selected text" onclick={() => replaceSelection('*','*','italic text')}><i>I</i></button>
<button type="button" disabled={disabled} aria-label="Add heading" onclick={() => replaceSelection('## ','','Heading',true)}>H2</button>
<button type="button" disabled={disabled} aria-label="Add bulleted list item" onclick={() => replaceSelection('- ','','List item',true)}>LIST</button>
<button type="button" disabled={disabled} aria-label="Add quote" onclick={() => replaceSelection('> ','','Quoted text',true)}>QUOTE</button>
<button type="button" disabled={disabled} aria-label="Add link" onclick={() => replaceSelection('[','](https://)','link text')}>LINK</button>
</div>
<textarea
bind:this={textarea}
bind:value
{id}
{name}
{rows}
{required}
{disabled}
maxlength={maxLength}
spellcheck="true"
></textarea>
{/if}
<div class="markdown-editor-foot">
<small>{help}</small>
<span>MARKDOWN SUPPORTED</span>
</div>
</div>
<style>
.markdown-editor{display:grid;border:1px solid var(--shd-border);background:#0d0f10}
.markdown-editor-head,.markdown-editor-foot{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);padding:7px 9px;background:var(--shd-surface-raised)}
.markdown-editor-head{border-bottom:1px solid var(--shd-border)}.markdown-editor-foot{border-top:1px solid var(--shd-border)}
.markdown-editor-head label,.markdown-editor-head span,.markdown-editor-foot span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
.markdown-editor-head label{color:var(--shd-text)}.markdown-editor-head span,.markdown-editor-foot span{color:var(--shd-text-muted)}.markdown-editor-head span.near-limit{color:var(--shd-orange)}
.markdown-editor-meta{display:flex;align-items:center;gap:9px}.markdown-view-tabs{display:flex;border:1px solid var(--shd-border)}.markdown-view-tabs button{min-height:28px;padding:0 8px;border:0;border-left:1px solid var(--shd-border);background:var(--shd-surface);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.markdown-view-tabs button:first-child{border-left:0}.markdown-view-tabs button.active{background:#211a0e;color:var(--shd-orange)}
.markdown-editor-foot small{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.markdown-toolbar{display:flex;flex-wrap:wrap;border-bottom:1px solid var(--shd-border)}
.markdown-toolbar button{min-width:40px;height:36px;padding:0 9px;border:0;border-right:1px solid var(--shd-border);background:var(--shd-surface);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}
.markdown-toolbar button:hover,.markdown-toolbar button:focus-visible{background:var(--shd-surface-active);color:var(--shd-orange)}
.markdown-toolbar button:active{transform:translateY(1px);filter:brightness(.82)}
.markdown-toolbar button:disabled{cursor:not-allowed;opacity:.48}
.markdown-editor textarea{width:100%;min-height:150px;margin:0;padding:11px;border:0;border-radius:0;background:#0d0f10;color:var(--shd-text);font-family:var(--shd-font-editorial);font-size:var(--shd-type-body);line-height:1.55;resize:vertical}
.markdown-editor textarea:focus-visible{position:relative;z-index:1;outline:1px solid var(--shd-orange);outline-offset:-1px}
.markdown-preview{min-height:150px;padding:11px}.markdown-view-tabs button:active{transform:translateY(1px)}
@media(max-width:600px){.markdown-toolbar button{min-width:44px;height:44px}.markdown-editor-head,.markdown-editor-foot{align-items:flex-start;flex-direction:column}.markdown-editor-meta{width:100%;justify-content:space-between}.markdown-view-tabs button{min-height:44px}.markdown-editor-foot span{align-self:flex-end}}
@media(prefers-reduced-motion:reduce){.markdown-toolbar button{transition:none}}
</style>
+114
View File
@@ -0,0 +1,114 @@
const escapeHtml = (value: string) => value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
function safeLink(raw: string) {
try {
const url = new URL(raw);
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null;
} catch {
return null;
}
}
function inlineMarkdown(value: string) {
const code: string[] = [];
let output = escapeHtml(value).replace(/`([^`\r\n]+)`/g, (_match, body: string) => {
const token = `\u0000CODE${code.length}\u0000`;
code.push(`<code>${body}</code>`);
return token;
});
output = output.replace(/\[([^\]\r\n]{1,240})\]\(([^)\s]{1,2048})\)/g, (_match, label: string, href: string) => {
const decodedHref = href
.replaceAll('&amp;', '&')
.replaceAll('&#39;', "'")
.replaceAll('&quot;', '"');
const safe = safeLink(decodedHref);
return safe
? `<a href="${escapeHtml(safe)}" rel="nofollow ugc noopener noreferrer" target="_blank">${label}</a>`
: label;
});
output = output
.replace(/\*\*([^*\r\n]+)\*\*/g, '<strong>$1</strong>')
.replace(/__([^_\r\n]+)__/g, '<strong>$1</strong>')
.replace(/(^|[^\w])\*([^*\r\n]+)\*/g, '$1<em>$2</em>')
.replace(/(^|[^\w])_([^_\r\n]+)_/g, '$1<em>$2</em>');
return output.replace(/\u0000CODE(\d+)\u0000/g, (_match, index: string) => code[Number(index)] ?? '');
}
/**
* Renders the shared Markdown subset accepted for user biographies and comments.
* Raw HTML is always escaped. Links are restricted to HTTP(S) and receive UGC,
* nofollow, and opener isolation attributes.
*/
export function renderUserMarkdown(markdown: unknown): string {
const input = String(markdown ?? '').normalize('NFKC').replace(/\r\n?/g, '\n').trim();
if (!input) return '';
const lines = input.split('\n');
const output: string[] = [];
let paragraph: string[] = [];
let list: 'ul' | 'ol' | null = null;
const closeParagraph = () => {
if (!paragraph.length) return;
output.push(`<p>${inlineMarkdown(paragraph.join(' '))}</p>`);
paragraph = [];
};
const closeList = () => {
if (!list) return;
output.push(`</${list}>`);
list = null;
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
closeParagraph();
closeList();
continue;
}
const heading = /^(#{1,3})\s+(.+)$/.exec(trimmed);
if (heading) {
closeParagraph();
closeList();
const level = heading[1].length + 2;
output.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
continue;
}
const unordered = /^[-*]\s+(.+)$/.exec(trimmed);
const ordered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
if (unordered || ordered) {
closeParagraph();
const nextList = unordered ? 'ul' : 'ol';
if (list !== nextList) {
closeList();
list = nextList;
output.push(`<${list}>`);
}
output.push(`<li>${inlineMarkdown((unordered ?? ordered)?.[1] ?? '')}</li>`);
continue;
}
const quote = /^>\s?(.+)$/.exec(trimmed);
if (quote) {
closeParagraph();
closeList();
output.push(`<blockquote><p>${inlineMarkdown(quote[1])}</p></blockquote>`);
continue;
}
closeList();
paragraph.push(trimmed);
}
closeParagraph();
closeList();
return output.join('');
}
export function normalizeUserMarkdown(markdown: unknown, maxCharacters: number) {
const normalized = String(markdown ?? '').normalize('NFKC').replace(/\r\n?/g, '\n').trim();
if (Array.from(normalized).length > maxCharacters) return null;
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(normalized)) return null;
return normalized;
}
+9 -3
View File
@@ -26,9 +26,15 @@ export async function softDeleteAccount(
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?` `UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`
)).run(deletedUsername, userId); )).run(deletedUsername, userId);
await (await database.prepare( await (await database.prepare(
`INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?) `INSERT INTO user_profiles(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
ON CONFLICT(user_id) DO UPDATE SET display_name=excluded.display_name,updated_at=excluded.updated_at` VALUES(?,?,?,0,?,?)
)).run(userId, 'Deleted Agent', now); ON CONFLICT(user_id) DO UPDATE SET
display_name=excluded.display_name,
bio_markdown='',
is_public=0,
bio_updated_at=excluded.bio_updated_at,
updated_at=excluded.updated_at`
)).run(userId, 'Deleted Agent', '', now, now);
await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(userId); await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(userId); await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM webauthn_challenges WHERE user_id=?')).run(userId); await (await database.prepare('DELETE FROM webauthn_challenges WHERE user_id=?')).run(userId);
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { normalizeUserMarkdown, renderUserMarkdown } from '../markdown';
describe('user Markdown', () => {
it('escapes raw HTML and blocks executable links', () => {
const rendered = renderUserMarkdown(
'<script>alert(1)</script> [unsafe](javascript:alert(1)) [safe](https://shd.build)'
);
expect(rendered).toContain('&lt;script&gt;');
expect(rendered).not.toContain('<script>');
expect(rendered).not.toContain('javascript:');
expect(rendered).toContain('href="https://shd.build/"');
});
it('renders the supported biography formatting subset', () => {
expect(renderUserMarkdown('## Agent\n\n- **Medic**\n- _Support_')).toBe(
'<h4>Agent</h4><ul><li><strong>Medic</strong></li><li><em>Support</em></li></ul>'
);
});
it('bounds Unicode characters and rejects control characters', () => {
expect(normalizeUserMarkdown('漢字', 2)).toBe('漢字');
expect(normalizeUserMarkdown('漢字A', 2)).toBeNull();
expect(normalizeUserMarkdown('hello\u0000world', 20)).toBeNull();
});
});
+77 -3
View File
@@ -9,12 +9,14 @@ import { createMagicLinkToken } from '$lib/server/auth/magic-link';
import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/auth/mail'; import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/auth/mail';
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods'; import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
import { recordIsacAudit } from '$lib/server/audit'; import { recordIsacAudit } from '$lib/server/audit';
import { normalizeUserMarkdown, renderUserMarkdown } from '$lib/markdown';
import { randomUUID } from 'node:crypto';
export const load: PageServerLoad = async ({ cookies, url }) => { export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies); const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in'); if (!user) redirect(303, '/sign-in');
const database = await getDatabase(); const database = await getDatabase();
const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread] = await Promise.all([ const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread, profile] = await Promise.all([
(await database.prepare('SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? ORDER BY updated_at DESC')).all(user.id), (await database.prepare('SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? ORDER BY updated_at DESC')).all(user.id),
(await database.prepare("SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? AND guide_body IS NOT NULL AND length(trim(guide_body)) > 0 ORDER BY updated_at DESC")).all(user.id), (await database.prepare("SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? AND guide_body IS NOT NULL AND length(trim(guide_body)) > 0 ORDER BY updated_at DESC")).all(user.id),
(await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id), (await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id),
@@ -31,7 +33,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
ORDER BY n.created_at DESC LIMIT 5`)).all(user.id), ORDER BY n.created_at DESC LIMIT 5`)).all(user.id),
(await database.prepare(`SELECT COUNT(*) AS count FROM notifications n (await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id) WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id),
(await database.prepare(`SELECT profile.bio_markdown,profile.is_public,profile.bio_updated_at,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,moderation.reason AS moderation_reason
FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE profile.user_id=?`)).get(user.id)
]); ]);
const oauthErrorCode = url.searchParams.get('oauth_error'); const oauthErrorCode = url.searchParams.get('oauth_error');
const oauthErrors: Record<string, { title: string; message: string }> = { const oauthErrors: Record<string, { title: string; message: string }> = {
@@ -55,6 +62,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
} }
: null; : null;
return { return {
user: {
...user,
bioMarkdown: String(profile?.bio_markdown ?? ''),
profileIsPublic: profile?.is_public === undefined ? true : Boolean(profile.is_public)
},
account: { builds, guides, favorites, ratings, notifications, notificationUnread: Number(notificationUnread?.count ?? 0) }, account: { builds, guides, favorites, ratings, notifications, notificationUnread: Number(notificationUnread?.count ?? 0) },
discordConnection: connection?.discord_id ? { discordConnection: connection?.discord_id ? {
id: String(connection.discord_id), id: String(connection.discord_id),
@@ -77,11 +89,73 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
chooseDisplayName: url.searchParams.has('choose_display_name'), chooseDisplayName: url.searchParams.has('choose_display_name'),
discordLinked: url.searchParams.has('discord_linked'), discordLinked: url.searchParams.has('discord_linked'),
oauthError, oauthError,
avatarPresets: AVATAR_PRESETS avatarPresets: AVATAR_PRESETS,
profile: {
bioMarkdown: String(profile?.bio_markdown ?? ''),
bioHtml: renderUserMarkdown(profile?.bio_markdown),
isPublic: Boolean(profile?.is_public),
bioHidden: Boolean(profile?.bio_hidden),
moderationReason: profile?.moderation_reason ? String(profile.moderation_reason) : null,
bioUpdatedAt: profile?.bio_updated_at ? String(profile.bio_updated_at) : null,
href: `/agents/${encodeURIComponent(user.id)}`
}
}; };
}; };
export const actions: Actions = { export const actions: Actions = {
saveBio: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const bio = normalizeUserMarkdown(form.get('bio'), 4_000);
if (bio === null) {
return fail(400, {
bioError: 'Agent biographies may contain up to 4,000 characters and cannot contain control characters.',
bioResultToken: randomUUID()
});
}
const now = new Date().toISOString();
const database = await getDatabase();
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
VALUES(?,?,?,1,?,?)
ON CONFLICT(user_id) DO UPDATE SET
bio_markdown=excluded.bio_markdown,
bio_updated_at=excluded.bio_updated_at,
updated_at=excluded.updated_at`))
.run(user.id, user.displayName, bio, now, now);
return {
bioSaved: true,
bioMarkdown: bio,
bioResultToken: randomUUID()
};
},
setProfileVisibility: async ({ request, cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const isPublicValue = String((await request.formData()).get('isPublic') ?? '');
if (!['0', '1'].includes(isPublicValue)) {
return fail(400, {
profileVisibilityError: 'Choose whether your Agent profile is public.',
profileVisibilityResultToken: randomUUID()
});
}
const isPublic = isPublicValue === '1';
const now = new Date().toISOString();
const database = await getDatabase();
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,is_public,updated_at)
VALUES(?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET
is_public=excluded.is_public,
updated_at=excluded.updated_at`))
.run(user.id, user.displayName, isPublic ? 1 : 0, now);
return {
profileVisibilitySaved: true,
profileIsPublic: isPublic,
profileVisibilityResultToken: randomUUID()
};
},
requestEmailVerification: async ({ request, cookies }) => { requestEmailVerification: async ({ request, cookies }) => {
const user = await getSessionUser(cookies); const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in'); if (!user) redirect(303, '/sign-in');
+65 -1
View File
@@ -2,6 +2,7 @@
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte'; import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import PendingForm from '$lib/components/PendingForm.svelte'; import PendingForm from '$lib/components/PendingForm.svelte';
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte'; import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
import MarkdownEditor from '$lib/components/MarkdownEditor.svelte';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser'; import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -47,6 +48,24 @@
let passkeyMessage = $state(''); let passkeyMessage = $state('');
let passkeyClientError = $state(''); let passkeyClientError = $state('');
let passkeyName = $state('My passkey'); let passkeyName = $state('My passkey');
let bioDraft = $state('');
let bioInitialized = $state(false);
let handledBioToken = $state('');
$effect(() => {
const token = String(form?.bioResultToken ?? '');
if (!bioInitialized) {
bioDraft = String(user.bioMarkdown ?? '');
bioInitialized = true;
}
if (!token || token === handledBioToken) return;
handledBioToken = token;
if (form?.bioSaved) {
bioDraft = String(user.bioMarkdown ?? bioDraft);
toast.success('Profile bio saved', 'Your public Agent profile has been updated.');
} else if (form?.bioError) {
toast.error('Profile bio not saved', String(form.bioError));
}
});
onMount(() => passkeySupported = browserSupportsWebAuthn()); onMount(() => passkeySupported = browserSupportsWebAuthn());
async function registerPasskey() { async function registerPasskey() {
if (passkeyPending) return; if (passkeyPending) return;
@@ -82,7 +101,7 @@
<svelte:head><title>{user.displayName} — SHD.build</title></svelte:head> <svelte:head><title>{user.displayName} — SHD.build</title></svelte:head>
<main class="page profile-page"> <main class="page profile-page">
<section class="profile-command"> <section class="profile-command">
<div class="profile-ident"><img class="profile-avatar" src={user.avatarUrl} alt={`${user.displayName}'s avatar`} /><div><PageBreadcrumb items={[{ label: 'AGENT PROFILE' }]} /><h1>{user.displayName}</h1><span class="role-badge">{user.role.toUpperCase()} ACCESS</span></div></div> <div class="profile-ident"><img class="profile-avatar" src={user.avatarUrl} alt={`${user.displayName}'s avatar`} /><div><PageBreadcrumb items={[{ label: 'AGENT PROFILE' }]} /><h1>{user.displayName}</h1><div class="profile-public-actions"><span class="role-badge">{user.role.toUpperCase()} ACCESS</span><a href={`/agents/${encodeURIComponent(user.id)}`}>VIEW PUBLIC PROFILE →</a></div></div></div>
<div class="profile-metrics"><div><strong>{data.account.builds.length}</strong><span>BUILDS</span></div><div><strong>{data.account.guides.length}</strong><span>GUIDES</span></div><div><strong>{data.account.favorites.length}</strong><span>FAVORITES</span></div><div><strong>{data.account.ratings.length}</strong><span>RATED</span></div><a href="/account/notifications"><strong>{data.account.notificationUnread}</strong><span>INBOX</span></a></div> <div class="profile-metrics"><div><strong>{data.account.builds.length}</strong><span>BUILDS</span></div><div><strong>{data.account.guides.length}</strong><span>GUIDES</span></div><div><strong>{data.account.favorites.length}</strong><span>FAVORITES</span></div><div><strong>{data.account.ratings.length}</strong><span>RATED</span></div><a href="/account/notifications"><strong>{data.account.notificationUnread}</strong><span>INBOX</span></a></div>
</section> </section>
{#if data.chooseDisplayName}<div class="account-alert"><b>IDENTITY SETUP</b><span>Choose the SHD.build display name other agents will see. Unicode characters are supported.</span></div>{/if} {#if data.chooseDisplayName}<div class="account-alert"><b>IDENTITY SETUP</b><span>Choose the SHD.build display name other agents will see. Unicode characters are supported.</span></div>{/if}
@@ -143,6 +162,44 @@
<hr/> <hr/>
<p class="eyebrow">PUBLIC IDENTITY</p><h2>DISPLAY NAME</h2> <p class="eyebrow">PUBLIC IDENTITY</p><h2>DISPLAY NAME</h2>
<PendingForm action="?/changeDisplayName"><label class="field">PUBLIC DISPLAY NAME<input name="displayName" value={user.displayName} maxlength="80" required /></label><small class="identity-note">Visible to other agents. Unicode, spaces, and non-ASCII writing systems are supported.</small>{#if form?.displayNameError}<p class="error">{form.displayNameError}</p>{/if}<button class="primary console-action">UPDATE DISPLAY NAME →</button></PendingForm> <PendingForm action="?/changeDisplayName"><label class="field">PUBLIC DISPLAY NAME<input name="displayName" value={user.displayName} maxlength="80" required /></label><small class="identity-note">Visible to other agents. Unicode, spaces, and non-ASCII writing systems are supported.</small>{#if form?.displayNameError}<p class="error">{form.displayNameError}</p>{/if}<button class="primary console-action">UPDATE DISPLAY NAME →</button></PendingForm>
<hr/>
<p class="eyebrow">FIELD DOSSIER</p><h2>PUBLIC BIO</h2>
{#if data.profile.bioHidden}
<div class="account-alert error-alert" role="status"><b>BIO HIDDEN BY OVERSIGHT</b><span>{data.profile.moderationReason ?? 'Review the ISAC notification in your inbox for details.'} You may edit your bio, but it will remain hidden until Oversight restores it.</span></div>
{/if}
<PendingForm action="?/saveBio">
<MarkdownEditor
name="bio"
label="AGENT BIO"
help="Describe your playstyle, preferred roles, community work, or current objectives. Links are allowed."
maxLength={4000}
rows={10}
bind:value={bioDraft}
/>
{#if form?.bioError}<p class="error">{form.bioError}</p>{/if}
{#if form?.bioSaved}<p class="success">Public bio updated.</p>{/if}
<button class="primary console-action">SAVE PUBLIC BIO →</button>
</PendingForm>
<div class="profile-visibility-control">
<div>
<span>PROFILE VISIBILITY</span>
<strong>{user.profileIsPublic !== false ? 'PUBLIC' : 'HIDDEN'}</strong>
<p>
{user.profileIsPublic !== false
? 'Other agents can discover this profile and leave profile messages.'
: 'This profile is excluded from discovery and cannot receive new profile messages.'}
</p>
<small>Hiding your profile does not remove your name or avatar from public builds, guides, or comments you authored.</small>
</div>
<PendingForm action="?/setProfileVisibility">
<input type="hidden" name="isPublic" value={user.profileIsPublic === false ? '1' : '0'}/>
<button class={user.profileIsPublic === false ? 'primary' : 'secondary'}>
{user.profileIsPublic === false ? 'MAKE PROFILE PUBLIC →' : 'HIDE PUBLIC PROFILE'}
</button>
</PendingForm>
</div>
{#if form?.profileVisibilityError}<p class="error">{form.profileVisibilityError}</p>{/if}
{#if form?.profileVisibilitySaved}<p class="success">Profile visibility updated.</p>{/if}
</div> </div>
{:else if accountPanel === 'access'} {:else if accountPanel === 'access'}
<div class="account-panel" id="access-panel" role="tabpanel" aria-labelledby="access-tab"> <div class="account-panel" id="access-panel" role="tabpanel" aria-labelledby="access-tab">
@@ -226,4 +283,11 @@
.profile-notification-list a.isac-message:not(.positive):not(.negative) { border-left: 2px solid #68a9c4; background: #10171a; } .profile-notification-list a.isac-message:not(.positive):not(.negative) { border-left: 2px solid #68a9c4; background: #10171a; }
.profile-notification-list a.positive em { color: var(--shd-success); } .profile-notification-list a.positive em { color: var(--shd-success); }
.profile-notification-list a.negative em { color: var(--shd-danger); } .profile-notification-list a.negative em { color: var(--shd-danger); }
.profile-public-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.profile-public-actions a{min-height:28px;display:inline-flex;align-items:center;padding:4px 7px;border:1px solid var(--shd-border);color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.profile-public-actions a:hover,.profile-public-actions a:focus-visible{border-color:var(--shd-orange);background:#211a0e}
.profile-visibility-control{display:grid;gap:9px;margin-top:9px;padding:10px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:#0d1011}
.profile-visibility-control>div{display:grid;gap:4px}.profile-visibility-control span,.profile-visibility-control strong{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.profile-visibility-control span{color:var(--shd-text-muted)}.profile-visibility-control strong{color:var(--shd-info)}.profile-visibility-control p,.profile-visibility-control small{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.profile-visibility-control small{font-family:var(--shd-font-editorial)}
.profile-visibility-control :global(form button){width:100%;min-height:40px}
@media(max-width:600px){.profile-public-actions a,.profile-visibility-control :global(form button){min-height:44px}}
</style> </style>
+282
View File
@@ -0,0 +1,282 @@
import { avatarUrl } from '$lib/avatar';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { normalizeUserMarkdown, renderUserMarkdown } from '$lib/markdown';
import { createNotification } from '$lib/server/notifications';
import { error, fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
const COMMENT_MAX_CHARACTERS = 2_000;
const RAPID_COMMENT_SECONDS = 10;
const DUPLICATE_COMMENT_SECONDS = 300;
const unavailable = (row: Record<string, unknown> | null | undefined) => {
const banned = typeof row?.banned_until === 'string' && new Date(row.banned_until) > new Date();
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`;
return `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
};
export const load: PageServerLoad = async ({ params, cookies }) => {
const database = await getDatabase();
const viewer = await getSessionUser(cookies);
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,
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
FROM users
LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN profile_moderation moderation ON moderation.user_id=users.id
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;
if (
unavailable(agent)
|| (!Boolean(agent?.is_public) && !isSelf)
) error(404, 'Agent profile not found');
const [buildRows, guideRows, authoredCommentRows, profileCommentRows, authoredCount, profileCommentCount, contentCounts] =
await Promise.all([
(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),
(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),
(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
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')
LEFT JOIN users target_user ON target_user.id=thread.target_id AND thread.target_type='profile'
LEFT JOIN user_profiles target_profile ON target_profile.user_id=target_user.id
LEFT JOIN deleted_accounts target_deleted ON target_deleted.user_id=target_user.id
LEFT JOIN user_status target_status ON target_status.user_id=target_user.id
WHERE comments.author_id=? AND comments.status='visible' AND (
(thread.target_type IN ('build','guide') AND builds.visibility='public')
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<=?))
)
ORDER BY comments.created_at DESC LIMIT 50`)).all(params.id, 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,
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'
LEFT JOIN users ON users.id=comments.author_id
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),
(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')
LEFT JOIN user_profiles target_profile ON target_profile.user_id=thread.target_id
AND thread.target_type='profile'
LEFT JOIN deleted_accounts target_deleted ON target_deleted.user_id=thread.target_id
LEFT JOIN user_status target_status ON target_status.user_id=thread.target_id
WHERE comments.author_id=? AND comments.status='visible' AND (
(thread.target_type IN ('build','guide') AND builds.visibility='public')
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()),
(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),
(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)
]);
return {
agent: {
id: String(agent.id),
displayName: String(agent.display_name),
avatarUrl: avatarUrl(
agent.avatar_source,
agent.avatar_value,
String(agent.id),
agent.avatar_updated_at
),
bioHtml: agent.bio_hidden ? '' : renderUserMarkdown(agent.bio_markdown)
},
counts: {
builds: Number(contentCounts?.builds ?? 0),
guides: Number(contentCounts?.guides ?? 0),
authoredComments: Number(authoredCount?.count ?? 0),
profileComments: Number(profileCommentCount?.count ?? 0)
},
builds: buildRows.map((build) => ({
id: String(build.id),
slug: String(build.slug),
title: String(build.title),
summary: String(build.summary ?? ''),
updatedAt: String(build.updated_at)
})),
guides: guideRows.map((guide) => ({
id: String(guide.id),
slug: String(guide.slug),
title: String(guide.title),
summary: String(guide.summary ?? ''),
updatedAt: String(guide.updated_at)
})),
authoredComments: authoredCommentRows.map((comment) => {
const targetType = String(comment.target_type);
const targetId = String(comment.target_id);
return {
id: String(comment.id),
bodyHtml: renderUserMarkdown(comment.body_markdown),
createdAt: String(comment.created_at),
targetType,
targetTitle: targetType === 'profile'
? `${String(comment.target_display_name ?? 'Agent')} profile`
: String(comment.build_title ?? 'Community content'),
targetHref: commentHref(targetType, comment.slug, targetId)
};
}),
profileComments: profileCommentRows.map((comment) => ({
id: String(comment.id),
bodyHtml: renderUserMarkdown(comment.body_markdown),
createdAt: String(comment.created_at),
author: {
id: String(comment.author_id),
displayName: String(comment.display_name),
avatarUrl: avatarUrl(
comment.avatar_source,
comment.avatar_value,
String(comment.author_id),
comment.avatar_updated_at
)
},
canDelete: viewer?.id === String(comment.author_id)
})),
viewer: {
id: viewer?.id ?? null,
canComment: Boolean(viewer) && Boolean(agent.is_public),
isSelf
}
};
};
export const actions: Actions = {
commentProfile: async ({ params, cookies, request }) => {
const commenter = await getSessionUser(cookies);
if (!commenter) return fail(401, { profileCommentError: 'Sign in to post a profile comment.' });
const body = normalizeUserMarkdown(
(await request.formData()).get('body'),
COMMENT_MAX_CHARACTERS
);
if (body === null || Array.from(body).length < 2) {
return fail(400, {
profileCommentError: `Comments must contain 2–${COMMENT_MAX_CHARACTERS.toLocaleString()} visible characters.`
});
}
const database = await getDatabase();
const now = new Date();
const rows = await (await database.prepare(`SELECT users.id,
COALESCE(profile.display_name,users.username) AS display_name,profile.is_public,
status.blocked_at,status.banned_until,deleted.deleted_at
FROM users
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);
const commenterState = rows.find((row) => row.id === commenter.id);
const ownerState = rows.find((row) => row.id === params.id);
if (unavailable(commenterState)) {
return fail(403, { profileCommentError: 'This account cannot post comments.' });
}
if (unavailable(ownerState) || !Boolean(ownerState?.is_public)) {
return fail(404, { profileCommentError: 'Agent profile not found.' });
}
const recent = await (await database.prepare(`SELECT comments.body_markdown,comments.created_at
FROM comments JOIN comment_threads thread ON thread.id=comments.thread_id
WHERE comments.author_id=? AND comments.status='visible'
AND thread.target_type='profile' AND thread.target_id=?
ORDER BY comments.created_at DESC LIMIT 1`)).get(commenter.id, params.id);
if (typeof recent?.created_at === 'string') {
const elapsed = (now.getTime() - new Date(recent.created_at).getTime()) / 1_000;
if (elapsed < RAPID_COMMENT_SECONDS) {
return fail(429, { profileCommentError: 'Wait a few seconds before posting again.' });
}
if (elapsed < DUPLICATE_COMMENT_SECONDS && recent.body_markdown === body) {
return fail(409, { profileCommentError: 'That comment was already posted.' });
}
}
const createdAt = now.toISOString();
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);
const thread = await (await database.prepare(
"SELECT id FROM comment_threads WHERE target_type='profile' AND target_id=?"
)).get(params.id);
if (!thread?.id) return fail(500, { profileCommentError: 'The profile thread could not be opened.' });
const commentId = randomUUID();
await (await database.prepare(`INSERT INTO comments
(id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at)
VALUES(?,?,?,NULL,?,'visible',?,?)`))
.run(commentId, thread.id, commenter.id, body, createdAt, createdAt);
if (commenter.id !== params.id) {
await createNotification({
userId: params.id,
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`,
actions: [{
label: 'VIEW COMMENT',
href: `/agents/${encodeURIComponent(params.id)}#profile-comments`
}]
});
}
return {
profileCommentSaved: true,
profileCommentId: commentId,
profileCommentResultToken: randomUUID()
};
},
deleteProfileComment: async ({ params, cookies, request }) => {
const viewer = await getSessionUser(cookies);
if (!viewer) return fail(401, { profileCommentError: 'Sign in to remove a comment.' });
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 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);
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.' });
}
const removedAt = new Date().toISOString();
await (await database.prepare(`UPDATE comments SET status='removed',removed_at=?,removed_by=?,
removal_reason='Removed by author',updated_at=? WHERE id=? AND status='visible'`))
.run(removedAt, viewer.id, removedAt, commentId);
return {
profileCommentDeleted: true,
profileCommentId: commentId,
profileCommentResultToken: randomUUID()
};
}
};
+229
View File
@@ -0,0 +1,229 @@
<script lang="ts">
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
import MarkdownEditor from '$lib/components/MarkdownEditor.svelte';
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
type PublicAgent = {
id: string;
displayName: string;
avatarUrl: string;
bioHtml: string;
};
type PublicContent = {
id: string;
slug: string;
title: string;
summary: string | null;
updatedAt: string;
};
type AuthoredComment = {
id: string;
bodyHtml: string;
createdAt: string;
targetType: string;
targetTitle: string;
targetHref: string;
};
type ProfileComment = {
id: string;
bodyHtml: string;
createdAt: string;
author: { id: string; displayName: string; avatarUrl: string };
canDelete: boolean;
};
type PageData = {
agent: PublicAgent;
counts: { builds: number; guides: number; authoredComments: number; profileComments: number };
builds: PublicContent[];
guides: PublicContent[];
authoredComments: AuthoredComment[];
profileComments: ProfileComment[];
viewer: { id: string | null; canComment: boolean; isSelf: boolean };
};
let { data, form }: { data: PageData; form?: Record<string, any> | null } = $props();
let profileCommentDraft = $state('');
let deletingCommentId = $state('');
let handledCommentToken = $state('');
const dateLabel = (value: string) => {
const date = new Date(value);
return Number.isNaN(date.valueOf())
? 'DATE UNKNOWN'
: date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }).toUpperCase();
};
$effect(() => {
const token = String(form?.profileCommentResultToken ?? form?.commentResultToken ?? '');
if (!token || token === handledCommentToken) return;
handledCommentToken = token;
if (form?.profileCommentSaved || form?.commentSaved) profileCommentDraft = '';
if (form?.profileCommentDeleted || form?.commentDeleted) deletingCommentId = '';
});
</script>
<svelte:head>
<title>{data.agent.displayName} — Agent Profile — SHD.build</title>
<meta name="description" content={`View ${data.agent.displayName}'s public Agent profile, builds, guides, and community activity on SHD.build.`}/>
</svelte:head>
<main class="page public-agent-page">
<PageBreadcrumb items={[{ label: 'AGENTS' }, { label: data.agent.displayName }]} />
<header class="agent-command">
<AgentIdentityLink agent={data.agent} size="large" meta="SHD.BUILD AGENT PROFILE" />
<div class="agent-command-actions">
{#if data.viewer.isSelf}<a class="secondary" href="/account">EDIT MY PROFILE →</a>{/if}
</div>
</header>
<section class="agent-telemetry" aria-label="Agent content totals">
<div><span>PUBLIC BUILDS</span><strong>{data.counts.builds}</strong></div>
<div><span>PUBLISHED GUIDES</span><strong>{data.counts.guides}</strong></div>
<div><span>CONTENT COMMENTS</span><strong>{data.counts.authoredComments}</strong></div>
<div><span>PROFILE MESSAGES</span><strong>{data.counts.profileComments}</strong></div>
</section>
<div class="agent-profile-grid">
<div class="agent-primary">
<section class="profile-section agent-bio">
<header><div><p class="eyebrow">FIELD DOSSIER</p><h1>ABOUT {data.agent.displayName.toUpperCase()}</h1></div><span>PUBLIC BIO</span></header>
<div class="bio-content">
<MarkdownContent html={data.agent.bioHtml} emptyMessage="This Agent has not published a field bio yet." />
</div>
</section>
<section class="profile-section">
<header><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>PUBLIC BUILDS</h2></div><span>{data.counts.builds} TOTAL</span></header>
<div class="public-content-grid">
{#each data.builds as build}
<a class="public-content-card" href={`/builds/${encodeURIComponent(build.slug)}`}>
<span>LOADOUT</span>
<h3>{build.title}</h3>
<p>{build.summary || 'Open this loadout to inspect its equipment, Attributes, and calculated performance.'}</p>
<footer><small>UPDATED {dateLabel(build.updatedAt)}</small><b>VIEW BUILD →</b></footer>
</a>
{:else}
<div class="public-empty">
<span>◇</span><strong>NO PUBLIC BUILDS</strong>
<p>This Agent has not shared a public loadout.</p>
</div>
{/each}
</div>
</section>
<section class="profile-section">
<header><div><p class="eyebrow">FIELD GUIDANCE</p><h2>PUBLISHED GUIDES</h2></div><span>{data.counts.guides} TOTAL</span></header>
<div class="public-content-grid">
{#each data.guides as guide}
<a class="public-content-card guide-card" href={`/builds/${encodeURIComponent(guide.slug)}`}>
<span>GUIDE</span>
<h3>{guide.title}</h3>
<p>{guide.summary || 'Open this guide for the complete strategy and field notes.'}</p>
<footer><small>UPDATED {dateLabel(guide.updatedAt)}</small><b>READ GUIDE →</b></footer>
</a>
{:else}
<div class="public-empty">
<span>◇</span><strong>NO PUBLISHED GUIDES</strong>
<p>This Agent has not published a guide.</p>
</div>
{/each}
</div>
</section>
<section class="profile-section">
<header><div><p class="eyebrow">COMMUNITY RECORD</p><h2>RECENT DISCUSSION</h2></div><span>{data.counts.authoredComments} COMMENTS</span></header>
<div class="authored-comment-list">
{#each data.authoredComments as comment}
<article>
<header><div><span>{comment.targetType.toUpperCase()}</span><strong>{comment.targetTitle}</strong></div><small>{dateLabel(comment.createdAt)}</small></header>
<MarkdownContent html={comment.bodyHtml} compact />
<a href={comment.targetHref}>OPEN DISCUSSION →</a>
</article>
{:else}
<div class="public-empty">
<span>◇</span><strong>NO PUBLIC DISCUSSION</strong>
<p>Comments this Agent leaves on builds and guides will appear here.</p>
</div>
{/each}
</div>
</section>
</div>
<aside class="profile-section profile-comms">
<header><div><p class="eyebrow">AGENT COMMS</p><h2>PROFILE MESSAGES</h2></div><span>{data.counts.profileComments}</span></header>
{#if data.viewer.canComment}
<PendingForm action="?/commentProfile" class="profile-comment-form">
<input type="hidden" name="agentId" value={data.agent.id}/>
<MarkdownEditor
name="body"
label="LEAVE A MESSAGE"
help="Keep profile messages relevant, respectful, and useful to the Agent."
maxLength={2000}
rows={6}
required
bind:value={profileCommentDraft}
/>
{#if form?.profileCommentError || form?.commentError}<p class="error">{form?.profileCommentError ?? form?.commentError}</p>{/if}
{#if form?.profileCommentSaved || form?.commentSaved}<p class="success">Profile message transmitted.</p>{/if}
<button class="primary" disabled={!profileCommentDraft.trim()}>POST MESSAGE →</button>
</PendingForm>
{:else if !data.viewer.id}
<div class="comment-access-note"><strong>SIGN-IN REQUIRED</strong><p>Sign in to leave a message on this Agent profile.</p><a class="secondary" href="/sign-in">SIGN IN →</a></div>
{:else}
<div class="comment-access-note"><strong>MESSAGING UNAVAILABLE</strong><p>Your account cannot post profile messages at this time.</p></div>
{/if}
<div class="profile-comment-list">
{#each data.profileComments as comment}
<article>
<header>
<AgentIdentityLink agent={comment.author} size="compact" meta={dateLabel(comment.createdAt)} />
{#if comment.canDelete}
<button type="button" class="comment-remove" aria-expanded={deletingCommentId === comment.id} onclick={() => deletingCommentId = comment.id}>REMOVE</button>
{/if}
</header>
<MarkdownContent html={comment.bodyHtml} compact />
{#if deletingCommentId === comment.id}
<div class="comment-delete-confirm" role="alert">
<p>Remove this profile message?</p>
<div>
<button type="button" class="secondary" onclick={() => deletingCommentId = ''}>CANCEL</button>
<PendingForm action="?/deleteProfileComment">
<input type="hidden" name="commentId" value={comment.id}/>
<button class="primary">CONFIRM REMOVE</button>
</PendingForm>
</div>
</div>
{/if}
</article>
{:else}
<div class="public-empty compact">
<span>◇</span><strong>NO PROFILE MESSAGES</strong>
<p>Be the first Agent to leave a useful field message.</p>
</div>
{/each}
</div>
</aside>
</div>
</main>
<style>
.public-agent-page{padding-top:28px}.agent-command{min-height:116px;display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-lg);margin-top:12px;padding:20px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)}
.agent-command :global(.agent-identity){flex:1}.agent-command-actions{display:flex;gap:8px}.agent-command-actions a{min-height:40px;display:inline-flex;align-items:center}
.agent-telemetry{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:8px;border:1px solid var(--shd-border);background:var(--shd-surface)}
.agent-telemetry>div{display:grid;gap:4px;padding:10px 12px;border-left:1px solid var(--shd-border)}.agent-telemetry>div:first-child{border-left:0}.agent-telemetry span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.agent-telemetry strong{color:var(--shd-orange);font-size:24px}
.agent-profile-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(330px,.38fr);gap:10px;margin-top:10px}.agent-primary{display:grid;gap:10px;min-width:0}
.profile-section{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.profile-section>header{min-height:54px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 12px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.profile-section>header>div{display:grid;gap:2px}.profile-section>header :is(h1,h2,p){margin:0}.profile-section>header h1,.profile-section>header h2{font-size:var(--shd-type-body);letter-spacing:.4px}.profile-section>header>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
.bio-content{min-height:110px;padding:18px}.public-content-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;padding:7px}
.public-content-card{min-width:0;display:grid;grid-template-rows:auto auto minmax(48px,1fr) auto;gap:6px;padding:12px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:#0d1011;color:var(--shd-text)}.public-content-card.guide-card{border-left-color:var(--shd-info)}.public-content-card>span,.public-content-card footer{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.public-content-card>span{color:var(--shd-orange)}.guide-card>span{color:var(--shd-info)}.public-content-card h3,.public-content-card p{margin:0}.public-content-card h3{font-size:1.05rem}.public-content-card p{color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);line-height:1.45}.public-content-card footer{display:flex;justify-content:space-between;gap:8px;padding-top:8px;border-top:1px solid var(--shd-border-soft)}.public-content-card small{color:var(--shd-text-dim)}.public-content-card b{color:var(--shd-orange)}.public-content-card:hover,.public-content-card:focus-visible{border-color:var(--shd-orange);background:var(--shd-surface-active)}
.public-empty{grid-column:1/-1;min-height:150px;display:grid;place-content:center;gap:6px;padding:20px;text-align:center}.public-empty span{color:var(--shd-orange);font-size:28px}.public-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:1px}.public-empty p{margin:0;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.public-empty.compact{min-height:110px}
.authored-comment-list{display:grid;gap:7px;padding:7px}.authored-comment-list>article{display:grid;gap:9px;padding:10px;border:1px solid var(--shd-border);background:#0d1011}.authored-comment-list article>header{display:flex;justify-content:space-between;gap:10px}.authored-comment-list article>header>div{display:grid;gap:3px}.authored-comment-list article header span,.authored-comment-list article header small,.authored-comment-list article>a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.authored-comment-list article header span,.authored-comment-list article>a{color:var(--shd-orange)}.authored-comment-list article header small{color:var(--shd-text-muted)}.authored-comment-list article>a{justify-self:end}
.profile-comms{align-self:start;position:sticky;top:88px}:global(form.profile-comment-form){display:grid;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border)}:global(form.profile-comment-form > button){min-height:40px}.profile-comment-list{display:grid}.profile-comment-list>article{display:grid;gap:9px;padding:10px;border-bottom:1px solid var(--shd-border)}.profile-comment-list>article:last-child{border-bottom:0}.profile-comment-list article>header{display:flex;align-items:center;justify-content:space-between;gap:8px}.profile-comment-list article>header :global(.agent-identity){flex:1}.comment-remove{min-height:32px;padding:0 7px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}.comment-remove:hover,.comment-remove:focus-visible{background:var(--shd-danger);color:var(--shd-bg)}.comment-delete-confirm{display:grid;gap:8px;padding:8px;border:1px solid var(--shd-danger);background:#1b1111}.comment-delete-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-delete-confirm>div{display:flex;justify-content:flex-end;gap:7px}.comment-delete-confirm button{min-height:38px;padding:0 10px}.comment-access-note{display:grid;gap:7px;padding:12px;border-bottom:1px solid var(--shd-border)}.comment-access-note strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.comment-access-note p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-access-note a{justify-self:start}
@media(max-width:1000px){.agent-profile-grid{grid-template-columns:1fr}.profile-comms{position:static}.agent-telemetry{grid-template-columns:repeat(2,minmax(0,1fr))}.agent-telemetry>div:nth-child(3){border-left:0}.agent-telemetry>div:nth-child(n+3){border-top:1px solid var(--shd-border)}}
@media(max-width:650px){.agent-command{align-items:stretch;flex-direction:column}.agent-command-actions a{width:100%;min-height:44px;justify-content:center}.agent-telemetry{grid-template-columns:1fr}.agent-telemetry>div{border-top:1px solid var(--shd-border);border-left:0}.agent-telemetry>div:first-child{border-top:0}.public-content-grid{grid-template-columns:1fr}.profile-section>header{align-items:flex-start;flex-direction:column}:global(form.profile-comment-form > button),.comment-delete-confirm button{min-height:44px}.comment-delete-confirm>div{display:grid;grid-template-columns:1fr 1fr}}
@media(prefers-reduced-motion:reduce){.public-content-card,.comment-remove{transition:none}}
</style>
+20 -2
View File
@@ -1,20 +1,38 @@
import { getDatabase } from '$lib/server/database'; import { getDatabase } from '$lib/server/database';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { avatarUrl } from '$lib/avatar';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
const database = await getDatabase(); const database = await getDatabase();
const builds = await (await database.prepare(` const builds = await (await database.prepare(`
SELECT builds.id,builds.title,builds.slug,builds.updated_at, SELECT builds.id,builds.owner_id,builds.title,builds.slug,builds.updated_at,
COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author, COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value,
user_avatars.updated_at AS avatar_updated_at,
COALESCE(AVG(ratings.value),0) AS rating,COUNT(ratings.value) AS votes COALESCE(AVG(ratings.value),0) AS rating,COUNT(ratings.value) AS votes
FROM builds FROM builds
JOIN users ON users.id=builds.owner_id JOIN users ON users.id=builds.owner_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
LEFT JOIN ratings ON ratings.build_id=builds.id LEFT JOIN ratings ON ratings.build_id=builds.id
WHERE builds.visibility='public' AND builds.loadout_json IS NOT NULL WHERE builds.visibility='public' AND builds.loadout_json IS NOT NULL
GROUP BY builds.id GROUP BY builds.id
ORDER BY builds.updated_at DESC ORDER BY builds.updated_at DESC
LIMIT 60 LIMIT 60
`)).all(); `)).all();
return { builds }; return {
builds: builds.map((build) => ({
...build,
ownerId: String(build.owner_id),
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
authorAvatarUrl: avatarUrl(
build.avatar_source,
build.avatar_value,
String(build.owner_id),
build.avatar_updated_at
)
}))
};
}; };
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
let { data } = $props(); let { data } = $props();
</script> </script>
@@ -13,7 +14,7 @@
<article style="--build-color:#f6a821"> <article style="--build-color:#f6a821">
<div class="card-top"><span>PUBLIC</span><span>{Number(build.votes) ? `★ ${Number(build.rating).toFixed(1)}` : 'UNRATED'}</span></div> <div class="card-top"><span>PUBLIC</span><span>{Number(build.votes) ? `★ ${Number(build.rating).toFixed(1)}` : 'UNRATED'}</span></div>
<div class="weapon-art"><div></div><span>SHD LOADOUT</span></div> <div class="weapon-art"><div></div><span>SHD LOADOUT</span></div>
<div class="card-body"><p>COMMUNITY BUILD</p><h3>{build.title}</h3><div class="author"><span class="avatar">{String(build.author).slice(0,1)}</span><span>BY <strong>{build.author}</strong></span><span>{build.votes} RATINGS</span></div><a class="build-open" href={`/builds/${build.slug}`}>VIEW LOADOUT →</a></div> <div class="card-body"><p>COMMUNITY BUILD</p><h3>{build.title}</h3><div class="author"><AgentIdentityLink size="compact" linked={build.profileAvailable} agent={{ id: build.ownerId, displayName: String(build.author), avatarUrl: build.authorAvatarUrl }} meta="BUILD AUTHOR"/><span>{build.votes} RATINGS</span></div><a class="build-open" href={`/builds/${build.slug}`}>VIEW LOADOUT →</a></div>
</article> </article>
{:else} {:else}
<div class="catalogue-empty"><span>◇</span><strong>NO PUBLIC BUILDS YET</strong><p>Be the first agent to publish a loadout to the archive.</p><a class="primary" href="/builder">CREATE BUILD →</a></div> <div class="catalogue-empty"><span>◇</span><strong>NO PUBLIC BUILDS YET</strong><p>Be the first agent to publish a loadout to the archive.</p><a class="primary" href="/builder">CREATE BUILD →</a></div>
+15 -1
View File
@@ -4,16 +4,22 @@ import { getSessionUser } from '$lib/server/auth/session';
import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout'; import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
import { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat'; import { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
import { loadSharedBuildCombatCatalog } from '$lib/server/shared-build-combat'; import { loadSharedBuildCombatCatalog } from '$lib/server/shared-build-combat';
import { avatarUrl } from '$lib/avatar';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, cookies }) => { export const load: PageServerLoad = async ({ params, cookies }) => {
const database = await getDatabase(); const database = await getDatabase();
const viewer = await getSessionUser(cookies); const viewer = await getSessionUser(cookies);
const build = await (await database.prepare(` const build = await (await database.prepare(`
SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value,
user_avatars.updated_at AS avatar_updated_at
FROM builds FROM builds
JOIN users ON users.id=builds.owner_id JOIN users ON users.id=builds.owner_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
WHERE builds.slug=? WHERE builds.slug=?
`)).get(params.slug); `)).get(params.slug);
if (!build || (build.visibility === 'private' && build.owner_id !== viewer?.id)) error(404, 'Build not found'); if (!build || (build.visibility === 'private' && build.owner_id !== viewer?.id)) error(404, 'Build not found');
@@ -38,6 +44,14 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
build: { build: {
id: String(build.id), title: String(build.title), slug: String(build.slug), id: String(build.id), title: String(build.title), slug: String(build.slug),
visibility: String(build.visibility), author: String(build.author), visibility: String(build.visibility), author: String(build.author),
ownerId: String(build.owner_id),
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
authorAvatarUrl: avatarUrl(
build.avatar_source,
build.avatar_value,
String(build.owner_id),
build.avatar_updated_at
),
owner: build.owner_id === viewer?.id, updatedAt: String(build.updated_at) owner: build.owner_id === viewer?.id, updatedAt: String(build.updated_at)
}, },
loadout, gear, weapons, chips, protocols, brands, combatResults loadout, gear, weapons, chips, protocols, brands, combatResults
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { gearSlots } from '$lib/builder/loadout'; import { gearSlots } from '$lib/builder/loadout';
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte'; import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte';
let { data } = $props(); let { data } = $props();
const gear = (id: string) => data.gear.find((item) => item.id === id); const gear = (id: string) => data.gear.find((item) => item.id === id);
@@ -12,7 +13,7 @@
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head> <svelte:head><title>{data.build.title} — SHD.build</title></svelte:head>
<main class="app-page shared-build-page"> <main class="app-page shared-build-page">
<header class="shared-build-command"> <header class="shared-build-command">
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><p>Filed by <strong>{data.build.author}</strong> · Updated {new Date(data.build.updatedAt).toLocaleDateString()}</p></div> <div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
<div>{#if data.build.owner}<a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div> <div>{#if data.build.owner}<a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
</header> </header>
<section class="builder-combat-results" aria-labelledby="shared-combat-heading"> <section class="builder-combat-results" aria-labelledby="shared-combat-heading">
+5 -2
View File
@@ -33,12 +33,15 @@
{ href: '/isac/roles', label: 'Roles', permission: 'users.roles' } { href: '/isac/roles', label: 'Roles', permission: 'users.roles' }
]; ];
const visibleUserSections = $derived(userSections.filter((section) => can(section.permission))); const visibleUserSections = $derived(userSections.filter((section) => can(section.permission)));
const oversightSections: Array<{ href: string; label: string; permission: string; reviewKey?: 'data' | 'avatars' }> = [ const oversightSections: Array<{ href: string; label: string; permission?: string; permissions?: string[]; reviewKey?: 'data' | 'avatars' }> = [
{ href: '/isac/data-moderation', label: 'Data Oversight', permission: 'data.approve', reviewKey: 'data' }, { href: '/isac/data-moderation', label: 'Data Oversight', permission: 'data.approve', reviewKey: 'data' },
{ href: '/isac/avatar-oversight', label: 'Avatar Oversight', permission: 'avatars.review', reviewKey: 'avatars' }, { href: '/isac/avatar-oversight', label: 'Avatar Oversight', permission: 'avatars.review', reviewKey: 'avatars' },
{ href: '/isac/community', label: 'Community Oversight', permissions: ['community.comments.moderate', 'community.profiles.moderate'] },
{ href: '/isac/audit', label: 'Audit history', permission: 'audit.view' } { href: '/isac/audit', label: 'Audit history', permission: 'audit.view' }
]; ];
const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission))); const visibleOversightSections = $derived(oversightSections.filter((section) =>
section.permissions?.some(can) || (section.permission ? can(section.permission) : false)
));
const systemSections = [ const systemSections = [
{ href: '/isac/site-controls', label: 'Site controls', permission: 'site.settings.manage' }, { href: '/isac/site-controls', label: 'Site controls', permission: 'site.settings.manage' },
{ href: '/isac/images', label: 'Image library', permission: 'images.manage' }, { href: '/isac/images', label: 'Image library', permission: 'images.manage' },
+1
View File
@@ -87,6 +87,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare(`SELECT COUNT(*) AS total, (await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN entity_type='game_data' THEN 1 ELSE 0 END) AS game_data, SUM(CASE WHEN entity_type='game_data' THEN 1 ELSE 0 END) AS game_data,
SUM(CASE WHEN entity_type='data_moderation' THEN 1 ELSE 0 END) AS data_moderation, SUM(CASE WHEN entity_type='data_moderation' THEN 1 ELSE 0 END) AS data_moderation,
SUM(CASE WHEN entity_type='community_moderation' THEN 1 ELSE 0 END) AS community_moderation,
SUM(CASE WHEN entity_type IN ('user','user_access','user_moderation','identity') THEN 1 ELSE 0 END) AS users, SUM(CASE WHEN entity_type IN ('user','user_access','user_moderation','identity') THEN 1 ELSE 0 END) AS users,
SUM(CASE WHEN entity_type IN ('role','role_management') THEN 1 ELSE 0 END) AS access, SUM(CASE WHEN entity_type IN ('role','role_management') THEN 1 ELSE 0 END) AS access,
SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration, SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration,
+1
View File
@@ -36,6 +36,7 @@
{value:'calculation_configuration',label:'CALCULATION RULES',count:data.summary.calculation_configuration,description:'Combat model revisions and calibration'}, {value:'calculation_configuration',label:'CALCULATION RULES',count:data.summary.calculation_configuration,description:'Combat model revisions and calibration'},
{value:'ai_assistance',label:'AI ASSISTANCE',count:data.summary.ai_assistance,description:'Permission-gated mechanic drafting'}, {value:'ai_assistance',label:'AI ASSISTANCE',count:data.summary.ai_assistance,description:'Permission-gated mechanic drafting'},
{value:'data_moderation',label:'DATA MODERATION',count:data.summary.data_moderation,description:'Proposal lifecycle decisions'}, {value:'data_moderation',label:'DATA MODERATION',count:data.summary.data_moderation,description:'Proposal lifecycle decisions'},
{value:'community_moderation',label:'COMMUNITY',count:data.summary.community_moderation,description:'Profile and comment moderation'},
{value:'user',label:'USER OPERATIONS',count:data.summary.users,description:'Access, moderation, identities'}, {value:'user',label:'USER OPERATIONS',count:data.summary.users,description:'Access, moderation, identities'},
{value:'access_policy',label:'ACCESS POLICY',count:data.summary.access,description:'Role definition changes'} {value:'access_policy',label:'ACCESS POLICY',count:data.summary.access,description:'Role definition changes'}
,{value:'system_configuration',label:'SYSTEM CONFIG',count:data.summary.system_configuration,description:'Automated messages and options'} ,{value:'system_configuration',label:'SYSTEM CONFIG',count:data.summary.system_configuration,description:'Automated messages and options'}
+473
View File
@@ -0,0 +1,473 @@
import { avatarUrl } from '$lib/avatar';
import { recordIsacAudit } from '$lib/server/audit';
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { renderUserMarkdown } from '$lib/markdown';
import { createNotification } from '$lib/server/notifications';
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
import { error, fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
const COMMENT_PERMISSION = 'community.comments.moderate';
const PROFILE_PERMISSION = 'community.profiles.moderate';
const REMOVED_RETENTION_DAYS = 30;
const has = (permissions: string[], permission: string) =>
permissions.includes('*') || permissions.includes(permission);
const actionFailure = (status: number, message: string) => fail(status, {
communityError: message,
communityResultToken: randomUUID()
});
const requiredReason = (value: FormDataEntryValue | null) => {
const reason = String(value ?? '').normalize('NFKC').trim().slice(0, 500);
return reason.length >= 3 ? reason : null;
};
const targetHref = (targetType: string, targetId: unknown, slug: unknown) =>
targetType === 'profile'
? `/agents/${encodeURIComponent(String(targetId))}#profile-comments`
: `/builds/${encodeURIComponent(String(slug ?? ''))}#comments`;
const parseAuditSnapshot = (value: unknown) => {
if (typeof value !== 'string') return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null;
} catch {
return null;
}
};
export const load: PageServerLoad = async ({ cookies, url }) => {
const operator = await getSessionUser(cookies);
if (!operator) error(401, 'Sign in required');
const granted = await getPermissions(operator);
const canModerateComments = has(granted, COMMENT_PERMISSION);
const canModerateProfiles = has(granted, PROFILE_PERMISSION);
if (!canModerateComments && !canModerateProfiles) error(403, 'Community Oversight permission required');
const requestedTab = url.searchParams.get('tab');
const tab = requestedTab === 'bios'
? 'bios'
: requestedTab === 'comments'
? 'comments'
: canModerateComments ? 'comments' : 'bios';
if (tab === 'comments' && !canModerateComments) error(403, 'Comment moderation permission required');
if (tab === 'bios' && !canModerateProfiles) error(403, 'Profile moderation permission required');
const allowedStatuses = tab === 'comments' ? ['all', 'visible', 'removed'] : ['all', 'visible', 'hidden'];
const status = allowedStatuses.includes(String(url.searchParams.get('status')))
? String(url.searchParams.get('status'))
: 'visible';
const { search, pattern } = getRegistrySearch(url);
const database = await getDatabase();
const removedCutoff = new Date(Date.now() - REMOVED_RETENTION_DAYS * 86_400_000).toISOString();
const commentSummaryRows = canModerateComments
? await (await database.prepare(`SELECT status,COUNT(*) AS count FROM comments
WHERE status='visible' OR (status='removed' AND removed_at>=?)
GROUP BY status`)).all(removedCutoff)
: [];
const visibleBios = canModerateProfiles
? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE profile.is_public=1 AND COALESCE(moderation.bio_hidden,0)=0
AND length(trim(profile.bio_markdown))>0`)).get())?.count ?? 0)
: 0;
const hiddenBios = canModerateProfiles
? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM user_profiles profile
JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE moderation.bio_hidden=1 AND length(trim(profile.bio_markdown))>0`)).get())?.count ?? 0)
: 0;
const commentSummary = Object.fromEntries(
commentSummaryRows.map((row) => [String(row.status), Number(row.count)])
);
if (tab === 'comments') {
const sort = getRegistrySort(url, {
created: {
asc: 'comments.created_at ASC',
desc: 'comments.created_at DESC',
defaultDirection: 'desc'
},
updated: {
asc: 'comments.updated_at ASC',
desc: 'comments.updated_at DESC',
defaultDirection: 'desc'
},
author: {
asc: 'display_name COLLATE NOCASE ASC',
desc: 'display_name COLLATE NOCASE DESC'
}
}, 'created', 'desc');
const conditions: string[] = [];
const args: unknown[] = [];
if (status === 'all') {
conditions.push("(comments.status='visible' OR (comments.status='removed' AND comments.removed_at>=?))");
args.push(removedCutoff);
} else {
conditions.push('comments.status=?');
args.push(status);
}
if (status === 'removed') {
conditions.push('comments.removed_at>=?');
args.push(removedCutoff);
}
if (search) {
conditions.push(`(comments.body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
OR COALESCE(profile.display_name,users.username,'Deleted Agent') LIKE ? ESCAPE '\\' COLLATE NOCASE
OR COALESCE(builds.title,target_profile.display_name,target_user.username,'') LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
args.push(pattern, pattern, pattern);
}
const joins = `FROM comments
JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN users ON users.id=comments.author_id
LEFT JOIN user_profiles profile ON profile.user_id=users.id
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
LEFT JOIN builds ON builds.id=thread.target_id AND thread.target_type IN ('build','guide')
LEFT JOIN users target_user ON target_user.id=thread.target_id AND thread.target_type='profile'
LEFT JOIN user_profiles target_profile ON target_profile.user_id=target_user.id`;
const where = `WHERE ${conditions.join(' AND ')}`;
const total = Number((await (await database.prepare(
`SELECT COUNT(*) AS count ${joins} ${where}`
)).get(...args))?.count ?? 0);
const pagination = getPagination(url, total);
const rows = await (await database.prepare(`SELECT comments.id,comments.body_markdown,comments.status,
comments.created_at,comments.updated_at,comments.removal_reason,
comments.author_id,COALESCE(profile.display_name,users.username,'Deleted Agent') AS display_name,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
thread.target_type,thread.target_id,builds.slug,
COALESCE(builds.title,target_profile.display_name,target_user.username,'Community content') AS target_title
${joins} ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`))
.all(...args, pagination.perPage, pagination.offset);
return {
tab,
status,
search,
summary: {
comments: { visible: Number(commentSummary.visible ?? 0), removed: Number(commentSummary.removed ?? 0) },
bios: { visible: visibleBios, hidden: hiddenBios }
},
items: rows.map((row) => ({
id: String(row.id),
bodyHtml: renderUserMarkdown(row.body_markdown),
status: String(row.status),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at),
moderationReason: row.removal_reason ? String(row.removal_reason) : null,
targetType: String(row.target_type),
targetTitle: String(row.target_title),
targetHref: targetHref(String(row.target_type), row.target_id, row.slug),
author: {
id: String(row.author_id),
displayName: String(row.display_name),
avatarUrl: avatarUrl(row.avatar_source, row.avatar_value, String(row.author_id), row.avatar_updated_at)
}
})),
pagination,
sort: { key: sort.key, direction: sort.direction },
permissions: { comments: canModerateComments, profiles: canModerateProfiles }
};
}
const sort = getRegistrySort(url, {
updated: {
asc: 'profile.updated_at ASC',
desc: 'profile.updated_at DESC',
defaultDirection: 'desc'
},
agent: {
asc: 'display_name COLLATE NOCASE ASC',
desc: 'display_name COLLATE NOCASE DESC'
}
}, 'updated', 'desc');
const conditions = [
status === 'all'
? "((profile.is_public=1 AND COALESCE(moderation.bio_hidden,0)=0) OR moderation.bio_hidden=1)"
: status === 'visible'
? 'profile.is_public=1 AND COALESCE(moderation.bio_hidden,0)=0'
: 'moderation.bio_hidden=1',
'length(trim(profile.bio_markdown))>0'
];
const args: unknown[] = [];
if (search) {
conditions.push(`(profile.bio_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
OR COALESCE(profile.display_name,users.username) LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
args.push(pattern, pattern);
}
const where = `WHERE ${conditions.join(' AND ')}`;
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS count
FROM user_profiles profile JOIN users ON users.id=profile.user_id
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
${where}`)).get(...args))?.count ?? 0);
const pagination = getPagination(url, total);
const rows = await (await database.prepare(`SELECT profile.user_id,profile.bio_markdown,profile.is_public,
profile.updated_at,COALESCE(profile.display_name,users.username) AS display_name,
avatar.source AS avatar_source,avatar.value AS avatar_value,avatar.updated_at AS avatar_updated_at,
moderation.reason AS moderation_reason
FROM user_profiles profile
JOIN users ON users.id=profile.user_id
LEFT JOIN user_avatars avatar ON avatar.user_id=users.id
LEFT JOIN profile_moderation moderation ON moderation.user_id=users.id
${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`))
.all(...args, pagination.perPage, pagination.offset);
return {
tab,
status,
search,
summary: {
comments: { visible: Number(commentSummary.visible ?? 0), removed: Number(commentSummary.removed ?? 0) },
bios: { visible: visibleBios, hidden: hiddenBios }
},
items: rows.map((row) => ({
id: String(row.user_id),
bodyHtml: renderUserMarkdown(row.bio_markdown),
status,
updatedAt: String(row.updated_at),
moderationReason: row.moderation_reason ? String(row.moderation_reason) : null,
agent: {
id: String(row.user_id),
displayName: String(row.display_name),
avatarUrl: avatarUrl(row.avatar_source, row.avatar_value, String(row.user_id), row.avatar_updated_at)
}
})),
pagination,
sort: { key: sort.key, direction: sort.direction },
permissions: { comments: canModerateComments, profiles: canModerateProfiles }
};
};
export const actions: Actions = {
removeComment: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), COMMENT_PERMISSION);
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const reason = requiredReason(form.get('reason'));
if (!id || !reason) return actionFailure(400, 'Choose a comment and provide a brief removal reason.');
const database = await getDatabase();
const comment = await (await database.prepare(`SELECT comments.*,thread.target_type,thread.target_id,builds.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')
WHERE comments.id=? AND comments.status='visible'`)).get(id);
if (!comment) return actionFailure(404, 'That visible comment no longer exists.');
const before = {
status: 'visible',
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
targetType: String(comment.target_type),
targetId: String(comment.target_id),
updatedAt: String(comment.updated_at)
};
const now = new Date().toISOString();
const result = await (await database.prepare(`UPDATE comments SET status='removed',removed_at=?,
removed_by=?,removal_reason=?,updated_at=? WHERE id=? AND status='visible'`))
.run(now, operator.id, reason, now, id);
if (!result.changes) return actionFailure(409, 'That comment changed before it could be removed.');
const after = { ...before, status: 'removed', removedAt: now, removedBy: operator.id, reason, updatedAt: now };
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: 'comment.removed',
entityId: id,
before,
after
});
await createNotification({
userId: String(comment.author_id),
category: 'isac',
source: 'ISAC COMMUNITY OVERSIGHT',
title: 'Comment removed',
body: `Community Oversight removed one of your comments. Reason: ${reason}`,
link: targetHref(String(comment.target_type), comment.target_id, comment.slug),
actions: [{
label: 'VIEW DISCUSSION',
href: targetHref(String(comment.target_type), comment.target_id, comment.slug)
}],
mandatory: true
});
return { communitySaved: true, communityResultToken: randomUUID() };
},
restoreComment: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), COMMENT_PERMISSION);
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const reason = requiredReason(form.get('reason'));
if (!id || !reason) return actionFailure(400, 'Choose a comment and provide a brief restoration reason.');
const database = await getDatabase();
const comment = await (await database.prepare(`SELECT * FROM comments WHERE id=? AND status='removed'`)).get(id);
if (!comment) return actionFailure(404, 'That removed comment no longer exists.');
const audit = await (await database.prepare(`SELECT after_json FROM audit_log
WHERE entity_type='community_moderation' AND entity_id=? AND action='comment.removed'
ORDER BY created_at DESC LIMIT 1`)).get(id);
const expected = parseAuditSnapshot(audit?.after_json);
if (
!expected
|| expected.status !== 'removed'
|| String(expected.removedAt ?? '') !== String(comment.removed_at ?? '')
|| String(expected.updatedAt ?? '') !== String(comment.updated_at ?? '')
) return actionFailure(409, 'This comment was not removed by Community Oversight or changed after removal.');
const before = {
status: 'removed',
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
removedAt: String(comment.removed_at),
removedBy: String(comment.removed_by),
removalReason: String(comment.removal_reason),
updatedAt: String(comment.updated_at)
};
const now = new Date().toISOString();
const result = await (await database.prepare(`UPDATE comments SET status='visible',removed_at=NULL,
removed_by=NULL,removal_reason=NULL,updated_at=? WHERE id=? AND status='removed' AND updated_at=?`))
.run(now, id, comment.updated_at);
if (!result.changes) return actionFailure(409, 'That comment changed before it could be restored.');
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: 'comment.restored',
entityId: id,
before,
after: {
status: 'visible',
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
restorationReason: reason,
updatedAt: now
}
});
return { communitySaved: true, communityResultToken: randomUUID() };
},
hideBio: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), PROFILE_PERMISSION);
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const reason = requiredReason(form.get('reason'));
if (!id || !reason) return actionFailure(400, 'Choose a profile and provide a brief reason for hiding it.');
const database = await getDatabase();
const profile = await (await database.prepare(`SELECT profile.user_id,profile.bio_markdown,
profile.is_public,profile.bio_updated_at,profile.updated_at,
COALESCE(moderation.bio_hidden,0) AS bio_hidden,moderation.reason,
moderation.moderated_at,moderation.moderated_by
FROM user_profiles profile
LEFT JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE profile.user_id=? AND profile.is_public=1
AND COALESCE(moderation.bio_hidden,0)=0 AND length(trim(profile.bio_markdown))>0`)).get(id);
if (!profile) return actionFailure(404, 'That visible profile biography no longer exists.');
const before = {
bioMarkdown: String(profile.bio_markdown),
isPublic: true,
bioHidden: false,
bioUpdatedAt: profile.bio_updated_at ? String(profile.bio_updated_at) : null,
profileUpdatedAt: String(profile.updated_at),
moderation: profile.moderated_at ? {
reason: profile.reason ? String(profile.reason) : null,
moderatedAt: String(profile.moderated_at),
moderatedBy: profile.moderated_by ? String(profile.moderated_by) : null
} : null
};
const now = new Date().toISOString();
const result = await (await database.prepare(`INSERT INTO profile_moderation
(user_id,bio_hidden,reason,moderated_at,moderated_by) VALUES(?,1,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET bio_hidden=1,reason=excluded.reason,
moderated_at=excluded.moderated_at,moderated_by=excluded.moderated_by
WHERE profile_moderation.bio_hidden=0`)).run(id, reason, now, operator.id);
if (!result.changes) return actionFailure(409, 'That profile changed before it could be hidden.');
const after = {
...before,
bioHidden: true,
reason,
moderatedAt: now,
moderatedBy: operator.id
};
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: 'profile.hidden',
entityId: id,
before,
after
});
await createNotification({
userId: id,
category: 'isac',
source: 'ISAC COMMUNITY OVERSIGHT',
title: 'Agent profile hidden',
body: `Community Oversight hid your public Agent profile. Your biography is preserved but is not publicly visible. Reason: ${reason}`,
link: '/account',
actions: [{ label: 'REVIEW PROFILE', href: '/account' }],
mandatory: true
});
return { communitySaved: true, communityResultToken: randomUUID() };
},
restoreBio: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), PROFILE_PERMISSION);
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const reason = requiredReason(form.get('reason'));
if (!id || !reason) return actionFailure(400, 'Choose a profile and provide a brief restoration reason.');
const database = await getDatabase();
const profile = await (await database.prepare(`SELECT profile.user_id,profile.bio_markdown,
profile.is_public,profile.bio_updated_at,profile.updated_at,
moderation.bio_hidden,moderation.reason,moderation.moderated_at,moderation.moderated_by
FROM user_profiles profile
JOIN profile_moderation moderation ON moderation.user_id=profile.user_id
WHERE profile.user_id=? AND moderation.bio_hidden=1
AND length(trim(profile.bio_markdown))>0`)).get(id);
if (!profile) return actionFailure(404, 'That hidden profile biography no longer exists.');
const audit = await (await database.prepare(`SELECT after_json FROM audit_log
WHERE entity_type='community_moderation' AND entity_id=? AND action='profile.hidden'
ORDER BY created_at DESC LIMIT 1`)).get(id);
const expected = parseAuditSnapshot(audit?.after_json);
if (
!expected
|| expected.bioHidden !== true
|| String(expected.bioMarkdown ?? '') !== String(profile.bio_markdown)
|| String(expected.bioUpdatedAt ?? '') !== String(profile.bio_updated_at ?? '')
|| String(expected.profileUpdatedAt ?? '') !== String(profile.updated_at)
|| String(expected.moderatedAt ?? '') !== String(profile.moderated_at)
|| String(expected.moderatedBy ?? '') !== String(profile.moderated_by)
) return actionFailure(409, 'This profile was not hidden by Community Oversight or changed after it was hidden.');
const before = {
bioMarkdown: String(profile.bio_markdown),
isPublic: Boolean(profile.is_public),
bioHidden: true,
bioUpdatedAt: profile.bio_updated_at ? String(profile.bio_updated_at) : null,
profileUpdatedAt: String(profile.updated_at),
reason: String(profile.reason),
moderatedAt: String(profile.moderated_at),
moderatedBy: String(profile.moderated_by)
};
const now = new Date().toISOString();
const result = await (await database.prepare(`UPDATE profile_moderation
SET bio_hidden=0,reason=?,moderated_at=?,moderated_by=?
WHERE user_id=? AND bio_hidden=1 AND moderated_at=?`))
.run(reason, now, operator.id, id, profile.moderated_at);
if (!result.changes) return actionFailure(409, 'That profile changed before it could be restored.');
await recordIsacAudit({
actorId: operator.id,
category: 'community_moderation',
action: 'profile.restored',
entityId: id,
before,
after: {
...before,
bioHidden: false,
restorationReason: reason,
moderatedAt: now,
moderatedBy: operator.id
}
});
return { communitySaved: true, communityResultToken: randomUUID() };
}
};
+270
View File
@@ -0,0 +1,270 @@
<script lang="ts">
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
type AgentSummary = { id: string; displayName: string; avatarUrl: string };
type CommunityComment = {
id: string;
bodyHtml: string;
status: 'visible' | 'removed';
createdAt: string;
updatedAt: string;
moderationReason: string | null;
targetType: string;
targetTitle: string;
targetHref: string;
author: AgentSummary;
};
type CommunityBio = {
id: string;
bodyHtml: string;
status: 'visible' | 'hidden';
updatedAt: string;
moderationReason: string | null;
agent: AgentSummary;
};
type CommunityItem = CommunityComment | CommunityBio;
type ModerationAction = 'removeComment' | 'restoreComment' | 'hideBio' | 'restoreBio';
type PageData = {
tab: 'comments' | 'bios';
status: string;
search: string;
summary: {
comments: { visible: number; removed: number };
bios: { visible: number; hidden: number };
};
items: CommunityItem[];
pagination: any;
permissions: { comments: boolean; profiles: boolean };
};
let { data, form }: { data: PageData; form?: Record<string, any> | null } = $props();
let reviewing = $state<CommunityItem | null>(null);
let moderationAction = $state<ModerationAction | null>(null);
let moderationReason = $state('');
let confirming = $state(false);
let handledResultToken = $state('');
const canModerateComments = $derived(data.permissions.comments);
const canModerateProfiles = $derived(data.permissions.profiles);
const isComment = (item: CommunityItem): item is CommunityComment => 'author' in item;
const itemAgent = (item: CommunityItem) => isComment(item) ? item.author : item.agent;
const itemTimestamp = (item: CommunityItem) => isComment(item) ? item.createdAt : item.updatedAt;
const date = (value: string) => {
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf())
? 'DATE UNKNOWN'
: new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(parsed);
};
const actionRoute = $derived(
moderationAction === 'removeComment' ? '?/removeComment'
: moderationAction === 'restoreComment' ? '?/restoreComment'
: moderationAction === 'hideBio' ? '?/hideBio'
: '?/restoreBio'
);
const actionLabel = $derived(
moderationAction === 'removeComment' ? 'REMOVE COMMENT'
: moderationAction === 'restoreComment' ? 'RESTORE COMMENT'
: moderationAction === 'hideBio' ? 'HIDE PROFILE BIO'
: 'RESTORE PROFILE BIO'
);
const actionConsequence = $derived(
moderationAction === 'removeComment'
? 'The comment will no longer appear on its public target or the author’s public activity.'
: moderationAction === 'restoreComment'
? 'The comment will return to its public target and the author’s public activity.'
: moderationAction === 'hideBio'
? 'The current bio will be hidden from the Agent’s public profile.'
: 'The bio will return to the Agent’s public profile.'
);
const statusItems = $derived(
data.tab === 'comments'
? [
{ value: 'all', label: 'ALL', count: data.summary.comments.visible + data.summary.comments.removed, description: 'All public and moderated comments' },
{ value: 'visible', label: 'VISIBLE', count: data.summary.comments.visible, description: 'Comments currently visible to the community' },
{ value: 'removed', label: 'REMOVED', count: data.summary.comments.removed, description: 'Comments removed by Oversight' }
]
: [
{ value: 'all', label: 'ALL', count: data.summary.bios.visible + data.summary.bios.hidden, description: 'All public and moderated bios' },
{ value: 'visible', label: 'VISIBLE', count: data.summary.bios.visible, description: 'Bios currently visible on Agent profiles' },
{ value: 'hidden', label: 'HIDDEN', count: data.summary.bios.hidden, description: 'Bios hidden by Oversight' }
]
);
function openModeration(item: CommunityItem) {
reviewing = item;
moderationAction = isComment(item)
? item.status === 'removed' ? 'restoreComment' : 'removeComment'
: item.status === 'hidden' ? 'restoreBio' : 'hideBio';
moderationReason = '';
confirming = false;
}
function closeModeration() {
reviewing = null;
moderationAction = null;
moderationReason = '';
confirming = false;
}
$effect(() => {
const token = String(form?.communityResultToken ?? '');
if (!token || token === handledResultToken) return;
handledResultToken = token;
if (form?.communitySaved) closeModeration();
});
</script>
<svelte:head><title>Community Oversight — ISAC</title></svelte:head>
<svelte:window onkeydown={(event) => {
if (event.key === 'Escape' && reviewing) closeModeration();
}}/>
<div class="module-toolbar moderation-toolbar">
<div>
<p class="eyebrow">COMMUNITY OVERSIGHT / PUBLIC CONTENT CONTROL</p>
<h2>COMMUNITY OVERSIGHT</h2>
<p>Review public discussion and Agent bios. Moderation preserves evidence, requires a reason, and remains reversible.</p>
</div>
<div class="queue-readout">
<span>{data.tab === 'comments' ? 'VISIBLE COMMENTS' : 'VISIBLE BIOS'}</span>
<strong>{data.tab === 'comments' ? data.summary.comments.visible : data.summary.bios.visible}</strong>
<small>PUBLIC RECORDS</small>
</div>
</div>
<nav class="community-tabs" aria-label="Community oversight channels">
{#if canModerateComments}
<a class:active={data.tab === 'comments'} aria-current={data.tab === 'comments' ? 'page' : undefined} href="/isac/community?tab=comments&status=all">
<span>COMMENTS</span><b>{data.summary.comments.visible + data.summary.comments.removed}</b>
</a>
{/if}
{#if canModerateProfiles}
<a class:active={data.tab === 'bios'} aria-current={data.tab === 'bios' ? 'page' : undefined} href="/isac/community?tab=bios&status=all">
<span>PROFILE BIOS</span><b>{data.summary.bios.visible + data.summary.bios.hidden}</b>
</a>
{/if}
</nav>
<KpiFilters parameter="status" active={data.status} items={statusItems}/>
<form class="audit-filter community-filter">
<input type="hidden" name="tab" value={data.tab}/>
<input type="hidden" name="status" value={data.status}/>
<input name="search" type="search" value={data.search} placeholder={data.tab === 'comments' ? 'AUTHOR, TARGET, OR COMMENT TEXT' : 'AGENT OR BIO TEXT'}/>
<button class="secondary">SEARCH COMMUNITY →</button>
</form>
<RegistryPagination pagination={data.pagination}/>
<div class="community-registry">
{#each data.items as item}
<article class:moderated={item.status === 'removed' || item.status === 'hidden'}>
<header>
<AgentIdentityLink agent={itemAgent(item)} meta={isComment(item) ? 'COMMENT AUTHOR' : 'PROFILE OWNER'} />
<div class="community-state">
<span>{date(itemTimestamp(item))}</span>
<b class:visible={item.status === 'visible'}>{item.status.toUpperCase()}</b>
</div>
</header>
{#if isComment(item)}
<div class="community-target">
<span>{item.targetType.toUpperCase()}</span>
<a href={item.targetHref}>{item.targetTitle} →</a>
</div>
{:else}
<div class="community-target"><span>PUBLIC PROFILE BIO</span><a href={`/agents/${encodeURIComponent(item.agent.id)}`}>VIEW AGENT PROFILE →</a></div>
{/if}
<div class="community-markdown-preview">
<MarkdownContent html={item.bodyHtml} compact emptyMessage={isComment(item) ? 'Comment body unavailable.' : 'No bio content.'}/>
</div>
{#if item.moderationReason}
<div class="moderation-evidence"><span>LAST OVERSIGHT REASON</span><p>{item.moderationReason}</p></div>
{/if}
<footer>
<span>{isComment(item) ? `COMMENT ${item.id.slice(0,8).toUpperCase()}` : `AGENT ${item.id.slice(0,8).toUpperCase()}`}</span>
{#if (isComment(item) && canModerateComments) || (!isComment(item) && canModerateProfiles)}
<button class={item.status === 'visible' ? 'danger' : 'secondary'} onclick={() => openModeration(item)}>
{item.status === 'visible' ? isComment(item) ? 'REMOVE COMMENT →' : 'HIDE BIO →' : isComment(item) ? 'RESTORE COMMENT →' : 'RESTORE BIO →'}
</button>
{/if}
</footer>
</article>
{:else}
<div class="empty-dashboard">
<strong>NO MATCHING {data.tab === 'comments' ? 'COMMENTS' : 'PROFILE BIOS'}</strong>
<span>Adjust the status or search filters to inspect another part of the community record.</span>
</div>
{/each}
</div>
{#if reviewing && moderationAction}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal community-review-modal" role="dialog" aria-modal="true" aria-labelledby="community-review-title">
<div class="editor-title">
<div><p class="eyebrow">COMMUNITY CONTROL / {reviewing.status.toUpperCase()}</p><h3 id="community-review-title">{actionLabel}</h3></div>
<button aria-label="Close community moderation review" onclick={closeModeration}>×</button>
</div>
<div class="community-review-identity">
<AgentIdentityLink agent={itemAgent(reviewing)} meta={isComment(reviewing) ? 'COMMENT AUTHOR' : 'PROFILE OWNER'} />
<span>{date(itemTimestamp(reviewing))}</span>
</div>
{#if isComment(reviewing)}
<div class="community-target review-target"><span>{reviewing.targetType.toUpperCase()}</span><a href={reviewing.targetHref}>{reviewing.targetTitle} →</a></div>
{/if}
<div class="community-review-content"><MarkdownContent html={reviewing.bodyHtml} emptyMessage="No content is available for review."/></div>
{#if reviewing.moderationReason}<div class="moderation-evidence"><span>PREVIOUS OVERSIGHT REASON</span><p>{reviewing.moderationReason}</p></div>{/if}
{#if !confirming}
<label class="field moderation-reason">
OVERSIGHT REASON — REQUIRED
<textarea bind:value={moderationReason} maxlength="500" required placeholder="Record a specific policy, safety, or restoration reason…"></textarea>
</label>
{#if form?.communityError}<p class="error">{form.communityError}</p>{/if}
<div class="moderation-warning" class:rejecting={moderationAction === 'removeComment' || moderationAction === 'hideBio'}>
<b>!</b><span>{actionConsequence} The original content and this reason remain available to authorized Oversight.</span>
</div>
<div class="form-actions">
<button type="button" class="secondary" onclick={closeModeration}>CANCEL</button>
<button type="button" class="primary" disabled={!moderationReason.trim()} onclick={() => confirming = true}>REVIEW ACTION →</button>
</div>
{:else}
{@const reviewingId = reviewing.id}
<div class="community-final-confirm" role="alert">
<div><span>FINAL CONFIRMATION</span><strong>{actionLabel}?</strong><p>{actionConsequence}</p></div>
<dl><dt>OVERSIGHT REASON</dt><dd>{moderationReason}</dd></dl>
</div>
<PendingForm action={actionRoute}>
{#snippet children(pending)}
<input type="hidden" name="id" value={reviewingId}/>
<input type="hidden" name="reason" value={moderationReason}/>
{#if form?.communityError}<p class="error">{form.communityError}</p>{/if}
<div class="form-actions">
<button type="button" class="secondary" disabled={pending} onclick={() => confirming = false}>BACK</button>
<button class={moderationAction === 'removeComment' || moderationAction === 'hideBio' ? 'danger' : 'primary'} disabled={pending} aria-busy={pending}>
{pending ? 'PROCESSING…' : `CONFIRM ${actionLabel} →`}
</button>
</div>
{/snippet}
</PendingForm>
{/if}
</div>
</div>
{/if}
<style>
.community-tabs{height:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0 0 8px;border:1px solid var(--shd-border);background:#0d1011}
.community-tabs a{min-height:46px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;padding:8px 11px;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;color:var(--shd-text-muted)}.community-tabs a:first-child{border-left:0}.community-tabs a span,.community-tabs a b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.community-tabs a b{color:var(--shd-text-dim)}.community-tabs a:hover,.community-tabs a:focus-visible{background:var(--shd-surface-active);color:var(--shd-text)}.community-tabs a.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.community-tabs a.active b{color:var(--shd-orange)}
.community-filter{grid-template-columns:minmax(0,1fr) auto}.community-registry{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:7px}.community-registry>article{min-width:0;display:grid;grid-template-rows:auto auto minmax(100px,1fr) auto;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface)}.community-registry>article.moderated{border-left-color:var(--shd-danger)}
.community-registry article>header{min-height:55px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.community-registry article>header :global(.agent-identity){flex:1}.community-state{display:grid;justify-items:end;gap:4px}.community-state span,.community-state b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-state span{color:var(--shd-text-muted)}.community-state b{padding:3px 5px;border:1px solid var(--shd-danger);color:var(--shd-danger)}.community-state b.visible{border-color:var(--shd-success);color:var(--shd-success)}
.community-target{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 10px;border-bottom:1px solid var(--shd-border-soft);background:#0d1011}.community-target span,.community-target a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-target span{color:var(--shd-text-muted)}.community-target a{overflow:hidden;color:var(--shd-orange);text-overflow:ellipsis;white-space:nowrap}.community-markdown-preview{max-height:150px;overflow:hidden;padding:11px;position:relative}.community-markdown-preview:after{position:absolute;right:0;bottom:0;left:0;height:25px;background:linear-gradient(transparent,var(--shd-surface));content:"";pointer-events:none}
.moderation-evidence{display:grid;gap:4px;margin:0 10px 9px;padding:8px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-danger);background:#1b1111}.moderation-evidence span{color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.moderation-evidence p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.community-registry article>footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border-top:1px solid var(--shd-border);background:var(--shd-surface-raised)}.community-registry article>footer>span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-registry article>footer button{min-height:36px;padding:0 10px}
.community-review-modal{width:min(900px,calc(100vw - 28px));max-height:calc(100vh - 28px);overflow:auto}.community-review-identity{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px;border-bottom:1px solid var(--shd-border)}.community-review-identity :global(.agent-identity){flex:1}.community-review-identity>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.review-target{border-bottom:1px solid var(--shd-border)}.community-review-content{max-height:330px;overflow:auto;padding:14px;border-bottom:1px solid var(--shd-border);background:#0d1011}.community-review-content :global(.markdown-content){max-width:none}.moderation-reason{margin:10px}.moderation-reason textarea{min-height:90px}.community-final-confirm{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,.65fr);gap:9px;margin:10px;padding:10px;border:1px solid var(--shd-orange);background:#1b160d}.community-final-confirm>div{display:grid;gap:4px}.community-final-confirm span,.community-final-confirm dt{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.community-final-confirm p,.community-final-confirm dd{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.community-final-confirm dl{display:grid;align-content:start;gap:5px;margin:0;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.community-review-modal>.moderation-warning,.community-review-modal>.form-actions,.community-review-modal :global(form > .form-actions),.community-review-modal :global(form > .error){margin-right:10px;margin-left:10px}
@media(max-width:900px){.community-registry{grid-template-columns:1fr}}
@media(max-width:650px){.community-tabs{grid-template-columns:1fr}.community-tabs a{min-height:52px;border-top:1px solid var(--shd-border);border-left:0}.community-tabs a:first-child{border-top:0}.community-filter{grid-template-columns:1fr}.community-filter button{min-height:44px}.community-registry article>header,.community-registry article>footer,.community-review-identity,.community-target{align-items:stretch;flex-direction:column}.community-state{justify-items:start}.community-target a{white-space:normal}.community-registry article>footer button{width:100%;min-height:44px}.community-final-confirm{grid-template-columns:1fr}.community-review-modal>.form-actions,.community-review-modal :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}.community-review-modal button{min-height:44px}}
@media(prefers-reduced-motion:reduce){.community-tabs a,.community-registry button{transition:none}}
</style>
+2 -2
View File
@@ -26,7 +26,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
deleted_accounts.deleted_at, deleted_accounts.deleted_at,
(SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at, (SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at,
(SELECT COUNT(*) FROM builds WHERE owner_id=users.id) AS build_count, (SELECT COUNT(*) FROM builds WHERE owner_id=users.id) AS build_count,
(SELECT COUNT(*) FROM comments WHERE user_id=users.id) AS comment_count (SELECT COUNT(*) FROM comments WHERE author_id=users.id) AS comment_count
FROM users LEFT JOIN user_status ON user_status.user_id=users.id FROM users LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
@@ -76,7 +76,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at, (SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at,
(SELECT provider FROM login_events WHERE user_id=users.id AND succeeded=1 ORDER BY created_at DESC LIMIT 1) AS last_login_provider, (SELECT provider FROM login_events WHERE user_id=users.id AND succeeded=1 ORDER BY created_at DESC LIMIT 1) AS last_login_provider,
(SELECT COUNT(*) FROM builds WHERE owner_id=users.id) AS build_count, (SELECT COUNT(*) FROM builds WHERE owner_id=users.id) AS build_count,
(SELECT COUNT(*) FROM comments WHERE user_id=users.id) AS comment_count (SELECT COUNT(*) FROM comments WHERE author_id=users.id) AS comment_count
FROM users LEFT JOIN user_status ON user_status.user_id=users.id FROM users LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
+14 -4
View File
@@ -30,12 +30,17 @@ export const load: PageServerLoad = async ({ cookies, params, url }) => {
const loginArgs: unknown[] = [params.id, loginPattern, loginPattern, loginPattern, loginPattern, loginPattern, ...(loginStatus ? [loginStatus === 'success' ? 1 : 0] : [])]; const loginArgs: unknown[] = [params.id, loginPattern, loginPattern, loginPattern, loginPattern, loginPattern, ...(loginStatus ? [loginStatus === 'success' ? 1 : 0] : [])];
const buildWhere = `owner_id=? AND (title LIKE ? ESCAPE '\\' COLLATE NOCASE OR slug LIKE ? ESCAPE '\\' COLLATE NOCASE)${buildVisibility ? ' AND visibility=?' : ''}`; const buildWhere = `owner_id=? AND (title LIKE ? ESCAPE '\\' COLLATE NOCASE OR slug LIKE ? ESCAPE '\\' COLLATE NOCASE)${buildVisibility ? ' AND visibility=?' : ''}`;
const buildArgs: unknown[] = [params.id, pattern(buildQuery), pattern(buildQuery), ...(buildVisibility ? [buildVisibility] : [])]; const buildArgs: unknown[] = [params.id, pattern(buildQuery), pattern(buildQuery), ...(buildVisibility ? [buildVisibility] : [])];
const commentWhere = `comments.user_id=? AND (comments.body LIKE ? ESCAPE '\\' COLLATE NOCASE OR builds.title LIKE ? ESCAPE '\\' COLLATE NOCASE)`; const commentWhere = `comments.author_id=? AND (comments.body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
OR COALESCE(builds.title,target_profiles.display_name,'Agent profile') LIKE ? ESCAPE '\\' COLLATE NOCASE)`;
const commentArgs: unknown[] = [params.id, pattern(commentQuery), pattern(commentQuery)]; const commentArgs: unknown[] = [params.id, pattern(commentQuery), pattern(commentQuery)];
const [loginCount, buildCount, commentCount] = await Promise.all([ const [loginCount, buildCount, commentCount] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS count FROM login_events WHERE ${loginWhere}`)).get(...loginArgs), (await database.prepare(`SELECT COUNT(*) AS count FROM login_events WHERE ${loginWhere}`)).get(...loginArgs),
(await database.prepare(`SELECT COUNT(*) AS count FROM builds WHERE ${buildWhere}`)).get(...buildArgs), (await database.prepare(`SELECT COUNT(*) AS count FROM builds WHERE ${buildWhere}`)).get(...buildArgs),
(await database.prepare(`SELECT COUNT(*) AS count FROM comments JOIN builds ON builds.id=comments.build_id WHERE ${commentWhere}`)).get(...commentArgs) (await database.prepare(`SELECT COUNT(*) AS count FROM comments
JOIN comment_threads ON comment_threads.id=comments.thread_id
LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id
LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_id
WHERE ${commentWhere}`)).get(...commentArgs)
]); ]);
const loginPagination = getPagination(url, Number(loginCount?.count ?? 0), 'login'); const loginPagination = getPagination(url, Number(loginCount?.count ?? 0), 'login');
const buildPagination = getPagination(url, Number(buildCount?.count ?? 0), 'build'); const buildPagination = getPagination(url, Number(buildCount?.count ?? 0), 'build');
@@ -67,8 +72,13 @@ export const load: PageServerLoad = async ({ cookies, params, url }) => {
FROM login_events LEFT JOIN sessions ON sessions.id=login_events.session_id FROM login_events LEFT JOIN sessions ON sessions.id=login_events.session_id
WHERE ${loginWhere} ORDER BY login_events.created_at DESC LIMIT ? OFFSET ?`)).all(...loginArgs, loginPagination.perPage, loginPagination.offset), WHERE ${loginWhere} ORDER BY login_events.created_at DESC LIMIT ? OFFSET ?`)).all(...loginArgs, loginPagination.perPage, loginPagination.offset),
(await database.prepare(`SELECT id,title,slug,visibility,updated_at FROM builds WHERE ${buildWhere} ORDER BY updated_at DESC LIMIT ? OFFSET ?`)).all(...buildArgs, buildPagination.perPage, buildPagination.offset), (await database.prepare(`SELECT id,title,slug,visibility,updated_at FROM builds WHERE ${buildWhere} ORDER BY updated_at DESC LIMIT ? OFFSET ?`)).all(...buildArgs, buildPagination.perPage, buildPagination.offset),
(await database.prepare(`SELECT comments.id,comments.body,comments.created_at,builds.title,builds.slug (await database.prepare(`SELECT comments.id,comments.body_markdown AS body,comments.created_at,
FROM comments JOIN builds ON builds.id=comments.build_id WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`)).all(...commentArgs, commentPagination.perPage, commentPagination.offset) COALESCE(builds.title,target_profiles.display_name,'Agent profile') AS title,builds.slug
FROM comments
JOIN comment_threads ON comment_threads.id=comments.thread_id
LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id
LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_id
WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`)).all(...commentArgs, commentPagination.perPage, commentPagination.offset)
,getAuthenticationMethods(database, params.id) ,getAuthenticationMethods(database, params.id)
,(await database.prepare('SELECT email,verified_at,updated_at FROM user_emails WHERE user_id=?')).all(params.id) ,(await database.prepare('SELECT email,verified_at,updated_at FROM user_emails WHERE user_id=?')).all(params.id)
,(await database.prepare('SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC')).all(params.id) ,(await database.prepare('SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC')).all(params.id)
+28 -6
View File
@@ -70,6 +70,12 @@
under a “Deleted Agent” attribution to preserve community references. You may not under a “Deleted Agent” attribution to preserve community references. You may not
upload unlawful, infringing, malicious, or privacy-invasive content. upload unlawful, infringing, malicious, or privacy-invasive content.
</p> </p>
<p>
Community Oversight may hide or remove profile biographies, profile messages,
avatars, builds, guides, comments, or other user content that violates these terms
or presents a safety, privacy, legal, or abuse risk. Removing content does not
remove related moderation, audit, or legal records when SHD.build must retain them.
</p>
</div> </div>
</section> </section>
<section id="conduct"> <section id="conduct">
@@ -93,9 +99,23 @@
We may process internal account identifiers, private login username, public display We may process internal account identifiers, private login username, public display
name, password hash, verified email, linked-provider identifiers and profile name, password hash, verified email, linked-provider identifiers and profile
details, passkey public credentials, avatar, roles, permissions, builds, guides, details, passkey public credentials, avatar, roles, permissions, builds, guides,
favorites, ratings, comments, notification messages, read state, and delivery public biography, profile visibility, public authored-content summaries, profile
preferences. SHD.build stores passwords only as salted Argon2id hashes. Private messages, favorites, ratings, comments, notification messages, read state, and
passkey material remains on your authenticator. delivery preferences. SHD.build stores passwords only as salted Argon2id hashes.
Private passkey material remains on your authenticator.
</p>
<p>
A public Agent profile displays your public display name, avatar, biography, public
builds, published guides, recent public discussion, and profile messages. Other
signed-in users may post messages on a public profile when their account has
permission to do so. Profile messages display the author's public identity and may
be removed by their author or Community Oversight.
</p>
<p>
You may hide your Agent profile from public discovery and prevent new profile
messages. Hiding a profile does not remove your display name or avatar from public
builds, guides, or comments you authored. It also does not make existing public
content anonymous.
</p> </p>
<p> <p>
For login security and support, we may record the time, success status, For login security and support, we may record the time, success status,
@@ -198,9 +218,11 @@
When you delete your account, SHD.build removes active sessions, credentials, When you delete your account, SHD.build removes active sessions, credentials,
verified email, provider profiles, passkeys, permissions, and uploaded avatar. You verified email, provider profiles, passkeys, permissions, and uploaded avatar. You
may also select an immediate purge of retained login-event history during may also select an immediate purge of retained login-event history during
self-deletion. SHD.build may keep public contributions under a “Deleted Agent” self-deletion. Account deletion removes the public biography and makes the Agent
attribution. SHD.build may retain backups and records for a limited period where profile unavailable. SHD.build may keep public builds, guides, and comments under
applicable when fraud prevention, dispute resolution, or law requires them. a “Deleted Agent” attribution so community references remain intact. SHD.build may
retain backups and records for a limited period when fraud prevention, dispute
resolution, or law requires them.
</p> </p>
</div> </div>
</section> </section>