Skip to content

Design drift ledger

The record of every place the implementation knowingly differs from the Claude Design MCP project. Governed by ADR-0015.

How to use this — 30 seconds, in the PR that diverges

Add one row when you make the change, not later. That is the whole point: reconstructing why a visual decision was made, months later, from a diff, is archaeology. Writing it down while the reason is in your head is free.

npm run check:design fails any diff touching packages/tokens/** or apps/mobile/src/ui/** without a row added in the same range. If your change genuinely has no visual effect (a refactor, a rename), say exactly that in the row — an explicit "no visual change" is a valid entry and takes less time than arguing with the gate.

Kinds of entry

KindMeaningNeeds sync back to the design project?
divergenceImplementation now differs from the design, deliberatelyYes — push at the next develop → uat
correctionImplementation was changed to match the design (it had drifted)No — the design was already right
aheadImplemented something the design has no opinion on yet (new component/variant)Yes — the design needs to gain it
noneTouched the systemic surface with no visual effectNo

Reconciliation

Open rows are worked at the develop → uat promotion (see the promotion runbook). Each is either pushed to the design project via DesignSync and marked synced, or explicitly deferred with a reason written on the row. A row is never silently dropped.

Ledger

Newest first. Status: 🔴 open · 🟢 synced · ⚪ n/a (correction / no visual change) · 🟡 deferred (with reason).

DateKindStatusSurfaceWhat & why
2026-07-28correction⚪ n/asrc/ui/Chip (toggleable filter pill)Chip gained onPress + selected, which is the component the design has specified all along. components/badges/Chip.prompt.md describes a toggleable filter/selection pill (<Chip selected onToggle>Food</Chip>); the RN implementation was static-only, so the reminders priority filter had nothing to build on (QRS-230). A correction, not an ahead: the contract existed and we had implemented half of it. Two implementation notes that are ours rather than the design's — a pressable chip wraps in PressableScale with a touchMin box around the smaller visible pill (the two-layer pattern from IconButton; hitSlop is invisible to RNW layout and to the Playwright gate), and selection uses the accent pair regardless of tone, because selection is a state of the control, not a restatement of what the chip is about — a selected danger pill that stayed red would read as "this filter is dangerous". Nothing to push upstream. The Chip-vs-Badge role split below is now half closed: Chip does the filter job the design gives it, and still also does Badge's status job. QRS-230
2026-07-28correction⚪ n/asrc/ui/Chip (danger tone)Chip gained the danger tone, closing a severity ladder that ran backwards. Reminder priority mapped urgentinfo, so the most severe priority rendered calmer (blue) than high (amber). Recorded as a correction because the design already specifies this tone on its status pill — components/badges/Badge.prompt.md uses <Badge tone="danger"> in its own example — so nothing new was invented and nothing goes upstream. Tokens only (danger-soft / danger). The finding worth carrying, though, is bigger than the tone: the design splits Chip (a toggleable filter or removable pill — "Food", "Veg only ✕") from Badge (a small uppercase status pill with a semantic tone — Live/Draft/Paused). The RN side has only Chip, and every current use of it is a status badge — reminder priority, repeat labels, the dashboard's demo marker. So we have one component doing two specified jobs under the wrong name, which is why it lacked a danger tone in the first place. Splitting it means a new Badge primitive plus a call-site migration; deliberately not bundled into a screen fix. Sync target: none for the tone; the Chip-vs-Badge split is an implementation gap to schedule, not a design change to push. QRS-226
2026-07-28divergence🔴 opensrc/ui/ScreenHeader (new)Screen chrome is now one primitive — title, actions slot, controls slot, and the hairline rule the design specifies and we never had. Pulled from components/app-shell/AppShell.jsx (narrow-container branch) before implementing, per ADR-0015; its spec is borderBottom: 1px solid var(--border-subtle), sticky, title flex 1 + ellipsis, trailing actions. Two halves to this row. Corrective: the rule was absent on every screen, so a title and the first list group ran together with nothing but padding between them — and the headers were written className="px-5" (18px on our scale) above list content padded to 20, rendering each title 2px inboard of the cards it labels. Both now read space[6] through the exported SCREEN_GUTTER. Divergent, deliberately, three ways: (1) no vibrancy blur — the design layers its header over scrolling content at 82% opacity with backdrop-filter; here it is a sibling above the ScrollView so nothing passes beneath it, and a blur would cost expo-blur (a native module, against the app-size budget) to composite against an opaque background; (2) the title is the app's own pushed-route nav bar — centred, 17px, weight 800 — rather than AppShell's left-aligned 15px, because Settings and Profile have rendered exactly that since they were written and internal consistency beats matching a spec calibrated for a ≤560px container in a desktop preview card. (This replaced a left-aligned 22px title the component carried for one iteration, QRS-231: a large title beside a back chevron is a defensible idiom in isolation and was still a third header style in a four-screen app.) The back control uses IconButton's default outline variant and the row reuses Settings' own padding: 20 on both axes, so the chevron and any trailing action land pixel-identically to Settings and Profile; (3) 1dp rule, not StyleSheet.hairlineWidth, which is 0.33dp at 3× and vanishes at some Android densities. Sync target: either AppShell states that its narrow branch is container-scoped chrome, or the design gains a native mobile screen-header spec with the type size, the rule, and no blur. Amended same day (QRS-226): a leading back slot was added, matching AppShell's own structure (a leading element, then the title, then trailing actions) and the chevronLeft idiom Settings/Profile already use. Superseded by QRS-231 the same day: the two-idiom split this note recorded is closed — the primitive now renders Settings/Profile's own nav bar, so those screens adopting it (QRS-225) is structural (sticky + the rule) rather than a visual redesign. QRS-224
2026-07-28correction⚪ n/asrc/ui/SheetThe bottom sheet's 40px top corners now survive on native. rounded-t-3xl was on the file since the component was written and renders correctly on web, but it sits on an Animated.ViewcreateAnimatedComponent(View), a component NativeWind's registry does not contain — which is the same interop mechanism as QRS-190/QRS-203. The radius is now ALSO set inline, derived from radius['3xl'] rather than typed as 40, so the class and the inline value cannot disagree. Recorded as a correction: the design already specifies the soft-corner shell and CLAUDE.md already states it as an invariant — nothing goes upstream, and nobody should later "tidy away" either half. QRS-222
2026-07-27correction⚪ n/apackages/tokens (theme.css dark-mode compilation)Dark variables now declare on .dark:root, preceded by an @cssInterop set darkMode class dark; flag as the file's first rule. No design value changed — every token keeps its palette value. Purely a parser correction, and the one that made the theme render correctly on Android and iOS for the first time: React Native has no CSS cascade, so react-native-css-interop only lifts a block into its dark variable table when the selector is .dark:root/:root[class~="dark"]; a bare .dark silently fell back to the LIGHT values on native (dark cards on a white page) while web was fine. And the selector alone was inert: css-interop defaults darkMode to media, in which mode no class-based dark block registers at all, so the flag had to come first. A @media (prefers-color-scheme) block had been carrying native's dark theme by accident for months. Recorded because packages/tokens/** is design-first under ADR-0015 and every change to it belongs on the record, corrective ones included. Nothing to push upstream — the design project has no equivalent construct — but apps/web will consume this same file and inherits the fix. QRS-206
2026-07-27divergence🔴 opensrc/ui/PressableScale (Android press visual)The Android material ripple is removed; press feedback is now scale + opacity on all three surfaces, with the haptic retained on both natives. This supersedes the ripple half of the 2026-07-26 row below (and the ADR-0011 carve-out it referenced): the ripple shipped as a grey block with sharp corners over rounded controls, because overlay is the 42–62% modal-scrim token and RN masks a bounded android_ripple to the view rect, never its borderRadius. RN offers no corner-radius option, so there was no "fix the ripple" available at this layer. Nine call sites had already been passing rippleColor={null} to switch it off individually. Sync target — this one genuinely needs a design decision, not just a doc update: the design project should specify the intended Android pressed state now that a bounded ripple is off the table (options: no ripple / borderless ripple / a token-driven pressed-fill), and TabBar/Button prompt specs updated accordingly. Until then the implementation is deliberately ahead of the spec and consistent across platforms, which is what product asked for. QRS-207
2026-07-27correction⚪ n/asrc/ui/PressableScale (cssInterop, native)The 2026-07-26 cssInterop registration is now web-only — on native it was removing every style from every PressableScale. No design value changed and nothing needs pushing upstream; this restores the already-approved design on Android and iOS, where controls were rendering with no background, padding, radius or row direction. Cause: Reanimated forwards className down to the wrapped Pressable (which NativeWind registers) on native, so the extra registration on the wrapper hijacked className and discarded the [style, aStyle] array. On web the wrapper does not forward, so the registration is still required there. Recorded because apps/*/src/ui/** is design-first under ADR-0015 and every change to it belongs on the record — including a corrective one. QRS-203
2026-07-27correction⚪ n/apackages/tokens (theme.css generator)Removed the @media (prefers-color-scheme: dark) block; the .dark class is now the only dark source, and color-scheme moved onto :root/.dark. No design value changed — every token keeps its palette value, and in a correctly-configured client nothing renders differently. This is a structural correction: the media query bound the CSS variables to the operating system while useThemeColors() followed the in-app preference, so an explicit "Light" on an OS-dark device rendered white cards and navy text on a dark page. Recorded here because packages/tokens/** is design-first under ADR-0015 and any change to it must be on the record, even a corrective one. Nothing to push upstream — the design project has no equivalent construct — but the DOM stack (apps/web) will consume this same file, so it inherits the fix rather than having to rediscover it. QRS-201
2026-07-27divergence🔴 openpackages/tokens (accent-active, content-tertiary — light)Two token colours fail WCAG AA as text in the light theme, and the implementation is knowingly shipping them. Measured by the new contrast gate: accent-active 2.90:1 on surface (2.58:1 in an accent-soft pill), content-tertiary 3.82:1 on surface (3.48:1 on surface-muted); AA needs 4.5:1. Dark passes. Not corrected in code because these are design values and this surface is design-first — darkening the brand amber changes brand identity, and darkening the tertiary ink changes the whole type hierarchy's feel. Sync target: the design project should re-derive the light-theme accent-active and content-tertiary ramps against a 4.5:1 floor on surface and on their soft/muted companions, then we pull them. Held meanwhile as two exact-colour exemptions in the gate so no other low-contrast pairing can slip in. QRS-202
2026-07-27divergence🔴 openDashboard home (screen composition)The console home leads with the Setu Card, not a revenue stat card. The design's merchant-dashboard/Overview opens with four equal StatCards (Revenue / Orders / QR scans / Avg rating) — correct for a desktop console with real analytics behind it. On mobile in R1 those numbers are sample data, so that layout would put a fabricated revenue figure in the largest type on the merchant's own home screen. Implemented instead: one hero showing real Setu Card status, the four numbers consolidated into a single MetricPanel, and a DemoChip wherever values are illustrative. Also removed the gradient share prompt (PromoCard) — the hero carries that CTA, and two gradient cards competed for the same tap. Screen composition is code-first under ADR-0015, so this needed no prior approval; recorded because the divergence is real. Sync target: the design should gain a mobile Overview variant, and a sample-vs-measured state for any metric it draws.
2026-07-27divergence🔴 openpackages/tokens (sizing.touchMin)Touch-target minimum raised 40 → 44. The token was ported verbatim from the design handoff at 40, which is below Apple HIG (44pt) and Material (48dp) — and it is the root cause of all 12 undersized controls in QRS-191. Chose 44, not 48, so the token matches the Playwright gate exactly rather than maintaining two numbers. Also documented that sizing.control.* are visual sizes that may legitimately be smaller than the tap box. Sync target: the design project's tokens/sizing.css should carry 44 and the visual-vs-tappable distinction, otherwise the DOM stack keeps shipping 40. QRS-193
2026-07-27ahead🔴 opensrc/ui/IconButton (new)Implemented the IconButton the design already specifies, plus a two-layer hit box the design has no concept of. The design defines components/button/IconButton (with aria-label in both its examples) but the RN side never had one, so screens hand-rolled raw <Pressable className="h-10 w-10"> — which is 32×32 on our scale. The new primitive makes accessibilityLabel a required prop. The part that is genuinely ahead: a control may keep a small visible circle while presenting a touchMin tap box, because RN/RNW ignore hitSlop for layout and a slop-only fix would look fixed without being fixed. Same two-layer pattern applied to LanguageSelect chips so the dense header switcher keeps its proportions. Sync target: the design's IconButton/spacing guidance should state the visible-vs-tappable split. QRS-191
2026-07-27correction⚪ n/asrc/ui/PressableScale, src/ui/ButtonDisabled opacity now actually renders, at the design's 0.42. Button had drifted to 0.45 and the value was inert — PressableScale's animated style overrode any caller-set opacity, so disabled buttons never dimmed at all. Dimming moved into the primitive's animated style with a new opacity.disabled token. Recorded as a correction because the design already specifies 0.42; nothing here needs pushing upstream. QRS-194
2026-07-27none⚪ n/asrc/ui/PressableScale (cssInterop)No visual change intended, but a real one occurred — worth an explicit row so it is not mistaken for design drift later. Registering cssInterop made className work on this component for the first time, so padding/radius/border declared in classes at 6 call sites began applying. That is the previously-authored design finally rendering, not a new design decision. Measured example: LanguageSelect chips went 10×17 → 30×25, exactly px-2.5 + py-1. QRS-190
2026-07-26divergence🔴 opensrc/ui/PressableScaleHaptics now fire on Android as well as iOS. The design system has no opinion on haptics (it is a DOM/JSX reference implementation and cannot express them), but this reverses a documented carve-out in ADR-0011 that gave Android the ripple instead of a haptic. Product requires the tactile response itself to be consistent across platforms, with only the visual idiom differing. Sync target: the ADR-0011 carve-out wording and the TabBar/Button prompt specs should state that press feedback is ripple + haptic on Android. QRS-183
2026-07-26ahead🔴 opensrc/ui/BrandIconNew tone="brand" variant rendering official platform colours (BRAND_COLORS, from simple-icons) instead of a monochrome token, used on Profile › Social so the list is scannable by recognition. The design project's Icon has no brand-colour variant. Includes one deliberate sub-rule worth carrying across: X/Twitter resolves against the theme because its brand colour is pure black and vanishes on a dark surface (X itself publishes a white mark for dark backgrounds). Sync target: components/icon/Icon gains a brand-tone variant + the X exception. QRS-187
2026-07-26correction⚪ n/aapp shell tab barRemoved the accent background pill behind the active tab; active state is now icon + label colour only (accent-active vs content-tertiary). This was drift, not a new designcomponents/app-shell/TabBar.jsx already specified background: 'none' with colour-only active state. Recorded so nobody later "syncs" this to the design, which already contains it. QRS-186
2026-07-26divergence🔴 openpackages/tokens (fontMetrics) + src/ui/AppTextLine height is clamped to a per-font floor (Math.max(variantRatio, fontMetrics[family])). The design's typography ratios (1.05–1.55) are valid CSS, where a short line-height merely lets glyphs overflow; in React Native it is a hard clip box, and Baloo 2 needs 1.61× — so every display-face variant clipped. The token ratios are deliberately unchanged because they are correct for the DOM app that shares them. Sync target: the design project's tokens/typography.css should carry a comment that these ratios are DOM-only and RN consumers must clamp — otherwise the next person re-derives this bug. QRS-181
2026-07-26ahead🔴 openProfile › Hours"Add holiday" is disabled while any holiday row is incomplete, with an inline reason line. The design has no empty/partial state for the planned-holidays list. Sync target: the Profile Hours screen needs the disabled + explanatory state drawn. QRS-185
2026-07-28correction⚪ n/asrc/ui/SheetThe height cap now measures the space ACTUALLY available, not the window(H - keyboardOverlap) * 0.9, plus flexShrink: 1 + minHeight: 0 on the content wrapper so a consumer's own ScrollView receives a bounded height. This was a bug, not a design change: on iOS the keyboard overlays the window and reports no inset, so useWindowDimensions().height stayed 812 on an iPhone X with 336dp of keyboard up and the cap over-promised by exactly that much. Any form sheet taller than the remaining space rendered its lower half — including its submit button — outside the visible area, unreachable, because Sheet deliberately does not force-scroll. Android was unaffected (adjustResize shrinks the window, so the reported height already excludes the keyboard) and the iOS Simulator could not reproduce it (it defaults to a hardware keyboard, so nothing ever overlays) — it appeared only on a physical iPhone. No design consultation needed: the design specifies a sheet whose content is reachable, and this restores that. QRS-235.
2026-07-28ahead🔴 opensrc/ui/Banner (new)New RN primitive, built from components/overlays/Banner.jsx + .prompt.md — tone-soft fill, tinted icon, 13.5/700 title, 12.5 description, --r-3xl radius, dismiss-free. One deliberate addition: an optional action. The design has no action affordance and is right not to for its own examples ("Menu published" is a notice, and a notice with a button is a dialog). But the first real consumer is "your alerts are blocked", where the entire point is that the merchant can fix it — a banner that reports a broken state and offers no route out is the silent failure it exists to replace. Optional and absent by default, so design-faithful usages stay faithful. Sync target: add an optional action slot to the design's Banner, or record that our blocked-state banner is a distinct component. QRS-236.
2026-07-28ahead🔴 opensrc/ui/CountBadge (new)The design has NO count badge. components/badges/Badge.jsx is a different component doing a different job: an uppercase, letter-spaced STATUS pill (Live/Draft/Paused/New) that sits inline in content flow. A counter that overlays a 32px icon and stays legible at two digits is not that component with different text — reusing it would have hung a wide pill off the side of the bell. So this leads the design, while borrowing only vocabulary the design already established (11px/700 and --r-pill from Badge; the sibling-overlay + pointerEvents="none" pattern from IconButton). Two decisions worth reconciling: it renders NOTHING at zero (a "0" badge draws the eye to reassure, which is the definition of noise), and it uses the SOFT-fill/STRONG-ink tone pair rather than the filled-red-with-white-text platform default — because there is no danger-contrast token and danger is lighter in dark mode, so white ink would land near 2.6:1. A filled variant needs a real danger-contrast token first, which belongs with QRS-202. Sync target: add a count/notification badge to the design project. QRS-238.
2026-07-28correction⚪ n/apackages/tokens (danger-strong) + Chip / Banner / CountBadgeNew danger-strong token, because the design's own danger + danger-soft pairing fails WCAG AA as text. Measured on the real web export by the theme-consistency gate: danger ink on danger-soft is 3.64:1 in light mode, against AA's 4.5:1 floor. Dark already passed (4.63:1), so this was light-theme-only. danger-strong is 4 74% 44% light (4.94:1) and 4 82% 68% dark (5.23:1), and it is used as the on-soft ink in the three components that render danger text on a danger field. A correction rather than a divergence: the design specifies tone-soft/tone pairs and intends them to be legible, so this restores the intent rather than departing from it — but the design project's own Badge/Chip/Banner still specify var(--danger) as the ink, so the upstream tokens need the same addition or its red pills carry the same failure in the DOM idiom. theme.css updated alongside tokens.ts for exactly that reason. Additive: danger is unchanged, so fills keep their weight. Sync target: add --danger-strong to the design's tokens/colors.css and repoint the three components' danger ink. QRS-240, related QRS-202.
2026-07-29none⚪ n/apackages/tokens + 40 files in src/ui (static-analysis pass)No visual change intended and none expected — recorded because the systemic surface was touched broadly and a silent 40-file diff there is exactly what a later reader would mistake for design drift. All of it is QRS-247 static-analysis remediation, and every edit is type-level or value-selection with the same inputs and the same outputs: props types wrapped in Readonly<> (92 sites, types only, zero runtime); nested ternaries rewritten as guard clauses in Button (the danger-on-variant palette), TextField (border colour precedence + the error/helper caption slot), IconButton (variant → record lookup, matching the idiom Button already used), SettingRow (leading glyph), ScreenHeader (trailing actions/spacer slot), ClockPicker (the 12-o'clock label); and packages/tokens' six-deep HSL sextant chain extracted to a named hslSextant helper with byte-identical conditions and order. One behavioural change, deliberately kept off the visual surface: Sheet moved from Reanimated's deprecated runOnJS to scheduleOnRN — the same UI-runtime-to-RN-thread hop with arguments passed directly, so the drag-to-dismiss and exit-animation unmount paths are the ones to look at on device. Why a none row and not silence: ADR-0015 says record at the moment of divergence, and "we changed 40 systemic files and nothing moved" is a claim worth being on the record so it can be falsified. It is falsifiable by exactly one thing, and it is the thing CLAUDE.md names: npm run e2e:quick (99 passed) covers the WEB bundle only, and jest mocks Reanimated's createAnimatedComponent to identity, so neither gate can see native press feedback, sheet dismissal, or ripple — the seam that produced QRS-203/QRS-206/QRS-207. The Android and iOS builds are the gate for this row. RESULT (2026-07-30): confirmed none. The owner ran the iOS build and reported no issues, then installed and exercised the arm64 release APK and reported it working. That covers the one behavioural change this row flagged — Sheet's runOnJSscheduleOnRN drag-to-dismiss and exit-animation unmount — on both natives, plus press feedback on the primitives the 40-file diff touched. The prediction is now a result, which is the whole reason the row was written as a falsifiable claim rather than left silent.
2026-07-30none⚪ n/anone — screen composition only (7 files)Recorded to state a negative: the systemic surface was NOT touched in this pass, so ADR-0015 required no row and npm run check:design correctly passed without one. This is the second half of QRS-247 — the 17 remaining static-analysis findings — and every edit landed in src/tiers/user/features/** (DashboardHome, NotificationsScreen + NotificationRow, RemindersScreen, SettingsScreen, AuthScreen, ProfileScreen), packages/domain/src/reminders, one Playwright spec, one CI script and the ESLint config. Zero changes to packages/tokens/** or apps/*/src/ui/**, which is what makes this code-first-and-sanctioned rather than a governance question. No visual change intended, and the mechanism is what makes that checkable: each screen's loading/error/empty/content ladder was replaced by a SHELL component holding the identical wrapper elements plus top-level guard returns, so the rendered element tree is unchanged — same View, same SafeAreaView edges, same ScrollView contentContainerStyle, same testIDs. Threading the content's dozen values out as props was the alternative and was rejected precisely because it does not have that property. The duplication this creates is acknowledged, not hidden: five near-identical shells now exist, and the shared @/ui primitive that would collapse them is QRS-259, deferred to the auth round because it IS a systemic-surface change and needs the design pulled first. Still native-gated despite the above. Broad JSX restructuring is the exact shape that produced QRS-203/QRS-206/QRS-207, and e2e:quick (99 passed) is the web bundle only. A fresh arm64 APK is built for the owner's device pass; until that and the iOS build are looked at, this row's none is a prediction.
2026-07-30none⚪ n/asrc/ui/{Calendar,Card,GradientHeadline} (3 files)No visual change intended. Part of QRS-260 (SonarQube CE burn-down, the layer beyond eslint-plugin-sonarjs) — every edit is a React key expression or a string-method swap, checkable by inspection rather than by trusting the description: Calendar — the weekday-letter row's key went from the bare index to `${w}-${i}` (letters repeat: 'S' and 'T' twice each, so the letter alone still could not be the key), and the day-grid View from the bare index to `${view.y}-${view.m0}-${i}` (day numbers repeat across months and the padding cells are all null, so only a month-scoped key is genuinely unique). Neither View/AppText gained, lost, or reordered a style prop. Card — the list-row divider's key now prefers child.key (already assigned by Children.toArray, which is precisely what that API exists for) over the index, falling back to it only for a non-element child. Same rendered output for every existing call site. GradientHeadline — three .replace(/\*/g, …) / .replace(/:/g, …) calls became .replaceAll(…); both do a literal-character global replace and produce byte-identical strings. Not touched, despite being in scope for the same finding: OtpInput and StepProgress also had an index-key finding, and both are suppressed in sonar-project.properties rather than edited — a fixed-length row of decorative, stateless slots has no other identity to key by, and restating the same index under a different name would not be a real fix. Their source is therefore unchanged, and this row does not cover them because there is nothing to cover. Still native-gated regardless of the size of the diff: apps/*/src/ui/** is the merchant app's shared component set, and Card/Calendar render on multiple screens; e2e:quick (part of this pass's gate sweep) is the web bundle only. A fresh arm64 APK covers this commit along with the rest of QRS-260's changes.
2026-08-01none⚪ n/asrc/ui/Icon (new isIconName guard)No visual change, and none possible: this adds an exported type-guard function and touches no rendering path. Recorded because apps/*/src/ui/** is design-first under ADR-0015 and every change to it belongs on the record, including an additive non-visual one. Why it was needed: QRS-249 moved the industry picker's glyphs out of a compile-time array and into business_domains.icon in the DATABASE, so IconName can no longer be assumed at that boundary — and an unknown name is not a benign no-op, because MAP[name] would be undefined and rendering it throws. The alternative was an unchecked cast at the call site, which converts a data-entry typo in the future command center into a crash on the merchant's industry screen. IndustryStep falls back to the generic spark glyph rather than dropping the option, on the reasoning that a merchant losing their own industry from the list is far worse than a slightly wrong picture beside it; a test covers exactly that row. Nothing to push upstream — the design project's Icon has a fixed authored set and no concept of a runtime-supplied name, which is correct for it. QRS-249