diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index a0f7457..1169c11 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -1183,6 +1183,68 @@ back to a short orange text mark rather than removing the relationship. Use this pattern only for small bounded sets that remain scannable, such as the two-to-four compatible Brand Sets on Gear. Long or unbounded relationships continue to use counts, searchable inspection, or expanded detail views. +Public Gear cards use the same rail for compatible Brand Sets and available +Talents so players can identify actual options instead of reading a count. +Each icon retains its accessible name and hover title, and missing artwork uses +the shared placeholder without suppressing the relationship. Relationship +icons are buttons: activating one opens a modal quick view using the same card +facts as that record's public database category. The overlay closes through +its explicit close action, backdrop, or Escape key. Do not render an empty +Talent rail on item types that cannot equip Talents. + +### Public game-database registries + +The public Database is a player-facing reference surface, separate from the +ISAC editing workflow. Its index exposes eight durable categories: Gear, +Weapons, Weapon Mods, Gear Mods, OS Protocols, Talents, Brands, and Skill +Chips. Attributes are implementation vocabulary resolved into the facts and +effects shown on those records; do not promote Attributes to a public +top-level category. + +Each index category is a fully interactive square registry card with a live +approved-record count, concise scope statement, visible hover/focus state, and +pressed feedback. A category page uses the shared compact category rail, +live name search, pagination, and a responsive card grid. Record cards use the +same hierarchy: + +- a fixed icon cell and compact identity header; +- small textual badges for quality, tier, or exceptional state; +- bordered fact cells for primary values; +- relationship lines and tier descriptions only when applicable. + +When a record represents an ordered progression rather than independent facts, +use the stacked fact treatment. Brand Set bonuses are shown vertically in +two-, three-, then four-piece order; do not add a redundant “3/3 bonuses” +badge when all three rows are already visible. + +Record cards may end with contextual actions that connect related public +registries. Actions use the compact orange tactical-button treatment and +encode their destination filter in the URL. Brand cards provide a “find Gear” +action that opens the Gear registry with that Brand selected; the same action +must remain available in Brand quick views. + +Cards display canonical active data only. Attribute references must be +resolved to readable names, values, and units instead of exposing IDs or raw +JSON. Missing uploaded art uses the standard missing-item placeholder. +Desktop layouts use three record columns, intermediate layouts use two, and +narrow layouts use one. The eight-category rail reduces from eight to four to +two columns. Preserve keyboard focus, pressed feedback, reduced-motion +behavior, and readable textual state in every layout. + +Category-specific discovery uses the shared tactical filter matrix. Filters +apply immediately, remain encoded in the URL, reset pagination, and compose +with name search. Multiple selections inside one facet are inclusive (`OR`); +active facet groups combine restrictively (`AND`). For example, selecting two +Brands and four Talents returns Gear matching any selected Brand and any +selected Talent. Never implement multi-select facets as an implicit match-all +within the same group. + +Use segmented icon choices for bounded visual vocabularies of six or fewer, +such as Gear slots. Use the matching searchable multi-select list for growing +sets such as Brands and Talents. Always expose selected counts, a single clear +action, `aria-pressed` state, keyboard focus, and explicit text explaining the +combination rule. At intermediate width, the bounded facet spans the row above +two searchable facets; on narrow screens all facet groups stack vertically. ### Build persistence and sharing diff --git a/src/app.css b/src/app.css index 0b5e3ce..5d28cea 100644 --- a/src/app.css +++ b/src/app.css @@ -197,5 +197,14 @@ .weapon-mod-exotic-toggle>span{width:44px;min-height:42px;padding:0}.weapon-mod-exotic-toggle input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}.weapon-mod-exotic-toggle .game-icon{width:30px;height:30px}.weapon-mod-compatibility .segmented-choice>div{height:48px}.weapon-mod-compatibility .segmented-choice .game-icon{width:34px;height:34px}.weapon-mod-exotic-section{margin-top:10px;padding:9px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#0d1011}.weapon-mod-exotic-attributes{display:grid;gap:6px;margin-top:7px}.weapon-mod-exotic-attributes>div{display:grid;grid-template-columns:30px minmax(240px,1fr) 150px 150px 30px;align-items:end;gap:6px;padding:6px;border:1px solid var(--shd-border);background:#101314}.weapon-mod-exotic-attributes>div>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:var(--quality-high-end);font-size:8px}.weapon-mod-exotic-attributes label{display:grid;gap:4px;color:var(--shd-text-muted);font-size:5px;font-weight:900;letter-spacing:.5px}.weapon-mod-exotic-attributes input{height:32px;margin:0;padding:0 6px}.weapon-mod-exotic-attributes>div>button{height:32px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.weapon-mod-exotic-attributes>div>button:disabled{opacity:.3;cursor:not-allowed}@media(max-width:700px){.weapon-mod-exotic-attributes>div{grid-template-columns:30px 1fr}.weapon-mod-exotic-attributes label,.weapon-mod-exotic-attributes>div>button{grid-column:2}.weapon-mod-compatibility .segmented-choice>div{height:auto;grid-template-columns:repeat(3,1fr)}.weapon-mod-compatibility .segmented-choice span{min-height:50px}} .gear-mod-identity{display:grid;grid-template-columns:minmax(320px,1fr) minmax(560px,1.5fr);align-items:end;gap:8px}.gear-mod-identity>.field{margin:0}.gear-mod-primary-types>div{flex-wrap:wrap}.gear-mod-row{min-width:1120px;grid-template-columns:1.2fr 1fr .9fr 1fr .55fr .55fr .3fr}.gear-mod-row>span{display:flex;align-items:center;gap:6px}.gear-mod-row>span small{color:var(--shd-text-muted)}@media(max-width:900px){.gear-mod-identity{grid-template-columns:1fr}}@media(max-width:700px){.gear-mod-primary-types>div{display:grid;grid-template-columns:1fr 1fr}} .missing-item-icon{opacity:.7;filter:saturate(.75)}.icon-upload-preview .missing-item-icon{width:66px;height:66px;opacity:.62}.registry-icon-identity>.missing-item-icon,.registry-icon-rail .missing-item-icon{padding:2px;border:1px dashed #424849;background:#101314}.builder-picker .missing-item-icon,.builder-radio-grid .missing-item-icon{opacity:.66} +.database-registry-grid>a{min-height:180px;display:flex;flex-direction:column;padding:17px;border:1px solid var(--shd-border);border-top:2px solid #43494a;background:#111415;color:var(--shd-text);transition:border-color .15s ease,background .15s ease,transform .1s ease}.database-registry-grid>a:hover,.database-registry-grid>a:focus-visible{border-top-color:var(--shd-orange);background:#15191a}.database-registry-grid>a:active{transform:translateY(1px);filter:brightness(.88)}.database-registry-grid>a>span{margin-top:auto;color:#687073;font-size:6px;font-weight:900;letter-spacing:.8px} +.database-category-page{padding-top:34px}.database-category-command{display:flex;justify-content:space-between;align-items:end;gap:28px;padding:22px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:linear-gradient(115deg,#15191a,#0d1011)}.database-category-command h1{margin:8px 0 7px;font-size:clamp(30px,4vw,52px);line-height:.95}.database-category-command>div:first-child>p:last-child{max-width:720px;margin:0;color:#8d9597;font:12px/1.55 Georgia,serif}.database-category-command>div:last-child{min-width:150px;display:grid;justify-items:end;gap:3px}.database-category-command>div:last-child strong{color:var(--shd-orange);font-size:32px;line-height:1}.database-category-command>div:last-child span{color:#687073;font-size:6px;letter-spacing:1px}.database-category-command>div:last-child a{margin-top:8px;color:var(--shd-orange);font-size:7px;font-weight:900;letter-spacing:.8px} +.database-category-nav{display:grid;grid-template-columns:repeat(8,minmax(0,1fr));margin:10px 0;border:1px solid var(--shd-border);background:#0d1011}.database-category-nav>a{min-height:55px;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:7px;padding:9px;border-left:1px solid var(--shd-border);color:#8e9698}.database-category-nav>a:first-child{border-left:0}.database-category-nav>a small{color:#525a5c;font-size:5px;letter-spacing:.6px}.database-category-nav>a span{font-size:7px;font-weight:900;letter-spacing:.35px}.database-category-nav>a b{color:#4f5759}.database-category-nav>a:hover,.database-category-nav>a:focus-visible{background:#171b1c;color:var(--shd-text)}.database-category-nav>a.active{box-shadow:inset 0 2px var(--shd-orange);background:#191814;color:var(--shd-orange)}.database-category-nav>a:active{filter:brightness(.85)} +.database-facet-panel{margin:10px 0;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:#0d1011}.database-facet-panel>header{height:auto;min-height:43px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 11px;border-bottom:1px solid var(--shd-border);background:#15191a}.database-facet-panel>header>div{display:grid;gap:3px}.database-facet-panel>header small{color:var(--shd-orange);font-size:6px;font-weight:900;letter-spacing:.9px}.database-facet-panel>header strong{color:#727a7c;font-size:5px;letter-spacing:.7px}.database-facet-panel>header button{min-height:30px;padding:0 10px;border:1px solid #5d4324;background:#1c160d;color:var(--shd-orange);font-size:6px;font-weight:900;letter-spacing:.7px;cursor:pointer}.database-facet-panel>header button:hover,.database-facet-panel>header button:focus-visible{border-color:var(--shd-orange);background:#261c0d}.database-facet-panel>header button:active{transform:translateY(1px);filter:brightness(.8)}.database-facet-groups{display:grid;grid-template-columns:1.2fr 1fr 1fr;gap:8px;padding:9px}.database-facet-group{min-width:0;margin:0;padding:7px;border:1px solid var(--shd-border);background:#111415}.database-facet-group legend{display:flex;align-items:center;gap:7px;padding:0 4px;color:#a7adaf;font-size:6px;font-weight:900;letter-spacing:.7px}.database-facet-group legend span{color:var(--shd-orange);font-size:5px}.database-facet-group>div{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:4px}.database-facet-group>div>button{min-width:0;min-height:46px;display:flex;align-items:center;justify-content:center;gap:5px;padding:5px;border:1px solid #353b3c;background:#0d1011;color:#7f8789;cursor:pointer}.database-facet-group>div>button :is(.game-icon,img){width:28px;height:28px;flex:0 0 28px;object-fit:contain}.database-facet-group>div>button span{overflow:hidden;font-size:6px;font-weight:900;letter-spacing:.3px;text-overflow:ellipsis;white-space:nowrap}.database-facet-group>div>button:hover,.database-facet-group>div>button:focus-visible{border-color:#6a4c26;background:#18150f;color:#d0d3d1}.database-facet-group>div>button.selected{border-color:var(--shd-orange);box-shadow:inset 0 2px var(--shd-orange);background:#21190e;color:var(--shd-orange)}.database-facet-group>div>button:active{transform:translateY(1px);filter:brightness(.82)}.database-facet-group.searchable>div{max-height:150px;display:grid;grid-template-columns:1fr;overflow:auto}.database-facet-group.searchable>div>button{min-height:36px;justify-content:flex-start}.database-facet-group.searchable>div>button :is(img,.game-icon){width:25px;height:25px;flex-basis:25px}.database-facet-search{display:block;margin-bottom:5px}.database-facet-search input{width:100%;height:32px;margin:0;padding:0 8px;border:1px solid #3b4243;background:#0a0d0e;color:var(--shd-text);font-size:7px}.database-facet-search input:focus{border-color:var(--shd-orange);outline:1px solid color-mix(in srgb,var(--shd-orange) 45%,transparent);outline-offset:1px} +.database-category-page>.registry-pagination{margin:10px 0}.database-item-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:9px}.database-item-card{min-width:0;display:flex;flex-direction:column;border:1px solid var(--shd-border);border-top:2px solid #404748;background:#111415}.database-item-card>header{height:auto;min-height:78px;display:grid;grid-template-columns:58px minmax(0,1fr);align-items:center;gap:12px;padding:11px 13px;border-bottom:1px solid var(--shd-border)}.database-item-icon{width:58px;height:58px;display:grid;place-items:center;border:1px solid #343a3b;background:#0c0f10}.database-item-icon :is(img,.game-icon){width:52px;height:52px;object-fit:contain}.database-item-card>header small{display:block;margin-bottom:5px;color:#697174;font-size:6px;font-weight:900;letter-spacing:.8px;text-transform:uppercase}.database-item-card h2{overflow-wrap:anywhere;margin:0;font-size:14px;line-height:1.1}.database-item-badges{display:flex;flex-wrap:wrap;gap:5px;padding:8px 12px 0}.database-item-badges span{padding:5px 7px;border:1px solid #424849;background:#171b1c;color:#a5acae;font-size:6px;font-weight:900;letter-spacing:.6px;text-transform:uppercase}.database-item-badges .exotic{border-color:#74552a;background:#1a160d;color:var(--quality-high-end)}.database-item-badges :is(.standard,.specialized,.superior,.highend){border-left-width:2px}.database-item-badges .standard{border-left-color:var(--quality-standard)}.database-item-badges .specialized{border-left-color:var(--quality-specialized)}.database-item-badges .superior{border-left-color:var(--quality-superior)}.database-item-badges .highend{border-left-color:var(--quality-high-end)}.database-item-facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(90px,1fr));margin:9px 12px 0;border:1px solid var(--shd-border)}.database-item-facts>div{min-width:0;display:grid;align-content:center;gap:4px;min-height:53px;padding:7px;border-left:1px solid var(--shd-border)}.database-item-facts>div:first-child{border-left:0}.database-item-facts span,.database-item-lines span{color:#626a6c;font-size:5px;font-weight:900;letter-spacing:.65px}.database-item-facts b{overflow-wrap:anywhere;color:#d2d6d3;font-size:8px;line-height:1.35}.database-item-lines{display:grid;gap:1px;margin:7px 12px 0;border-top:1px solid var(--shd-border)}.database-item-lines>div{display:grid;grid-template-columns:110px 1fr;gap:9px;padding:8px 0;border-bottom:1px solid var(--shd-border)}.database-item-lines b{overflow-wrap:anywhere;font-size:7px;line-height:1.4}.database-item-card>p{margin:9px 12px 0;padding:9px;border-left:2px solid var(--shd-orange);background:#0d1011;color:#a2a8aa;font:10px/1.55 Georgia,serif}.database-item-card>p.secondary-description{margin-top:5px;border-left-color:#5c6466}.database-item-card>:last-child{margin-bottom:12px}.database-empty{grid-column:1/-1;display:grid;justify-items:center;gap:9px;padding:70px 24px;border:1px solid var(--shd-border);background:#101314;text-align:center}.database-empty>span{color:var(--shd-orange);font-size:30px}.database-empty strong{font-size:10px;letter-spacing:1px}.database-empty p{margin:0;color:#7e8688;font:11px/1.5 Georgia,serif} +.database-item-facts.stacked{grid-template-columns:1fr}.database-item-facts.stacked>div{grid-template-columns:82px minmax(0,1fr);align-items:center;min-height:43px;border-left:0;border-top:1px solid var(--shd-border)}.database-item-facts.stacked>div:first-child{border-top:0}.database-item-facts.stacked b{color:var(--shd-orange)} +.database-item-actions{display:flex;flex-wrap:wrap;gap:6px;margin:10px 12px 12px;padding-top:9px;border-top:1px solid var(--shd-border)}.database-item-actions>a{min-height:34px;display:inline-flex;align-items:center;justify-content:center;padding:0 11px;border:1px solid #74552a;background:#1b150c;color:var(--shd-orange);font-size:6px;font-weight:900;letter-spacing:.65px}.database-item-actions>a:hover,.database-item-actions>a:focus-visible{border-color:var(--shd-orange);background:#261b0b;color:#ffd19b}.database-item-actions>a:active{transform:translateY(1px);filter:brightness(.82)} +.database-item-icon-groups{display:grid;gap:8px;margin:8px 12px 0}.database-item-icon-groups>div{display:grid;grid-template-columns:110px minmax(0,1fr);align-items:center;gap:9px;padding-top:8px;border-top:1px solid var(--shd-border)}.database-item-icon-groups>div>span{color:#626a6c;font-size:5px;font-weight:900;letter-spacing:.65px}.item-icon-rail{min-width:0;display:flex;flex-wrap:wrap;gap:5px}.item-icon-rail>button{width:34px;height:34px;display:grid;place-items:center;padding:0;border:1px solid #394041;background:#0d1011;cursor:pointer}.item-icon-rail>button:hover,.item-icon-rail>button:focus-visible{border-color:var(--shd-orange);background:#211a0e}.item-icon-rail>button:active{transform:translateY(1px);filter:brightness(.82)}.item-icon-rail img{width:30px;height:30px;object-fit:contain}.item-icon-rail small{color:#687073;font-size:6px;font-weight:900;letter-spacing:.55px}.database-preview-backdrop{position:fixed;z-index:1200;inset:0;display:grid;place-items:center;padding:20px;background:rgba(3,5,5,.82);backdrop-filter:blur(3px)}.database-preview-dialog{width:min(520px,100%);max-height:calc(100vh - 40px);overflow:auto;border:1px solid #4b5254;border-top:2px solid var(--shd-orange);background:#0b0e0f;box-shadow:0 18px 60px #000}.database-preview-dialog>header{height:auto;display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--shd-border);background:#15191a}.database-preview-dialog>header>div{display:grid;gap:3px}.database-preview-dialog>header small{color:#697174;font-size:5px;letter-spacing:.8px}.database-preview-dialog>header strong{font-size:10px;letter-spacing:.45px}.database-preview-dialog>header button{width:32px;height:32px;padding:0;border:1px solid #434a4c;background:#101314;color:#aab0b2;font-size:20px;line-height:1;cursor:pointer}.database-preview-dialog>header button:hover,.database-preview-dialog>header button:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}.database-preview-dialog>.database-item-card{margin:10px;border-top-color:#404748} +@media(max-width:1200px){.database-category-nav{grid-template-columns:repeat(4,1fr)}.database-category-nav>a:nth-child(5){border-left:0}.database-category-nav>a:nth-child(n+5){border-top:1px solid var(--shd-border)}.database-facet-groups{grid-template-columns:1fr 1fr}.database-facet-group:first-child{grid-column:1/-1}.database-item-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:700px){.database-category-command{align-items:stretch;flex-direction:column}.database-category-command>div:last-child{justify-items:start}.database-category-nav{grid-template-columns:repeat(2,1fr)}.database-category-nav>a:nth-child(odd){border-left:0}.database-category-nav>a:nth-child(n+3){border-top:1px solid var(--shd-border)}.database-facet-panel>header{align-items:flex-start;flex-direction:column}.database-facet-groups{grid-template-columns:1fr}.database-facet-group:first-child{grid-column:auto}.database-facet-group>div{grid-template-columns:repeat(2,minmax(0,1fr))}.database-item-grid{grid-template-columns:1fr}.database-item-card>header{grid-template-columns:52px minmax(0,1fr)}.database-item-icon{width:52px;height:52px}.database-item-icon :is(img,.game-icon){width:46px;height:46px}}@media(prefers-reduced-motion:reduce){.database-registry-grid>a{transition:none}} .community-metrics{gap:38px}.community-metrics strong{color:var(--shd-orange);font-size:15px;letter-spacing:1px}.community-preview .gear-node{width:98px;color:var(--shd-text)}.community-preview .gear-node:hover,.community-preview .gear-node:focus-visible{border-color:var(--shd-orange);background:#211a0e}.community-preview .gear-node:active{transform:translateY(1px);filter:brightness(.86)}.community-preview .gear-node b{font-size:7px;letter-spacing:.55px}.community-channels{padding:58px max(24px,5vw) 70px;border-top:1px solid var(--shd-border);background:#0c0f10}.community-channels>.section-heading{max-width:1240px}.community-channels>.section-heading>p{max-width:310px;margin:0;color:var(--shd-text-muted);font:12px/1.5 Georgia,serif;text-align:right}.community-channel-grid{max-width:1240px;display:grid;grid-template-columns:repeat(4,1fr);margin:auto;border:1px solid var(--shd-border)}.community-channel-grid>a{min-height:205px;display:flex;flex-direction:column;padding:20px;border-left:1px solid var(--shd-border);background:#111415;color:var(--shd-text)}.community-channel-grid>a:first-child{border-left:0}.community-channel-grid>a:hover,.community-channel-grid>a:focus-visible{background:#171b1c;box-shadow:inset 0 2px var(--shd-orange)}.community-channel-grid>a:active{filter:brightness(.84)}.community-channel-grid small{color:#626a6c;font-size:6px;letter-spacing:1px}.community-channel-grid strong{margin:18px 0 9px;font-size:14px;letter-spacing:.6px}.community-channel-grid span{color:#858d8f;font:11px/1.55 Georgia,serif}.community-channel-grid b{margin-top:auto;padding-top:17px;color:var(--shd-orange);font-size:7px;letter-spacing:.8px}.community-index-page{padding-top:34px}.community-index-hero{margin-bottom:12px}.community-database-intro{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px}.community-database-intro>div{display:grid;grid-template-columns:150px 1fr 1.4fr;align-items:center;gap:12px;min-height:65px;padding:10px 13px;border:1px solid var(--shd-border);background:#101314}.community-database-intro span{color:#666e70;font-size:6px;letter-spacing:1px}.community-database-intro strong{color:var(--shd-orange);font-size:8px;letter-spacing:.7px}.community-database-intro small{color:#7f8789;font:10px/1.45 Georgia,serif}.database-registry-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:9px}.database-registry-grid article{min-height:180px;display:flex;flex-direction:column;padding:17px;border:1px solid var(--shd-border);border-top:2px solid #43494a;background:#111415}.database-registry-grid article:hover{border-top-color:var(--shd-orange);background:#15191a}.database-registry-grid header{height:auto;display:flex;justify-content:space-between}.database-registry-grid header small{color:#596164;font-size:6px;letter-spacing:.8px}.database-registry-grid header b{color:var(--shd-orange);font-size:20px}.database-registry-grid h2{margin:18px 0 8px;font-size:16px}.database-registry-grid p{color:#858d8f;font:11px/1.55 Georgia,serif}.database-registry-grid>article>span{margin-top:auto;color:#687073;font-size:6px;font-weight:900;letter-spacing:.8px}.news-channel-grid{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:10px}.news-primary{min-height:390px;padding:30px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:linear-gradient(135deg,#151819,#0d1011)}.news-primary>div:first-child{display:flex;justify-content:space-between;padding-bottom:13px;margin-bottom:30px;border-bottom:1px solid var(--shd-border);color:#626a6c;font-size:6px;letter-spacing:1px}.news-primary>div:first-child b{color:var(--shd-orange)}.news-primary h2{max-width:720px;margin:19px 0;font-size:clamp(30px,4vw,52px);line-height:.95}.news-primary>p:not(.eyebrow){max-width:760px;color:#969d9f;font:14px/1.7 Georgia,serif}.news-primary footer{display:flex;gap:7px;margin-top:35px}.news-primary footer span{display:grid;padding:8px 10px;border:1px solid var(--shd-border);color:#626a6c;font-size:6px;letter-spacing:.8px}.news-primary footer b{margin-top:4px;color:#d5d8d5;font-size:8px}.news-channel-grid>aside{padding:19px;border:1px solid var(--shd-border);background:#111415}.news-channel-grid>aside>div{min-height:58px;display:grid;grid-template-columns:30px 1fr auto;align-items:center;border-top:1px solid var(--shd-border)}.news-channel-grid>aside small{color:#596164;font-size:7px}.news-channel-grid>aside span{font-size:8px;letter-spacing:.7px}.news-channel-grid>aside b{color:#6e7678;font-size:6px}@media(max-width:1100px){.community-channel-grid{grid-template-columns:1fr 1fr}.community-channel-grid>a:nth-child(3){border-left:0;border-top:1px solid var(--shd-border)}.community-channel-grid>a:nth-child(4){border-top:1px solid var(--shd-border)}.community-database-intro{grid-template-columns:1fr}.database-registry-grid{grid-template-columns:1fr 1fr}}@media(max-width:750px){.community-channels>.section-heading>p{display:none}.community-channel-grid,.database-registry-grid,.news-channel-grid{grid-template-columns:1fr}.community-channel-grid>a{min-height:155px;border-left:0;border-top:1px solid var(--shd-border)}.community-channel-grid>a:first-child{border-top:0}.community-database-intro>div{grid-template-columns:1fr}.news-primary{padding:22px}.news-primary footer{flex-wrap:wrap}}@media(max-width:430px){.site-header .brand-name small{display:block;font-size:4px;letter-spacing:.7px}} diff --git a/src/lib/components/DatabaseCategoryNav.svelte b/src/lib/components/DatabaseCategoryNav.svelte new file mode 100644 index 0000000..676ea19 --- /dev/null +++ b/src/lib/components/DatabaseCategoryNav.svelte @@ -0,0 +1,12 @@ + + + diff --git a/src/lib/components/DatabaseFacetFilters.svelte b/src/lib/components/DatabaseFacetFilters.svelte new file mode 100644 index 0000000..4d7a4de --- /dev/null +++ b/src/lib/components/DatabaseFacetFilters.svelte @@ -0,0 +1,56 @@ + + +
+
TACTICAL FILTER MATRIXMATCH ANY WITHIN A GROUP · MATCH EVERY ACTIVE GROUP
{#if selectedCount}{/if}
+
+ {#each groups as group} +
+ {group.label}{group.selected.length ? `${group.selected.length} ACTIVE` : 'ANY'} + {#if group.kind === 'searchable'} + + {/if} +
+ {#each visible(group) as option} + + {:else} + NO MATCHING OPTIONS + {/each} +
+
+ {/each} +
+
diff --git a/src/lib/components/DatabaseItemCard.svelte b/src/lib/components/DatabaseItemCard.svelte new file mode 100644 index 0000000..e3fa5e8 --- /dev/null +++ b/src/lib/components/DatabaseItemCard.svelte @@ -0,0 +1,41 @@ + + +
+
+
+ {#if item.gameIcon}{:else}{/if} +
+
{item.kicker}

{item.name}

+
+ {#if item.badges?.length}
{#each item.badges as badge}{badge.label}{/each}
{/if} +
+ {#each item.facts as fact}
{fact.label}{fact.value}
{/each} +
+ {#if item.lines?.length} +
{#each item.lines as line}
{line.label}{line.value}
{/each}
+ {/if} + {#if item.iconGroups?.length} +
+ {#each item.iconGroups as group} +
{group.label}
+ {/each} +
+ {/if} + {#if item.description}

{item.description}

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

{item.secondaryDescription}

{/if} + {#if item.actions?.length} + + {/if} +
diff --git a/src/lib/components/ItemIconRail.svelte b/src/lib/components/ItemIconRail.svelte new file mode 100644 index 0000000..5d19f3c --- /dev/null +++ b/src/lib/components/ItemIconRail.svelte @@ -0,0 +1,49 @@ + + + { if (event.key === 'Escape') selected = null; }}/> + +
item.name).join(', ') : emptyLabel}`}> + {#if items.length} + {#each items as item} + + {/each} + {:else} + {emptyLabel} + {/if} +
+ +{#if selected?.preview} + +{/if} diff --git a/src/lib/database/categories.ts b/src/lib/database/categories.ts new file mode 100644 index 0000000..4e56717 --- /dev/null +++ b/src/lib/database/categories.ts @@ -0,0 +1,14 @@ +export const databaseCategories = [ + { slug: 'gear', label: 'Gear', code: 'GEAR', description: 'Equipment slots, base health and armor, tiers, Brand Sets, and talents.' }, + { slug: 'weapons', label: 'Weapons', code: 'WPN', description: 'Weapon families, damage types, tier ranges, handling, talents, and bonuses.' }, + { slug: 'weapon-mods', label: 'Weapon Mods', code: 'WMOD', description: 'Slot and weapon compatibility, quality rolls, secondary pools, and Mod Effects.' }, + { slug: 'gear-mods', label: 'Gear Mods', code: 'GMOD', description: 'Quality-scaled primary rolls and selectable secondary attribute pools.' }, + { slug: 'os-protocols', label: 'OS Protocols', code: 'OSPR', description: 'Quality, core profile, primary effect, and unique upgradeable talent.' }, + { slug: 'talents', label: 'Talents', code: 'TLNT', description: 'Equipment and weapon talents with tiered descriptions and combat mechanics.' }, + { slug: 'brands', label: 'Brands', code: 'BRND', description: 'Brand Set identities and their two-, three-, and four-piece bonuses.' }, + { slug: 'skill-chips', label: 'Skill Chips', code: 'CHIP', description: 'Set bonuses, primary and secondary pools, and quality-scaled unique talents.' } +] as const; + +export type DatabaseCategorySlug = typeof databaseCategories[number]['slug']; + +export const databaseCategory = (slug: string) => databaseCategories.find((category) => category.slug === slug); diff --git a/src/lib/database/items.ts b/src/lib/database/items.ts new file mode 100644 index 0000000..fa43023 --- /dev/null +++ b/src/lib/database/items.ts @@ -0,0 +1,31 @@ +export type DatabaseBadge = { label: string; tone?: string }; +export type DatabaseFact = { label: string; value: string }; + +export type DatabaseItem = { + id: string; + name: string; + kicker: string; + iconUrl?: string; + gameIcon?: string; + badges?: DatabaseBadge[]; + facts: DatabaseFact[]; + factLayout?: 'grid' | 'stack'; + actions?: Array<{ label: string; href: string }>; + lines?: DatabaseFact[]; + iconGroups?: DatabaseIconGroup[]; + description?: string; + secondaryDescription?: string; +}; + +export type DatabaseRelatedItem = { + id: string; + name: string; + iconUrl?: string; + preview?: DatabaseItem; +}; + +export type DatabaseIconGroup = { + label: string; + items: DatabaseRelatedItem[]; + emptyLabel?: string; +}; diff --git a/src/routes/database/+page.server.ts b/src/routes/database/+page.server.ts index bd55de7..60e5138 100644 --- a/src/routes/database/+page.server.ts +++ b/src/routes/database/+page.server.ts @@ -1,23 +1,23 @@ import { getDatabase } from '$lib/server/database'; +import { databaseCategories } from '$lib/database/categories'; import type { PageServerLoad } from './$types'; -const registries = [ - { key: 'attributes', label: 'Attributes', table: 'stat_attributes', detail: 'Stats, units, source ranges, caps, and calculation types.' }, - { key: 'talents', label: 'Talents', table: 'talents', detail: 'Tiered effects, triggers, durations, cooldowns, and stacks.' }, - { key: 'brands', label: 'Brand Sets', table: 'brand_sets', detail: 'Two-, three-, and four-piece equipment bonuses.' }, - { key: 'gear', label: 'Gear', table: 'gear', detail: 'Equipment slots, base ranges, brands, talents, and tiers.' }, - { key: 'weapons', label: 'Weapons', table: 'weapons', detail: 'Weapon families, damage ranges, talents, upgrades, and bonuses.' }, - { key: 'weaponmods', label: 'Weapon Mods', table: 'weapon_mods', detail: 'Compatibility, quality rolls, secondary pools, and effects.' }, - { key: 'gearmods', label: 'Gear Mods', table: 'gear_mods', detail: 'Primary scaling and secondary attribute pools by quality.' }, - { key: 'protocols', label: 'OS Protocols', table: 'os_protocols', detail: 'Primary effects, shared profiles, and unique talents.' }, - { key: 'skillchips', label: 'Skill Chips', table: 'skill_chip_sets', detail: 'Quality-scaled set bonuses, rolls, and chip talents.' } -] as const; +const tables = { + gear: 'gear', + weapons: 'weapons', + 'weapon-mods': 'weapon_mods', + 'gear-mods': 'gear_mods', + 'os-protocols': 'os_protocols', + talents: 'talents', + brands: 'brand_sets', + 'skill-chips': 'skill_chip_sets' +} as const; export const load: PageServerLoad = async () => { const database = await getDatabase(); - const counts = await Promise.all(registries.map(async (registry) => { - const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM ${registry.table} WHERE active=1`)).get(); - return { ...registry, count: Number(row?.count ?? 0) }; + const counts = await Promise.all(databaseCategories.map(async (category) => { + const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM ${tables[category.slug]} WHERE active=1`)).get(); + return { ...category, count: Number(row?.count ?? 0) }; })); return { registries: counts, diff --git a/src/routes/database/+page.svelte b/src/routes/database/+page.svelte index 43c881e..f167d8a 100644 --- a/src/routes/database/+page.svelte +++ b/src/routes/database/+page.svelte @@ -13,17 +13,17 @@
{data.total}ACTIVE RECORDSCONTRIBUTE DATA →
-
DATABASE STATUSPUBLIC INDEX ONLINEDetailed public record pages will arrive incrementally. Current counts reflect active, approved data.
+
DATABASE STATUSPUBLIC INDEX ONLINESelect a registry to inspect active, approved records and their resolved gameplay attributes.
DATA STEWARDSHIPCOMMUNITY + OVERSIGHTRegistered agents can inspect source data and propose verified corrections through ISAC.
{#each data.registries as registry, index} - +

{registry.description}

+ {registry.count ? 'OPEN ACTIVE DATA →' : 'AWAITING CONTRIBUTIONS →'} +
{/each}
diff --git a/src/routes/database/[category]/+page.server.ts b/src/routes/database/[category]/+page.server.ts new file mode 100644 index 0000000..c85685a --- /dev/null +++ b/src/routes/database/[category]/+page.server.ts @@ -0,0 +1,376 @@ +import { databaseCategory } from '$lib/database/categories'; +import type { GameIconName } from '$lib/game-icons'; +import { gameDataIconUrl, type GameDataIconType } from '$lib/server/game-data-icons'; +import { getDatabase } from '$lib/server/database'; +import { getPagination, getRegistrySearch } from '$lib/server/pagination'; +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +const number = (value: unknown) => { + const numeric = Number(value); + return Number.isFinite(numeric) ? (Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '')) : '—'; +}; +const valueWithUnit = (value: unknown, unit: unknown) => `${number(value)}${unit ? (unit === '%' ? '%' : ` ${unit}`) : ''}`; +const range = (min: unknown, max: unknown, unit: unknown = '') => `${valueWithUnit(min, unit)}–${valueWithUnit(max, unit)}`; +const list = (value: unknown, fallback = 'NONE') => typeof value === 'string' && value ? value : fallback; +const gearIcon = (slot: unknown) => String(slot).toLowerCase().replaceAll(' ', '_') as GameIconName; +const weaponIcon = (category: unknown) => String(category).toLowerCase(); +const iconUrl = (type: GameDataIconType, id: unknown, updatedAt: unknown) => + updatedAt ? gameDataIconUrl(type, String(id), updatedAt) : ''; + +export const load: PageServerLoad = async ({ params, url }) => { + const category = databaseCategory(params.category); + if (!category) error(404, 'Database category not found'); + const database = await getDatabase(); + const { search, pattern } = getRegistrySearch(url); + const searchArgs = search ? [pattern] : []; + const searchClause = search ? " AND item.name LIKE ? ESCAPE '\\' COLLATE NOCASE" : ''; + let queryArgs: unknown[] = searchArgs; + let filters: Array<{ + key: string; + label: string; + kind: 'segmented' | 'searchable'; + selected: string[]; + options: Array<{ value: string; label: string; iconUrl?: string; gameIcon?: GameIconName }>; + }> = []; + let countSql = ''; + let rowsSql = ''; + + switch (category.slug) { + case 'gear': { + const [brandRows, talentRows] = await Promise.all([ + (await database.prepare(`SELECT brands.id,brands.name,icons.updated_at FROM brand_sets brands + LEFT JOIN game_data_icons icons ON icons.entity_type='brand_set' AND icons.entity_id=brands.id + WHERE brands.active=1 ORDER BY brands.name COLLATE NOCASE`)).all(), + (await database.prepare(`SELECT talents.id,talents.name,icons.updated_at FROM talents + LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id + WHERE talents.active=1 AND talents.category IN ('Backpack','Body Armor','Vest') + ORDER BY talents.name COLLATE NOCASE`)).all() + ]); + const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask']; + const validBrands = new Set(brandRows.map((row) => String(row.id))); + const validTalents = new Set(talentRows.map((row) => String(row.id))); + const selectedSlots = url.searchParams.getAll('slot').filter((value) => slots.includes(value)); + const selectedBrands = url.searchParams.getAll('brand').filter((value) => validBrands.has(value)); + const selectedTalents = url.searchParams.getAll('talent').filter((value) => validTalents.has(value)); + const conditions = ['item.active=1']; + queryArgs = []; + if (search) { + conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE"); + queryArgs.push(pattern); + } + if (selectedSlots.length) { + conditions.push(`item.slot IN (${selectedSlots.map(() => '?').join(',')})`); + queryArgs.push(...selectedSlots); + } + if (selectedBrands.length) { + conditions.push(`EXISTS (SELECT 1 FROM gear_brand_sets filter_brands + WHERE filter_brands.gear_id=item.id AND filter_brands.brand_set_id IN (${selectedBrands.map(() => '?').join(',')}))`); + queryArgs.push(...selectedBrands); + } + if (selectedTalents.length) { + conditions.push(`EXISTS (SELECT 1 FROM gear_talents filter_talents + WHERE filter_talents.gear_id=item.id AND filter_talents.talent_id IN (${selectedTalents.map(() => '?').join(',')}))`); + queryArgs.push(...selectedTalents); + } + const where = conditions.join(' AND '); + countSql = `SELECT COUNT(*) AS total FROM gear item WHERE ${where}`; + rowsSql = `SELECT item.* FROM gear item WHERE ${where} + ORDER BY item.slot,item.tier,item.name COLLATE NOCASE LIMIT ? OFFSET ?`; + filters = [ + { + key: 'slot', label: 'GEAR SLOT', kind: 'segmented', selected: selectedSlots, + options: slots.map((slot) => ({ value: slot, label: slot, gameIcon: gearIcon(slot) })) + }, + { + key: 'brand', label: 'BRAND SETS', kind: 'searchable', selected: selectedBrands, + options: brandRows.map((row) => ({ + value: String(row.id), label: String(row.name), + iconUrl: iconUrl('brand_set', row.id, row.updated_at) + })) + }, + { + key: 'talent', label: 'TALENTS', kind: 'searchable', selected: selectedTalents, + options: talentRows.map((row) => ({ + value: String(row.id), label: String(row.name), + iconUrl: iconUrl('talent', row.id, row.updated_at) + })) + } + ]; + break; + } + case 'weapons': + countSql = `SELECT COUNT(*) AS total FROM weapons item WHERE item.active=1${searchClause}`; + rowsSql = `SELECT item.*, + native.damage_min,native.damage_max,native.reference_level, + (SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=1) AS talent_1_count, + (SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count, + (SELECT COUNT(*) FROM weapon_base_attribute_bonuses bonuses WHERE bonuses.weapon_id=item.id) AS bonus_count + FROM weapons item + LEFT JOIN weapon_tier_ranges native ON native.weapon_id=item.id AND native.tier=item.native_tier + WHERE item.active=1${searchClause} + ORDER BY item.is_exotic DESC,item.category,item.name COLLATE NOCASE LIMIT ? OFFSET ?`; + break; + case 'weapon-mods': + countSql = `SELECT COUNT(*) AS total FROM weapon_mods item WHERE item.active=1${searchClause}`; + rowsSql = `SELECT item.*,weapons.name AS exotic_weapon, + GROUP_CONCAT(DISTINCT categories.weapon_category) AS categories, + (SELECT COUNT(*) FROM weapon_mod_secondary_options secondary WHERE secondary.weapon_mod_id=item.id) AS secondary_count, + (SELECT COUNT(*) FROM weapon_mod_effects effects WHERE effects.weapon_mod_id=item.id) AS effect_count + FROM weapon_mods item + LEFT JOIN weapons ON weapons.id=item.exotic_weapon_id + LEFT JOIN weapon_mod_compatible_categories categories ON categories.weapon_mod_id=item.id + WHERE item.active=1${searchClause} + GROUP BY item.id ORDER BY item.slot_type,item.name COLLATE NOCASE LIMIT ? OFFSET ?`; + break; + case 'gear-mods': + countSql = `SELECT COUNT(*) AS total FROM gear_mods item WHERE item.active=1${searchClause}`; + rowsSql = `SELECT item.*, + (SELECT COUNT(*) FROM gear_mod_secondary_options secondary WHERE secondary.gear_mod_id=item.id) AS secondary_count, + (SELECT MIN(primary_level_1_min) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_1_floor, + (SELECT MAX(primary_level_20_max) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_20_ceiling + FROM gear_mods item WHERE item.active=1${searchClause} + ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`; + break; + case 'os-protocols': + countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE item.active=1${searchClause}`; + rowsSql = `SELECT item.*,attributes.name AS primary_stat,attributes.unit AS primary_unit, + primary_effect.base_value,talents.name AS talent_name,icons.updated_at AS icon_updated_at + FROM os_protocols item + LEFT JOIN os_protocol_primary_effects primary_effect ON primary_effect.protocol_id=item.id + LEFT JOIN stat_attributes attributes ON attributes.id=primary_effect.stat_attribute_id + LEFT JOIN os_protocol_talents talents ON talents.protocol_id=item.id + LEFT JOIN game_data_icons icons ON icons.entity_type='os_protocol' AND icons.entity_id=item.id + WHERE item.active=1${searchClause} + ORDER BY item.quality,item.primary_attribute_type,item.name COLLATE NOCASE LIMIT ? OFFSET ?`; + break; + case 'talents': + countSql = `SELECT COUNT(*) AS total FROM talents item WHERE item.active=1${searchClause}`; + rowsSql = `SELECT item.*, + (SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=item.id AND tiers.tier='I') AS tier_i, + (SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=item.id AND tiers.tier='II') AS tier_ii, + (SELECT slot FROM weapon_talent_slots slots WHERE slots.talent_id=item.id) AS weapon_slot, + (SELECT scope FROM talent_weapon_scopes scopes WHERE scopes.talent_id=item.id) AS weapon_scope, + icons.updated_at AS icon_updated_at + FROM talents item + LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=item.id + WHERE item.active=1${searchClause} + ORDER BY item.category,item.name COLLATE NOCASE LIMIT ? OFFSET ?`; + break; + case 'brands': + countSql = `SELECT COUNT(*) AS total FROM brand_sets item WHERE item.active=1${searchClause}`; + rowsSql = `SELECT item.*,icons.updated_at AS icon_updated_at + FROM brand_sets item + LEFT JOIN game_data_icons icons ON icons.entity_type='brand_set' AND icons.entity_id=item.id + WHERE item.active=1${searchClause} + ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`; + break; + case 'skill-chips': + countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`; + rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit, + talents.name AS talent_name, + (SELECT COUNT(*) FROM skill_chip_primary_options options WHERE options.skill_chip_set_id=item.id) AS primary_count, + (SELECT COUNT(*) FROM skill_chip_secondary_options options WHERE options.skill_chip_set_id=item.id) AS secondary_count, + icons.updated_at AS icon_updated_at + FROM skill_chip_sets item + LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id + LEFT JOIN stat_attributes attributes ON attributes.id=bonus.stat_attribute_id + LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id + LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id + WHERE item.active=1${searchClause} + ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`; + break; + } + + const total = Number((await (await database.prepare(countSql)).get(...queryArgs))?.total ?? 0); + const pagination = getPagination(url, total); + const rows = await (await database.prepare(rowsSql)).all(...queryArgs, pagination.perPage, pagination.offset); + const gearRelations = category.slug === 'gear' && rows.length + ? await (await database.prepare(`SELECT relation.*,icons.updated_at + FROM ( + SELECT links.gear_id,'brand' AS kind,brands.id,brands.name, + NULL AS category,NULL AS cooldown_seconds,NULL AS duration_seconds,NULL AS max_stacks, + NULL AS tier_i,NULL AS tier_ii + FROM gear_brand_sets links JOIN brand_sets brands ON brands.id=links.brand_set_id + WHERE brands.active=1 + UNION ALL + SELECT links.gear_id,'talent' AS kind,talents.id,talents.name, + talents.category,talents.cooldown_seconds,talents.duration_seconds,talents.max_stacks, + (SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i, + (SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='II') AS tier_ii + FROM gear_talents links JOIN talents ON talents.id=links.talent_id + WHERE talents.active=1 + ) relation + LEFT JOIN game_data_icons icons ON icons.entity_id=relation.id + AND icons.entity_type=CASE relation.kind WHEN 'brand' THEN 'brand_set' ELSE 'talent' END + WHERE relation.gear_id IN (${rows.map(() => '?').join(',')}) + ORDER BY relation.kind,relation.name COLLATE NOCASE`)).all(...rows.map((row) => row.id)) + : []; + const gearBrandEffects = category.slug === 'gear' && gearRelations.some((relation) => relation.kind === 'brand') + ? await (await database.prepare(`SELECT effects.brand_set_id,effects.pieces_required,effects.value, + attributes.name,attributes.unit FROM brand_set_effects effects + JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id + WHERE effects.brand_set_id IN (${gearRelations.filter((relation) => relation.kind === 'brand').map(() => '?').join(',')}) + ORDER BY effects.pieces_required`)).all(...gearRelations.filter((relation) => relation.kind === 'brand').map((relation) => relation.id)) + : []; + const brandEffects = category.slug === 'brands' && rows.length + ? await (await database.prepare(`SELECT effects.brand_set_id,effects.pieces_required,effects.value, + attributes.name,attributes.unit FROM brand_set_effects effects + JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id + WHERE effects.brand_set_id IN (${rows.map(() => '?').join(',')}) ORDER BY effects.pieces_required`)).all(...rows.map((row) => row.id)) + : []; + + const items = rows.map((row) => { + switch (category.slug) { + case 'gear': + { + const relations = gearRelations.filter((relation) => relation.gear_id === row.id); + const relatedItems = (kind: 'brand' | 'talent', type: GameDataIconType) => relations + .filter((relation) => relation.kind === kind) + .map((relation) => { + const relationIcon = iconUrl(type, relation.id, relation.updated_at); + const effects = kind === 'brand' ? gearBrandEffects.filter((effect) => effect.brand_set_id === relation.id) : []; + return { + id: String(relation.id), + name: String(relation.name), + iconUrl: relationIcon, + preview: kind === 'brand' ? { + id: String(relation.id), name: String(relation.name), kicker: 'BRAND SET // EQUIPMENT', + iconUrl: relationIcon, + factLayout: 'stack' as const, + actions: [{ label: 'FIND GEAR WITH THIS BRAND', href: `/database/gear?brand=${encodeURIComponent(String(relation.id))}` }], + facts: effects.map((effect) => ({ + label: `${effect.pieces_required} PIECES`, + value: `${effect.name} · +${valueWithUnit(effect.value,effect.unit)}` + })) + } : { + id: String(relation.id), name: String(relation.name), kicker: `${relation.category} // GEAR TALENT`, + iconUrl: relationIcon, + badges: [ + ...(relation.cooldown_seconds ? [{ label: `${number(relation.cooldown_seconds)}s COOLDOWN` }] : []), + ...(relation.duration_seconds ? [{ label: `${number(relation.duration_seconds)}s DURATION` }] : []), + ...(relation.max_stacks ? [{ label: `${relation.max_stacks} STACKS` }] : []) + ], + facts: [ + { label: 'TIER I', value: relation.tier_i ? 'CONFIGURED' : 'MISSING' }, + { label: 'TIER II', value: relation.tier_ii ? 'CONFIGURED' : 'MISSING' } + ], + description: relation.tier_i ? `TIER I · ${relation.tier_i}` : 'Tier I description is not configured.', + secondaryDescription: relation.tier_ii ? `TIER II · ${relation.tier_ii}` : 'Tier II description is not configured.' + } + }; + }); + const iconGroups = [ + { label: 'BRAND OPTIONS', items: relatedItems('brand', 'brand_set'), emptyLabel: 'NO BRANDS ASSIGNED' }, + ...(['Backpack', 'Body Armor'].includes(String(row.slot)) + ? [{ label: 'AVAILABLE TALENTS', items: relatedItems('talent', 'talent'), emptyLabel: 'NO TALENTS ASSIGNED' }] + : []) + ]; + return { + id: String(row.id), name: String(row.name), kicker: `${row.slot} // T${row.tier}`, + gameIcon: gearIcon(row.slot), + badges: [{ label: `T${row.tier}` }, { label: `${Number(row.tier) === 0 ? 1 : 2} MOD SLOT${Number(row.tier) === 0 ? '' : 'S'}` }], + facts: [ + { label: 'BASE HEALTH', value: range(row.base_health_min,row.base_health_max) }, + ...(row.base_armor_min === null ? [] : [{ label: 'BASE ARMOR', value: range(row.base_armor_min,row.base_armor_max) }]) + ], + iconGroups + }; + } + case 'weapons': + return { + id: String(row.id), name: String(row.name), kicker: `${row.category} // ${row.damage_type}`, + gameIcon: weaponIcon(row.category), + badges: [{ label: `T${row.native_tier}` }, ...(Number(row.is_exotic) ? [{ label: 'EXOTIC', tone: 'exotic' }] : [])], + facts: [ + { label: 'DAMAGE RANGE', value: row.damage_min === null ? 'NOT CONFIGURED' : `${range(row.damage_min,row.damage_max)} · L${row.reference_level}` }, + { label: 'RATE / MAG', value: `${number(row.rpm)} RPM · ${number(row.magazine)} MAG` }, + { label: 'RELOAD', value: valueWithUnit(row.reload_speed,'s') }, + { label: 'ACCURACY / STABILITY', value: `${number(row.accuracy)} / ${number(row.stability)}` } + ], + lines: [ + { label: 'TALENT POOLS', value: `${row.talent_1_count} TOP · ${row.talent_2_count} BOTTOM` }, + { label: 'BASE BONUSES', value: String(row.bonus_count) } + ] + }; + case 'weapon-mods': + return { + id: String(row.id), name: String(row.name), kicker: `${row.slot_type} // WEAPON MOD`, + badges: row.exotic_weapon_id ? [{ label: 'EXOTIC', tone: 'exotic' }] : [{ label: 'STANDARD → HIGH-END' }], + facts: [ + { label: 'LEVELS', value: '01–20' }, + { label: 'SECONDARY POOL', value: row.exotic_weapon_id ? 'FIXED ATTRIBUTES' : `${row.secondary_count} OPTIONS` }, + { label: 'MOD EFFECTS', value: row.exotic_weapon_id ? 'FIXED' : String(row.effect_count) } + ], + lines: [{ label: row.exotic_weapon_id ? 'ASSIGNED WEAPON' : 'WEAPON CATEGORIES', value: row.exotic_weapon_id ? list(row.exotic_weapon) : list(row.categories).toUpperCase() }] + }; + case 'gear-mods': + return { + id: String(row.id), name: String(row.name), kicker: 'GEAR MOD // STANDARD QUALITIES', + badges: [{ label: 'STANDARD → HIGH-END' }], + facts: [ + { label: 'LEVELS', value: '01–20' }, + { label: 'PRIMARY OPTIONS', value: '5' }, + { label: 'SECONDARY POOL', value: `${row.secondary_count} OPTIONS` } + ], + lines: [{ label: 'OBSERVED PRIMARY ENVELOPE', value: `${number(row.level_1_floor)} → ${number(row.level_20_ceiling)}` }] + }; + case 'os-protocols': + return { + id: String(row.id), name: String(row.name), kicker: `${row.primary_attribute_type} // OS PROTOCOL`, + iconUrl: iconUrl('os_protocol',row.id,row.icon_updated_at), + badges: [{ label: String(row.quality), tone: String(row.quality).toLowerCase().replace('-','') }], + facts: [ + { label: 'LEVELS', value: '01–50' }, + { label: 'PRIMARY EFFECT', value: row.primary_stat ? `${row.primary_stat} · ${valueWithUnit(row.base_value,row.primary_unit)}` : 'NOT CONFIGURED' }, + { label: 'TALENT UPGRADES', value: '0–5' } + ], + lines: [{ label: 'UNIQUE TALENT', value: list(row.talent_name,'NOT CONFIGURED') }] + }; + case 'talents': + return { + id: String(row.id), name: String(row.name), kicker: `${row.category}${row.weapon_slot ? ` // SLOT ${row.weapon_slot}` : ''}`, + iconUrl: iconUrl('talent',row.id,row.icon_updated_at), + badges: [ + ...(row.weapon_scope === 'Exotic' ? [{ label: 'EXOTIC', tone: 'exotic' }] : []), + ...(row.cooldown_seconds ? [{ label: `${number(row.cooldown_seconds)}s COOLDOWN` }] : []), + ...(row.duration_seconds ? [{ label: `${number(row.duration_seconds)}s DURATION` }] : []), + ...(row.max_stacks ? [{ label: `${row.max_stacks} STACKS` }] : []) + ], + facts: [ + { label: 'TIER I', value: row.tier_i ? 'CONFIGURED' : 'MISSING' }, + { label: 'TIER II', value: row.tier_ii ? 'CONFIGURED' : 'MISSING' } + ], + description: row.tier_i ? `TIER I · ${row.tier_i}` : 'Tier I description is not configured.', + secondaryDescription: row.tier_ii ? `TIER II · ${row.tier_ii}` : 'Tier II description is not configured.' + }; + case 'brands': { + const effects = brandEffects.filter((effect) => effect.brand_set_id === row.id); + return { + id: String(row.id), name: String(row.name), kicker: 'BRAND SET // EQUIPMENT', + iconUrl: iconUrl('brand_set',row.id,row.icon_updated_at), + factLayout: 'stack' as const, + actions: [{ label: 'FIND GEAR WITH THIS BRAND', href: `/database/gear?brand=${encodeURIComponent(String(row.id))}` }], + facts: effects.map((effect) => ({ + label: `${effect.pieces_required} PIECES`, + value: `${effect.name} · +${valueWithUnit(effect.value,effect.unit)}` + })) + }; + } + case 'skill-chips': + return { + id: String(row.id), name: String(row.name), kicker: 'SKILL CHIP SET // 2–3 PIECES', + iconUrl: iconUrl('skill_chip',row.id,row.icon_updated_at), + badges: [{ label: 'SUPERIOR' }, { label: 'HIGH-END' }], + facts: [ + { label: '2-PIECE BONUS', value: row.bonus_stat ? `${row.bonus_stat} · ${range(row.min_value,row.max_value,row.bonus_unit)}` : 'NOT CONFIGURED' }, + { label: 'PRIMARY POOL', value: `${row.primary_count} OPTIONS` }, + { label: 'SECONDARY POOL', value: `${row.secondary_count} OPTIONS` } + ], + lines: [{ label: '3-PIECE TALENT', value: list(row.talent_name,'NOT CONFIGURED') }] + }; + } + }); + + return { category, items, pagination, search, filters }; +}; diff --git a/src/routes/database/[category]/+page.svelte b/src/routes/database/[category]/+page.svelte new file mode 100644 index 0000000..3ac3007 --- /dev/null +++ b/src/routes/database/[category]/+page.svelte @@ -0,0 +1,32 @@ + + + + {data.category.label} database — SHD.build + + + +
+
+

ISAC PUBLIC ARCHIVE / {data.category.code}

{data.category.label}

{data.category.description}

+
{data.pagination.total}ACTIVE RECORDSDATABASE INDEX →
+
+ + {#if data.filters?.length}{/if} + +
+ {#each data.items as item} + + {:else} +
+ ◇{data.search ? 'NO MATCHING RECORDS' : 'NO ACTIVE RECORDS'} +

{data.search ? `No ${data.category.label.toLocaleLowerCase()} match “${data.search}”.` : `This registry is awaiting approved ${data.category.label.toLocaleLowerCase()} data.`}

+
+ {/each} +
+