From d25369067ad34909b9c8ed1f9c8103ba090934cb Mon Sep 17 00:00:00 2001 From: JB Date: Sun, 26 Jul 2026 00:04:12 -0400 Subject: [PATCH] feat(isac): support deferred item icons --- docs/DESIGN_SYSTEM.md | 16 ++++++++++++++++ docs/architecture.md | 8 ++++++++ src/app.css | 1 + src/lib/components/BuilderPicker.svelte | 6 ++++-- src/lib/components/BuilderRadioGrid.svelte | 4 +++- src/lib/components/IconUploadField.svelte | 4 ++-- src/lib/components/ImageUploadInput.svelte | 6 ++++-- src/lib/components/ItemIcon.svelte | 13 +++++++++++++ src/routes/builds/[slug]/+page.server.ts | 2 +- src/routes/isac/brands/+page.svelte | 3 ++- .../isac/contributor-guidelines/+page.svelte | 2 +- src/routes/isac/gear/+page.svelte | 3 ++- src/routes/isac/protocols/+page.svelte | 3 ++- src/routes/isac/skillchips/+page.svelte | 3 ++- src/routes/isac/talents/+page.svelte | 3 ++- static/missing-item-icon.svg | 13 +++++++++++++ 16 files changed, 76 insertions(+), 14 deletions(-) create mode 100644 src/lib/components/ItemIcon.svelte create mode 100644 static/missing-item-icon.svg diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 569e61e..47d1ba9 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -1222,3 +1222,19 @@ Use explicit textual tier capacity wherever Gear tier is summarized: `1 MOD SLOT` for T0 and `2 MOD SLOTS` for T1/T2. On narrow screens the identity band and preview controls stack, while repeated quality data may scroll rather than compressing numeric controls below usable sizes. + +### Deferred game-data icons + +Game-data icons are optional enrichment, never a blocker for creating or +proposing a canonical item. Upload fields must state that an icon may be +contributed later and show the shared `/missing-item-icon.svg` artwork when no +upload exists. Registries, relationship rails, and builder selection controls +use the same placeholder instead of initials, empty boxes, or ad hoc glyphs. + +Treat the placeholder as an explicit incomplete-data state: keep its subdued +SHD styling and useful alternative text where the surrounding item name is not +already available. Do not persist placeholder metadata or copy the placeholder +into per-item storage. A later contributor replaces it through the normal icon +upload proposal, preserving the canonical record and audit history. The upload +itself retains all transparent-format, size, SVG-safety, focus, drag/drop, and +paste validation requirements. diff --git a/docs/architecture.md b/docs/architecture.md index a8e0568..22a135c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -313,3 +313,11 @@ served by the validated `/icons/game-data/[type]/[id]` endpoint with immutable cache headers and MIME sniffing disabled. ISAC save actions process files before mutation, apply upload or removal after record validation, and include only safe icon metadata—not image bytes—in their audit snapshots. + +Icon metadata is optional. Canonical records and contributor proposals may be +created without an upload, allowing values and artwork to be contributed in +separate dependency-safe changes. Missing artwork is represented at render +time by the static `/missing-item-icon.svg` asset through the shared +`ItemIcon` component; the placeholder is never inserted into +`game_data_icons`. A later edit or proposal can attach the real icon without +recreating or replacing the canonical item. diff --git a/src/app.css b/src/app.css index 0aed3bc..78a3887 100644 --- a/src/app.css +++ b/src/app.css @@ -196,4 +196,5 @@ .weapon-mod-exotic-quality{min-height:58px;display:flex;align-items:center;gap:8px;padding:7px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#18150d}.weapon-mod-exotic-quality .game-icon{width:34px;height:34px}.weapon-mod-exotic-quality span{display:grid;gap:3px}.weapon-mod-exotic-quality b{color:var(--quality-high-end);font-size:7px;letter-spacing:.8px}.weapon-mod-exotic-quality small{color:#a89568;font-size:5px;letter-spacing:.35px} .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} diff --git a/src/lib/components/BuilderPicker.svelte b/src/lib/components/BuilderPicker.svelte index 61a04b0..d716cc3 100644 --- a/src/lib/components/BuilderPicker.svelte +++ b/src/lib/components/BuilderPicker.svelte @@ -1,4 +1,6 @@ + + diff --git a/src/routes/builds/[slug]/+page.server.ts b/src/routes/builds/[slug]/+page.server.ts index 1a1ca26..e76ab89 100644 --- a/src/routes/builds/[slug]/+page.server.ts +++ b/src/routes/builds/[slug]/+page.server.ts @@ -19,7 +19,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => { const [gear, weapons, chips, protocols, brands] = await Promise.all([ (await database.prepare('SELECT id,name,slot,tier FROM gear')).all(), (await database.prepare('SELECT id,name,category,is_exotic FROM weapons')).all(), - (await database.prepare('SELECT id,name,quality FROM skill_chip_sets')).all(), + (await database.prepare('SELECT id,name FROM skill_chip_sets')).all(), (await database.prepare('SELECT id,name,quality FROM os_protocols')).all(), (await database.prepare('SELECT id,name FROM brand_sets')).all() ]); diff --git a/src/routes/isac/brands/+page.svelte b/src/routes/isac/brands/+page.svelte index 721bd66..eb638d4 100644 --- a/src/routes/isac/brands/+page.svelte +++ b/src/routes/isac/brands/+page.svelte @@ -3,6 +3,7 @@ import RegistryPagination from '$lib/components/RegistryPagination.svelte'; import KpiFilters from '$lib/components/KpiFilters.svelte'; import IconUploadField from '$lib/components/IconUploadField.svelte'; + import ItemIcon from '$lib/components/ItemIcon.svelte'; import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; let { data, form } = $props(); type Effect = { pieces_required: number; stat_attribute_id: string; value: number }; @@ -58,7 +59,7 @@
BRAND SET2 PIECES3 PIECES4+ PIECESSTATE
{#each data.brands as brand} -
{#if brand.iconUrl}{:else}◇{/if}
{brand.name}{brand.pending ? `PROPOSED BY ${brand.proposerName}` : `${brand.effects.length}/3 EFFECTS CONFIGURED`}
{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}{/each}{brand.pending ? 'PENDING' : Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if brand.pending}{:else}{/if}
+
{brand.name}{brand.pending ? `PROPOSED BY ${brand.proposerName}` : `${brand.effects.length}/3 EFFECTS CONFIGURED`}
{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}{/each}{brand.pending ? 'PENDING' : Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if brand.pending}{:else}{/if}
{:else}
NO BRAND SETS REGISTEREDAdd the first set to begin mapping equipment synergy bonuses.
{/each} diff --git a/src/routes/isac/contributor-guidelines/+page.svelte b/src/routes/isac/contributor-guidelines/+page.svelte index b78ceeb..1fd6f8f 100644 --- a/src/routes/isac/contributor-guidelines/+page.svelte +++ b/src/routes/isac/contributor-guidelines/+page.svelte @@ -21,7 +21,7 @@
03 -

APPROPRIATE IMAGES

Upload only relevant game-data icons or assets you are permitted to share. Images must be safe for a general audience and must not contain harassment, explicit material, personal information, watermarks, advertisements, or misleading edits. Prefer clean transparent PNG, WebP, or SVG assets that remain legible at builder-icon sizes.

+

APPROPRIATE IMAGES

Icons are optional when contributing item data. If you do not have appropriate artwork, save the item without one; SHD.build will use a missing-icon placeholder and another contributor can add the icon later. When uploading, use only relevant game-data icons or assets you are permitted to share. Images must be safe for a general audience and must not contain harassment, explicit material, personal information, watermarks, advertisements, or misleading edits. Prefer clean transparent PNG, WebP, AVIF, or SVG assets that remain legible at builder-icon sizes.

04 diff --git a/src/routes/isac/gear/+page.svelte b/src/routes/isac/gear/+page.svelte index 40a1bcb..77ec45e 100644 --- a/src/routes/isac/gear/+page.svelte +++ b/src/routes/isac/gear/+page.svelte @@ -5,6 +5,7 @@ import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; import GameIcon from '$lib/components/GameIcon.svelte'; + import ItemIcon from '$lib/components/ItemIcon.svelte'; import type { GameIconName } from '$lib/game-icons'; let { data, form } = $props(); 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[] }; @@ -135,6 +136,6 @@
GEARSLOTTIERBASE RANGESBRAND SETSSTATE
{#each data.gear as item} -
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}
{item.slot}T{item.tier} · {Number(item.tier) === 0 ? '1 MOD SLOT' : '2 MOD SLOTS'}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}
{item.pending ? 'PENDING' : Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
+
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}
{item.slot}T{item.tier} · {Number(item.tier) === 0 ? '1 MOD SLOT' : '2 MOD SLOTS'}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}{/each}
{item.pending ? 'PENDING' : Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
{:else}
NO GEAR REGISTEREDAdd the first gear definition after configuring at least two brand sets.
{/each}
diff --git a/src/routes/isac/protocols/+page.svelte b/src/routes/isac/protocols/+page.svelte index 234df54..21deb46 100644 --- a/src/routes/isac/protocols/+page.svelte +++ b/src/routes/isac/protocols/+page.svelte @@ -6,6 +6,7 @@ import GameIcon from '$lib/components/GameIcon.svelte'; import type { GameIconName } from '$lib/game-icons'; import IconUploadField from '$lib/components/IconUploadField.svelte'; + import ItemIcon from '$lib/components/ItemIcon.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; let { data, form } = $props(); @@ -166,6 +167,6 @@
OS PROTOCOLQUALITYTYPEUNIQUE PRIMARYUNIQUE TALENTSTATE
{#each data.protocols as protocol} -
{#if protocol.iconUrl}{:else}◇{/if}
{protocol.name}{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}
{protocol.quality}{protocol.primary_attribute_type}{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}
{protocol.talent?.name ?? 'NOT CONFIGURED'}◆◆◆◆◆ 5 UPGRADES · 2× MAX
{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if protocol.pending}{:else}{/if}
+
{protocol.name}{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}
{protocol.quality}{protocol.primary_attribute_type}{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}
{protocol.talent?.name ?? 'NOT CONFIGURED'}◆◆◆◆◆ 5 UPGRADES · 2× MAX
{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if protocol.pending}{:else}{/if}
{:else}
NO OS PROTOCOLS REGISTEREDConfigure shared secondary profiles, then add the first protocol.
{/each}
diff --git a/src/routes/isac/skillchips/+page.svelte b/src/routes/isac/skillchips/+page.svelte index 31cab0b..6510663 100644 --- a/src/routes/isac/skillchips/+page.svelte +++ b/src/routes/isac/skillchips/+page.svelte @@ -1,5 +1,6 @@