From cef3e6e78b9cdbcd32f599a8ac827fd15e39af65 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 18:14:17 -0400 Subject: [PATCH] Refine ISAC selection controls and icons --- docs/DESIGN_SYSTEM.md | 52 +++++++++++++++--- package.json | 1 + scripts/trim-static-icons.mjs | 40 ++++++++++++++ src/app.css | 6 +- src/lib/components/SegmentedChoice.svelte | 9 ++- src/lib/game-icons.ts | 8 ++- src/routes/admin/attributes/+page.svelte | 7 ++- src/routes/admin/audit/+page.svelte | 13 ++++- src/routes/admin/data-moderation/+page.svelte | 4 +- src/routes/admin/gear/+page.svelte | 26 +++++---- src/routes/admin/talents/+page.svelte | 4 +- src/routes/admin/users/[id]/+page.svelte | 10 +++- src/routes/admin/weapons/+page.svelte | 14 +++-- static/backpack.png | Bin 0 -> 12395 bytes static/blast.png | Bin 24634 -> 3791 bytes static/body_armor.png | Bin 0 -> 10188 bytes static/bulwark.png | Bin 39336 -> 15155 bytes static/combat_medic.png | Bin 60443 -> 44076 bytes static/demolitionist.png | Bin 59365 -> 49070 bytes static/engineering.png | Bin 5469 -> 4258 bytes static/firepower.png | Bin 26074 -> 4296 bytes static/gloves.png | Bin 0 -> 9850 bytes static/holster.png | Bin 0 -> 9862 bytes static/knee_pads.png | Bin 0 -> 14466 bytes static/legendary.png | Bin 31203 -> 9223 bytes static/mask.png | Bin 0 -> 9742 bytes static/pierce.png | Bin 24776 -> 3846 bytes static/shd_ticket.png | Bin 29166 -> 7561 bytes static/shredding.png | Bin 25734 -> 4185 bytes static/tech_operator.png | Bin 43702 -> 22701 bytes static/toughness.png | Bin 4004 -> 2854 bytes static/vanguard.png | Bin 42573 -> 24172 bytes 32 files changed, 159 insertions(+), 35 deletions(-) create mode 100644 scripts/trim-static-icons.mjs create mode 100644 static/backpack.png create mode 100644 static/body_armor.png create mode 100644 static/gloves.png create mode 100644 static/holster.png create mode 100644 static/knee_pads.png create mode 100644 static/mask.png diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 1ba0f66..879eb1b 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -755,13 +755,29 @@ rail and filled square indicator; deselected segments remain readable and available. The channel rail collapses from six to three and then two columns as space narrows, with minimum 44-pixel mobile targets. -Use the shared `SegmentedChoice` component for compact, finite option sets. -Radio mode represents one required choice; checkbox mode represents additive -independent choices. Prefer this visible segmented treatment over a dropdown -when the complete option set is small enough to scan comfortably. Dropdowns -remain appropriate for long, dynamic, or search-oriented lists. Both modes -share hover, pressed, focus-visible, selected, disabled, responsive, and -44-pixel mobile-target behavior so feature modules do not reimplement it. +Use the shared `SegmentedChoice` component for every static, scannable ISAC +choice set containing two through six options. Radio mode represents one +required choice; checkbox mode represents additive independent choices. This +rule applies equally to editors and registry filters: do not introduce a native +dropdown merely to save vertical space. Use the shared `SearchableAssignment` +component in single or multiple mode when there are seven or more options, when +the options come from a growing data registry, or when search materially helps +recognition. Compact mode is the standard treatment for filter bars and dense +repeated rows; full mode is used for primary relationships. + +Native selects are not a default ISAC control. Retain one only when a documented +browser-native behavior cannot be represented accessibly by either shared +component. Such an exception must be called out in this design system in the +same change. Both shared choice patterns expose explicit selection, hover, +pressed, focus-visible, disabled, empty/all, responsive, and 44-pixel +mobile-target states so modules do not reimplement them. + +`SegmentedChoice` supports two restrained density variants. Icon-only segments +may omit visible text when a canonical, unambiguous game icon exists; they must +retain an accessible label and hover title. Text-only segments may omit the +default square indicator when the selected background, orange text, and lower +rail already communicate state clearly. Do not remove both visible text and an +icon from the same option. ### Skill Chip data console @@ -810,6 +826,28 @@ the filtered result registry opens as an anchored overlay. The compact overlay must layer above adjacent rows, retain pending-record labels, and collapse to one column on narrow screens. +Gear identity editing uses the shared segmented radio treatment for both Gear +slot and item tier. Show all six slots together and all three tiers together so +contributors can verify classification without opening a menu. The record name +remains the dominant text field; at medium widths it occupies a full row above +the two selectors. On narrow screens the slot selector becomes a three-by-two +grid, both selectors use at least 44-pixel targets, and changing slot clears +incompatible Talent relationships exactly as the former selector did. Gear +slot segments use icon-only controls with accessible slot names; registry labels +retain icon and text for rapid table scanning. Tier segments use the compact +game notation `T0`, `T1`, and `T2`, omit the redundant square indicator, and +rely on the selected rail and color; do not expand these to “Tier 0” inside +dense segmented controls. On desktop, icon-only Gear slot segments are square +32-pixel cells so their classification rail stays compact and the adjacent +record-name input receives the dominant width. Increase those cells to square +44-pixel targets at mobile sizes without reintroducing visible slot text. + +Root-level static PNGs are interface icons and use content-fitted transparent +bounds. Run `npm run assets:trim-icons` after adding or replacing them. The +normalizer trims fully transparent padding, restores a two-pixel transparent +safety margin so antialiased edges never touch the canvas, preserves RGBA PNG +output, and leaves portrait/avatar and SVG asset classes untouched. + ### Shared equipment profiles Configuration inherited by every item of a finite equipment type belongs in a diff --git a/package.json b/package.json index 367ff41..3db271b 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs", "notifications:digest:production": "dotenvx run -f .env.production -- node scripts/notification-digest.mjs", "notifications:seed-test": "dotenvx run -f .env.development -- node scripts/seed-test-notifications.mjs", + "assets:trim-icons": "node scripts/trim-static-icons.mjs", "env:encrypt": "node scripts/env-files.mjs encrypt", "env:decrypt": "node scripts/env-files.mjs decrypt", "env:encrypt:development": "dotenvx encrypt -f .env.development", diff --git a/scripts/trim-static-icons.mjs b/scripts/trim-static-icons.mjs new file mode 100644 index 0000000..0c8b75a --- /dev/null +++ b/scripts/trim-static-icons.mjs @@ -0,0 +1,40 @@ +import sharp from 'sharp'; +import { readdir, rename, unlink } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +const iconDirectory = resolve('./static'); +const iconFiles = (await readdir(iconDirectory)) + .filter((file) => file.toLocaleLowerCase().endsWith('.png')) + .sort((left, right) => left.localeCompare(right)); + +for (const file of iconFiles) { + const source = join(iconDirectory, file); + const temporary = join(iconDirectory, `.${file}.trimmed`); + const before = await sharp(source).metadata(); + + try { + const result = await sharp(source) + .trim({ background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .extend({ + top: 2, + right: 2, + bottom: 2, + left: 2, + background: { r: 0, g: 0, b: 0, alpha: 0 } + }) + .png({ compressionLevel: 9, adaptiveFiltering: true }) + .toFile(temporary); + + if (!result.width || !result.height || result.channels !== 4) { + throw new Error(`${file} did not produce a valid transparent RGBA icon.`); + } + + await rename(temporary, source); + console.log(`${file}: ${before.width}x${before.height} -> ${result.width}x${result.height}`); + } catch (error) { + await unlink(temporary).catch(() => {}); + throw error; + } +} + +console.log(`Trimmed ${iconFiles.length} static PNG icons with a 2px transparent safety margin.`); diff --git a/src/app.css b/src/app.css index f9e7450..a558b12 100644 --- a/src/app.css +++ b/src/app.css @@ -29,6 +29,7 @@ --shd-transition:transform .12s ease,filter .18s ease,opacity .18s ease,box-shadow .18s ease; } *{box-sizing:border-box} html{scroll-behavior:smooth} body{margin:0;background:var(--shd-bg);color:var(--shd-text);font-family:Arial,Helvetica,sans-serif} button,input{font:inherit} a,a:hover,a:focus,a:focus-visible,a:active,a:visited{text-decoration:none} + .sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important} @media(min-width:2200px){:root{--shd-display-scale:1.15}html{zoom:var(--shd-display-scale)}} @media(min-width:2800px){:root{--shd-display-scale:1.3}} @media(min-width:3400px){:root{--shd-display-scale:1.5}} @@ -146,7 +147,7 @@ .registry-icon-rail{display:flex!important;align-items:center;gap:4px}.registry-icon-rail>span{width:30px;height:30px;display:grid;place-items:center;border:1px solid #353b3c;background:#0d1011}.registry-icon-rail img{width:26px;height:26px;object-fit:contain}.registry-icon-rail i{color:var(--shd-orange);font-size:7px;font-style:normal;font-weight:900;letter-spacing:.3px}.registry-icon-rail>span:hover{border-color:#8a672b;background:#171b1c} .gear-fixed-augment{min-height:70px;display:grid;align-content:center;gap:4px;margin-top:7px;padding:8px;border:1px solid #34473a;border-left:2px solid var(--shd-success);background:#101713}.gear-fixed-augment span,.gear-fixed-augment small{color:#718078;font-size:6px;letter-spacing:.6px}.gear-fixed-augment b{color:var(--shd-success);font-size:17px} .gear-tier-costs{margin:8px 0 0;padding:7px;border:1px solid #303536}.gear-tier-costs legend{padding:0 5px;color:#697174;font-size:6px;font-weight:900;letter-spacing:.7px}.gear-tier-costs>div{display:grid;grid-template-columns:repeat(3,1fr);gap:5px}.gear-tier-costs .field{margin:0}.gear-augment-profile-grid>button>div em{white-space:nowrap}@media(max-width:560px){.gear-tier-costs>div{grid-template-columns:1fr 1fr 1fr}} -.gear-identity{grid-template-columns:2fr 1fr .65fr} +.gear-identity{grid-template-columns:minmax(360px,1fr) 200px minmax(150px,180px);align-items:end}.gear-identity>.field,.gear-identity>.segmented-choice{margin:0}.gear-identity .segmented-choice legend{font-size:7px}.gear-identity .segmented-choice>div{margin-top:5px}.gear-identity .segmented-choice:first-of-type>div{grid-template-columns:repeat(6,32px);width:max-content}.gear-identity .segmented-choice:first-of-type label,.gear-identity .segmented-choice:first-of-type span{width:32px}@media(max-width:900px){.gear-identity{grid-template-columns:1fr 1fr}.gear-identity>.field{grid-column:1/3}}@media(max-width:700px){.gear-identity{grid-template-columns:1fr}.gear-identity>.field{grid-column:auto}.gear-identity .segmented-choice>div{height:auto}.gear-identity .segmented-choice span{min-height:44px}.gear-identity .segmented-choice:first-of-type>div{grid-template-columns:repeat(3,44px)}.gear-identity .segmented-choice:first-of-type label,.gear-identity .segmented-choice:first-of-type span{width:44px}} .builder-field-range{float:right;color:#5f6769;font-size:6px}.builder-scaling-note{margin:7px 0 0;padding:7px 9px;border-left:2px solid #667174;background:#101415;color:#727a7c;font-size:7px;line-height:1.45} /* Builder workbench */ @@ -163,6 +164,9 @@ .system-message-body:not(.expanded)>p,.system-message-body:not(.expanded) .system-message-detail>p,.notification-body-preview{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:2}.notification-actions{display:flex;align-items:center;gap:7px}.notification-actions .notification-response-action{min-height:28px;display:inline-flex;align-items:center;padding:0 10px;border:1px solid #6b5329;background:#1b160d;color:var(--shd-orange);transition:var(--shd-transition)}.notification-actions .notification-response-action:hover{border-color:var(--shd-orange);background:#241b0d;color:#fff}.notification-actions .notification-response-action:active{transform:translateY(1px);filter:brightness(.9)}.notification-actions .notification-response-action:focus-visible{outline:2px solid var(--shd-orange);outline-offset:2px}.profile-anchor{position:relative;top:calc(-1 * var(--shd-header-height));visibility:hidden}@media(max-width:700px){.notification-actions{align-items:stretch;flex-direction:column}.notification-actions .notification-response-action{min-height:44px;justify-content:center}} .notification-filter-console{flex:1;display:grid!important;grid-template-columns:minmax(520px,1fr) minmax(190px,.34fr);align-items:end!important;gap:8px!important}.notification-filter-console>input{display:none}.notification-filter-group{min-width:0;margin:0;padding:0;border:0}.notification-filter-group legend{margin-bottom:5px;color:var(--shd-text-muted);font-size:6px;font-weight:900;letter-spacing:1px}.notification-filter-group>div{height:40px;display:grid;grid-template-columns:repeat(6,minmax(0,1fr));border:1px solid #363b3c;background:#0d0f10}.notification-filter-group label{min-width:0;border-left:1px solid var(--shd-border);cursor:pointer}.notification-filter-group label:first-child{border-left:0}.notification-filter-group input{position:absolute;opacity:0;pointer-events:none}.notification-filter-group span{height:100%;display:flex;align-items:center;justify-content:center;gap:5px;color:#777f81;transition:var(--shd-transition)}.notification-filter-group span i{width:5px;height:5px;border:1px solid currentColor;background:transparent}.notification-filter-group b{overflow:hidden;text-overflow:ellipsis;color:inherit;font-size:6px;letter-spacing:.6px;white-space:nowrap}.notification-filter-group label:hover span{background:#171b1c;color:#c4c8c9}.notification-filter-group label:active span{transform:translateY(1px);filter:brightness(.88)}.notification-filter-group input:checked+span{background:#171b1c;color:var(--shd-orange);box-shadow:inset 0 -2px currentColor}.notification-filter-group input:checked+span i{background:currentColor}.notification-filter-group input:focus-visible+span{outline:2px solid var(--shd-orange);outline-offset:2px}.state-filter-group>div{grid-template-columns:repeat(2,minmax(0,1fr))}@media(max-width:900px){.notification-filter-console{grid-template-columns:1fr}.notification-filter-group>div{height:auto;grid-template-columns:repeat(3,1fr)}.notification-filter-group span{min-height:44px}.state-filter-group>div{grid-template-columns:repeat(2,1fr)}}@media(max-width:560px){.notification-filter-group>div{grid-template-columns:repeat(2,1fr)}} .segmented-choice{min-width:0;margin:0;padding:0;border:0}.segmented-choice legend{margin-bottom:5px;color:var(--shd-text-muted);font-size:6px;font-weight:900;letter-spacing:1px}.segmented-choice>div{height:32px;display:grid;grid-template-columns:repeat(var(--segmented-columns),minmax(0,1fr));border:1px solid #363b3c;background:#0d0f10}.segmented-choice label{min-width:0;border-left:1px solid var(--shd-border);cursor:pointer}.segmented-choice label:first-child{border-left:0}.segmented-choice input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}.segmented-choice span{height:100%;display:flex;align-items:center;justify-content:center;gap:5px;color:#777f81;transition:var(--shd-transition)}.segmented-choice span i{width:5px;height:5px;border:1px solid currentColor;background:transparent}.segmented-choice .game-icon{width:18px;height:18px;opacity:.55}.segmented-choice b{overflow:hidden;color:inherit;font-size:6px;letter-spacing:.6px;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}.segmented-choice label:hover span{background:#171b1c;color:#c4c8c9}.segmented-choice label:active span{transform:translateY(1px);filter:brightness(.88)}.segmented-choice input:checked+span{background:#171b1c;color:var(--shd-orange);box-shadow:inset 0 -2px currentColor}.segmented-choice input:checked+span i{background:currentColor}.segmented-choice input:checked+span .game-icon{opacity:1}.segmented-choice input:focus-visible+span{outline:2px solid var(--shd-orange);outline-offset:2px}.segmented-choice input:disabled+span{opacity:.45;cursor:not-allowed}.protocol-classification-stack .segmented-choice legend{font-size:8px}.protocol-classification-stack .segmented-choice>div{margin-top:5px}.protocol-classification-stack .quality-standard i,.protocol-classification-stack .quality-specialized i,.protocol-classification-stack .quality-superior i,.protocol-classification-stack .quality-highend i{background:currentColor} +.segmented-choice span.icon-only{gap:0}.segmented-choice span.icon-only .game-icon{width:24px;height:24px} +.dashboard-editor .form-grid>.segmented-choice{align-self:end;margin:8px 0}.dashboard-editor .form-grid>.segmented-choice legend,.talent-identity .segmented-choice legend,.weapon-identity-grid .segmented-choice legend{font-size:8px}.talent-identity{display:grid;grid-template-columns:minmax(260px,1fr) minmax(430px,1.5fr)!important;align-items:end;gap:9px}.talent-identity>.field{margin:0}.weapon-identity-grid{grid-template-columns:minmax(230px,1.1fr) minmax(390px,1.75fr) minmax(180px,.7fr) minmax(220px,.9fr) minmax(145px,.6fr);align-items:end}.weapon-identity-grid>.field,.weapon-identity-grid>.segmented-choice{margin:0}.history-filter{align-items:end;grid-template-columns:minmax(180px,1fr) minmax(250px,.9fr) 80px}.history-filter .segmented-choice>div{height:34px}.audit-filter>.assignment-picker.compact{height:40px}.moderation-filter{grid-template-columns:minmax(220px,1fr) minmax(280px,.9fr) auto}@media(max-width:1200px){.weapon-identity-grid{grid-template-columns:1fr 1fr}.weapon-identity-grid>.weapon-name,.weapon-identity-grid>.segmented-choice:first-of-type{grid-column:1/3}}@media(max-width:700px){.talent-identity,.weapon-identity-grid{grid-template-columns:1fr!important}.weapon-identity-grid>.weapon-name,.weapon-identity-grid>.segmented-choice:first-of-type{grid-column:auto}.talent-identity .segmented-choice>div,.weapon-identity-grid .segmented-choice>div,.dashboard-editor .form-grid>.segmented-choice>div{height:auto}.talent-identity .segmented-choice span,.weapon-identity-grid .segmented-choice span,.dashboard-editor .form-grid>.segmented-choice span{min-height:44px}.talent-identity .segmented-choice>div{grid-template-columns:repeat(2,1fr)}.weapon-identity-grid>.segmented-choice:first-of-type>div{grid-template-columns:repeat(3,1fr)}}@media(max-width:650px){.history-filter{grid-template-columns:1fr}.history-filter .segmented-choice>div{height:auto}.history-filter .segmented-choice span{min-height:44px}.moderation-filter{grid-template-columns:1fr}} +.gear-slot-label{display:flex!important;align-items:center;gap:5px}.gear-slot-label .game-icon{width:20px;height:20px} .notification-filter-console{min-width:0;grid-template-columns:minmax(0,3fr) minmax(180px,1fr)}.notification-filter-console .segmented-choice>div{height:40px}@media(max-width:900px){.notification-toolbar .notification-filter-console{grid-template-columns:1fr}.notification-filter-console .segmented-choice>div{height:auto;grid-template-columns:repeat(3,1fr)}.notification-filter-console .segmented-choice span{min-height:44px}.notification-filter-console .segmented-choice:last-child>div{grid-template-columns:repeat(2,1fr)}}@media(max-width:560px){.notification-filter-console .segmented-choice>div{grid-template-columns:repeat(2,1fr)}} .skill-chip-editor{border-top-color:var(--quality-superior)}.skill-chip-identity{display:grid;grid-template-columns:var(--isac-icon-editor-width) minmax(360px,1fr) minmax(220px,.55fr);align-items:start;gap:10px}.skill-chip-identity>.field{margin:0}.quality-scope{min-height:62px;display:grid;align-content:center;gap:5px;padding:7px 10px;border:1px solid var(--shd-border);background:#0d1011}.quality-scope>span{color:var(--shd-text-muted);font-size:6px;font-weight:900;letter-spacing:1px}.quality-scope>div{display:flex;gap:7px}.quality-scope b{padding:4px 6px;border:1px solid currentColor;font-size:6px;letter-spacing:.7px}.quality-scope small{color:#626a6c;font-size:6px}.skill-chip-bonus,.skill-chip-talent{margin-top:10px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.skill-chip-bonus{border-left:2px solid var(--quality-superior)}.skill-chip-bonus-grid{display:grid;grid-template-columns:minmax(360px,1fr) minmax(260px,.65fr);gap:8px;margin-top:7px}.skill-chip-range-fields{display:grid;grid-template-columns:1fr 1fr;gap:7px}.skill-chip-range-fields .field{margin:0}.skill-chip-pools{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:10px}.skill-chip-pools>div{min-width:0}.skill-chip-talent{border-left:2px solid var(--quality-high-end)}.skill-chip-talent-identity{display:grid;grid-template-columns:.7fr 1.3fr;gap:8px}.skill-chip-talent-identity textarea{min-height:66px}.skill-chip-preview{display:grid;grid-template-columns:minmax(360px,.9fr) minmax(260px,1.1fr);align-items:end;gap:8px;margin:4px 0 10px}.skill-chip-preview>div{min-height:42px;display:grid;align-content:center;gap:5px;padding:7px 10px;border:1px solid var(--shd-border);background:#111415}.skill-chip-preview>div span{color:var(--quality-high-end);font-size:6px;font-weight:900;letter-spacing:.8px}.skill-chip-preview p{margin:0;color:#c4c9ca;font:10px/1.45 Georgia,serif}.skill-chip-talent-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px}.skill-chip-effects{display:grid;border:1px solid var(--shd-border)}.skill-chip-effects>div{display:grid;grid-template-columns:27px minmax(140px,1fr) 75px 75px 27px;align-items:center;gap:5px;padding:5px;border-top:1px solid #282e2f}.skill-chip-effects>div:first-child{border-top:0}.skill-chip-effects strong{display:grid;place-items:center;height:100%;background:#171b1c;color:var(--shd-orange);font-size:7px}.skill-chip-effects select,.skill-chip-effects input{min-width:0;margin:0;padding:7px;font-size:8px}.skill-chip-effects button{height:28px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.skill-chip-effects button:disabled{opacity:.3;cursor:not-allowed}.skill-chip-row{min-width:1080px;grid-template-columns:1.15fr .45fr 1.1fr 1.1fr .85fr .5fr .3fr}.skill-chip-quality-pair{display:flex;gap:4px}.skill-chip-quality-pair b{padding:3px 4px;border:1px solid currentColor;font-size:5px}.skill-chip-row>div:nth-child(4){display:grid;gap:3px}.skill-chip-row>div:nth-child(4) small{color:#626a6c;font-size:6px}@media(max-width:1050px){.skill-chip-identity{grid-template-columns:var(--isac-icon-editor-width) 1fr}.quality-scope{grid-column:1/3}.skill-chip-pools,.skill-chip-talent-grid,.skill-chip-preview{grid-template-columns:1fr}}@media(max-width:700px){.skill-chip-identity,.skill-chip-bonus-grid,.skill-chip-range-fields{grid-template-columns:1fr}.quality-scope{grid-column:auto}.skill-chip-preview .segmented-choice>div{height:auto;grid-template-columns:repeat(2,1fr)}.skill-chip-preview .segmented-choice span{min-height:44px}.skill-chip-effects>div{grid-template-columns:27px 1fr}.skill-chip-effects input,.skill-chip-effects button{grid-column:2}.skill-chip-pools{grid-template-columns:1fr}} .profile-metrics>a{display:flex;flex-direction:column;justify-content:center;padding:0 16px;border-left:1px solid var(--shd-border);color:inherit}.profile-metrics>a:hover,.profile-metrics>a:focus-visible{background:#171b1c}.profile-metrics>a strong{color:var(--shd-orange)}.profile-notifications{border-top:2px solid #4f4329}.profile-notification-list{display:grid;border:1px solid var(--shd-border)}.profile-notification-list>a{min-width:0;display:grid;grid-template-columns:145px minmax(0,1fr) 55px;align-items:center;gap:10px;padding:9px 10px;border-top:1px solid var(--shd-border);background:#0f1213;color:#aeb4b5}.profile-notification-list>a:first-child{border-top:0}.profile-notification-list>a.unread{background:#151718;box-shadow:inset 2px 0 var(--shd-orange)}.profile-notification-list>a.mandatory{box-shadow:inset 2px 0 #68a9c4}.profile-notification-list>a:hover,.profile-notification-list>a:focus-visible{background:#191d1e}.profile-notification-list>a>div,.profile-notification-list>a>span{min-width:0;display:grid;gap:3px}.profile-notification-list>a b{color:var(--shd-orange);font-size:6px}.profile-notification-list>a strong{overflow:hidden;font-size:8px;text-overflow:ellipsis;white-space:nowrap}.profile-notification-list>a small{overflow:hidden;color:#697174;font-size:6px;text-overflow:ellipsis;white-space:nowrap}.profile-notification-list>a em{justify-self:end;color:var(--shd-orange);font-size:6px;font-style:normal;font-weight:900}.profile-empty.compact{min-height:90px}@media(max-width:700px){.profile-notification-list>a{grid-template-columns:1fr auto}.profile-notification-list>a>div{grid-column:1/3}.profile-notification-list>a>span small{white-space:normal}.profile-metrics>a{min-height:55px;border-top:1px solid var(--shd-border)}} diff --git a/src/lib/components/SegmentedChoice.svelte b/src/lib/components/SegmentedChoice.svelte index 89c744d..4ce915c 100644 --- a/src/lib/components/SegmentedChoice.svelte +++ b/src/lib/components/SegmentedChoice.svelte @@ -7,6 +7,7 @@ label: string; className?: string; icon?: GameIconName; + iconOnly?: boolean; }; let { @@ -18,6 +19,7 @@ selected = $bindable([]), columns = options.length, required = false, + showIndicator = true, onchange }: { legend: string; @@ -28,6 +30,7 @@ selected?: string[]; columns?: number; required?: boolean; + showIndicator?: boolean; onchange?: (values: string[]) => void; } = $props(); @@ -55,9 +58,9 @@ {required} onchange={(event) => change(option.value, event.currentTarget.checked)} /> - - {#if option.icon}{:else}{/if} - {option.label} + + {#if option.icon}{:else if showIndicator}{/if} + {option.label} {/each} diff --git a/src/lib/game-icons.ts b/src/lib/game-icons.ts index ee7935b..ff48230 100644 --- a/src/lib/game-icons.ts +++ b/src/lib/game-icons.ts @@ -11,7 +11,13 @@ export const gameIcons = { tech_operator: { src: '/tech_operator.png', label: 'Tech Operator specialization' }, vanguard: { src: '/vanguard.png', label: 'Vanguard specialization' }, legendary: { src: '/legendary.png', label: 'Legendary difficulty' }, - shd_ticket: { src: '/shd_ticket.png', label: 'SHD ticket' } + shd_ticket: { src: '/shd_ticket.png', label: 'SHD ticket' }, + holster: { src: '/holster.png', label: 'Holster gear slot' }, + gloves: { src: '/gloves.png', label: 'Gloves gear slot' }, + backpack: { src: '/backpack.png', label: 'Backpack gear slot' }, + knee_pads: { src: '/knee_pads.png', label: 'Knee Pads gear slot' }, + body_armor: { src: '/body_armor.png', label: 'Body Armor gear slot' }, + mask: { src: '/mask.png', label: 'Mask gear slot' } } as const; export type GameIconName = keyof typeof gameIcons; diff --git a/src/routes/admin/attributes/+page.svelte b/src/routes/admin/attributes/+page.svelte index 418aa7c..00e1fab 100644 --- a/src/routes/admin/attributes/+page.svelte +++ b/src/routes/admin/attributes/+page.svelte @@ -2,9 +2,14 @@ import PendingForm from '$lib/components/PendingForm.svelte'; import RegistryPagination from '$lib/components/RegistryPagination.svelte'; import KpiFilters from '$lib/components/KpiFilters.svelte'; + import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; let { data, form } = $props(); let editing = $state(null); let handledResultToken = $state(''); + const typeOptions = ['Offensive', 'Defensive'].map((value) => ({ value, label: value })); + const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value })); + const calculationOptions = ['Additive', 'Multiplicative'].map((value) => ({ value, label: value })); + const unitOptions = [{value:'',label:'None'},{value:'%',label:'%'},{value:'s',label:'s'},{value:'Shots',label:'Shots'},{value:'m',label:'m'}]; const blank = () => ({id:'',name:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''}); const value = (input: unknown) => input ?? ''; $effect(() => { @@ -17,6 +22,6 @@ Attributes — SHD.build admin

ATTRIBUTE REGISTRY

ATTRIBUTES

Builder values, hidden calculation targets, source ranges, and caps.

-{#if editing}

{editing.id?'MODIFY RECORD':'NEW RECORD'}

{editing.id?editing.name:'ATTRIBUTE DEFINITION'}

UNIT
{#each [['','NONE'],['%','%'],['s','s'],['Shots','SHOTS'],['m','m']] as option}{/each}
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{source[1]} RANGE · OPTIONAL
{/each}
{#if form?.error}

{form.error}

{/if}
{/if} +{#if editing}

{editing.id?'MODIFY RECORD':'NEW RECORD'}

{editing.id?editing.name:'ATTRIBUTE DEFINITION'}

{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{source[1]} RANGE · OPTIONAL
{/each}
{#if form?.error}

{form.error}

{/if}
{/if}
ATTRIBUTECLASSIFICATIONCATEGORYCALCULATIONSTATE
{#each data.stats as stat}
{stat.name}{stat.unit || 'NO UNIT'}
{stat.type}{stat.category}{stat.calculation_type}{Number(stat.active)===1?'ACTIVE':'ARCHIVED'}
{/each}
diff --git a/src/routes/admin/audit/+page.svelte b/src/routes/admin/audit/+page.svelte index d35c33c..2723a87 100644 --- a/src/routes/admin/audit/+page.svelte +++ b/src/routes/admin/audit/+page.svelte @@ -2,9 +2,18 @@ import KpiFilters from '$lib/components/KpiFilters.svelte'; import PendingForm from '$lib/components/PendingForm.svelte'; import RegistryPagination from '$lib/components/RegistryPagination.svelte'; + import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; let { data, form } = $props(); let confirming = $state(null); let handledResultToken = $state(''); + const categoryOptions = $derived([ + {id:'user',name:'USER OPERATIONS · ALL'}, + {id:'access_policy',name:'ACCESS POLICY · ALL'}, + ...data.categories + .filter((item) => !['user', 'access_policy'].includes(String(item.entity_type))) + .map((item) => ({id:String(item.entity_type),name:`${String(item.entity_type).replaceAll('_',' ').toUpperCase()} · ${item.count}`})) + ]); + const actionOptions = $derived(data.actions.map((item) => ({id:String(item.action),name:`${String(item.action).toUpperCase()} · ${item.count}`}))); const date = (value: unknown) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(String(value))); const pretty = (value: unknown) => { if (!value) return 'NO SNAPSHOT'; @@ -29,8 +38,8 @@ ]}/>
- - + + diff --git a/src/routes/admin/data-moderation/+page.svelte b/src/routes/admin/data-moderation/+page.svelte index d6b5132..e0ecf8f 100644 --- a/src/routes/admin/data-moderation/+page.svelte +++ b/src/routes/admin/data-moderation/+page.svelte @@ -2,6 +2,7 @@ import PendingForm from '$lib/components/PendingForm.svelte'; import RegistryPagination from '$lib/components/RegistryPagination.svelte'; import KpiFilters from '$lib/components/KpiFilters.svelte'; + import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; let { data, form } = $props(); let reviewing = $state(null); let decision = $state<'approve' | 'correct' | 'reject'>('approve'); @@ -13,6 +14,7 @@ ); const operationLabel = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase(); const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value.toUpperCase(); + const moduleOptions = $derived(data.modules.map((module) => ({ id: String(module), name: moduleLabel(String(module)) }))); const openReview = (proposal: any) => { reviewing = proposal; decision = 'approve'; @@ -43,7 +45,7 @@ ]}/>
- + diff --git a/src/routes/admin/gear/+page.svelte b/src/routes/admin/gear/+page.svelte index fd84abd..9d9143f 100644 --- a/src/routes/admin/gear/+page.svelte +++ b/src/routes/admin/gear/+page.svelte @@ -3,11 +3,17 @@ import RegistryPagination from '$lib/components/RegistryPagination.svelte'; import KpiFilters from '$lib/components/KpiFilters.svelte'; import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; + import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; + import GameIcon from '$lib/components/GameIcon.svelte'; + import type { GameIconName } from '$lib/game-icons'; let { data, form } = $props(); - type Editor = { id: string; name: string; slot: string; tier: number; base_health_min: number; base_health_max: number; base_armor_min: number | null; base_armor_max: number | null; brandIds: string[]; talentIds: string[] }; + type Editor = { id: string; name: string; slot: string; tier: string; base_health_min: number; base_health_max: number; base_armor_min: number | null; base_armor_max: number | null; brandIds: string[]; talentIds: string[] }; const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask']; + const slotIcon = (slot: string): GameIconName => slot.toLocaleLowerCase().replaceAll(' ', '_') as GameIconName; + const slotOptions = slots.map((slot) => ({ value: slot, label: slot, icon: slotIcon(slot), iconOnly: true })); + const tierOptions = [0, 1, 2].map((tier) => ({ value: String(tier), label: `T${tier}` })); const armoredSlots = ['Gloves', 'Knee Pads', 'Body Armor']; - const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: 0, base_health_min: 0, base_health_max: 0, base_armor_min: null, base_armor_max: null, brandIds: [], talentIds: [] }); + const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: '0', base_health_min: 0, base_health_max: 0, base_armor_min: null, base_armor_max: null, brandIds: [], talentIds: [] }); let editing = $state(null); let augmentEditing = $state(null); let handledResultToken = $state(''); @@ -21,7 +27,7 @@ return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`; }; const brandNames = (ids: string[]) => ids.map((id) => data.brands.find((brand) => brand.id === id)?.name ?? 'Archived brand').join(', '); - const edit = (item: Editor) => { editing = { ...item, brandIds: [...item.brandIds], talentIds: [...item.talentIds] }; }; + const edit = (item: Editor) => { editing = { ...item, tier: String(item.tier), brandIds: [...item.brandIds], talentIds: [...item.talentIds] }; }; const gearTalents = (slot: string) => data.talents.filter((talent) => talent.category === slot); const changeSlot = (slot: string) => { if (editing) editing = { ...editing, slot, talentIds: [] }; }; const baseTargets = (slot: string) => ['Gloves', 'Knee Pads', 'Body Armor'].includes(slot) ? 'BASE HEALTH + ARMOR' : 'BASE HEALTH'; @@ -84,11 +90,11 @@
TACTICAL AUGMENT 0{index + 1}{index === 0 ? 'CUSTOM ATTRIBUTE' : 'FIXED BASE UPGRADE'}
{#if index === 0}
ATTRIBUTE
- + {:else}
{baseTargets(augmentEditing.slot)}+{augment.value}%APPLIES TO THIS ITEM'S CONFIGURED BASE VALUE
{/if} -
UNLOCK COST BY ITEM TIER
{#each [0,1,2] as tier}{/each}
+
UNLOCK COST BY ITEM TIER
{#each [0,1,2] as tier}{/each}
{/each} @@ -105,12 +111,12 @@
- - + changeSlot(values[0])} required/> +
-
HPBASE HEALTH
-
ARBASE ARMOR
+
HPBASE HEALTH
+
ARBASE ARMOR
COMPATIBLE BRAND SETSSELECT AT LEAST TWO
{editing.brandIds.length} SELECTED
@@ -129,6 +135,6 @@
GEARSLOTTIERBASE RANGESBRAND SETSSTATE
{#each data.gear as item} -
{item.name}{item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}
{item.slot}T{item.tier}HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null} · AR {item.base_armor_min}–{item.base_armor_max}{/if}
{#each compatibleBrands(item.brandIds) as brand}{#if brand.iconUrl}{brand.name}/{:else}{brand.name}{/if}{/each}
{Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}
+
{item.name}{item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}
{item.slot}T{item.tier}HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null} · AR {item.base_armor_min}–{item.base_armor_max}{/if}
{#each compatibleBrands(item.brandIds) as brand}{#if brand.iconUrl}{brand.name}/{:else}{brand.name}{/if}{/each}
{Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}
{:else}
NO GEAR REGISTEREDAdd the first gear definition after configuring at least two brand sets.
{/each}
diff --git a/src/routes/admin/talents/+page.svelte b/src/routes/admin/talents/+page.svelte index 890f510..4539423 100644 --- a/src/routes/admin/talents/+page.svelte +++ b/src/routes/admin/talents/+page.svelte @@ -4,8 +4,10 @@ import KpiFilters from '$lib/components/KpiFilters.svelte'; import IconUploadField from '$lib/components/IconUploadField.svelte'; import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; + import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; let { data, form } = $props(); type Effect = { statId: string; valueI: string; valueII: string }; + const categoryOptions = ['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].map((value) => ({ value, label: value })); let talent = $state(null), trigger = $state(null), uptime = $state(false); let handledResultToken = $state(''); let iconResetToken = $state(0); @@ -81,7 +83,7 @@

{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}

{talent.id ? talent.name : 'TALENT DEFINITION'}

-
+
TIER I PREVIEW

{preview('I') || 'Description preview'}

TIER II PREVIEW

{preview('II') || 'Description preview'}

diff --git a/src/routes/admin/users/[id]/+page.svelte b/src/routes/admin/users/[id]/+page.svelte index 7a71176..86c8dc1 100644 --- a/src/routes/admin/users/[id]/+page.svelte +++ b/src/routes/admin/users/[id]/+page.svelte @@ -1,6 +1,7 @@