UI-SPEC — --primary-accessible binding-surface set (OBJ-2425)
Scope
deriveCustomTheme() clamps --primary-accessible against flat --card and --background only (ionic_frontend/src/lib/customTheme.ts:216-227), while every real consumer paints the token on a tinted composite. This spec is the design contract that closes that gap: it enumerates every surface text-primary-accessible actually renders on, in both modes, each traceable to a named anchor, and rules on the three questions Orion left open. It is the mirror of OBJ-1984's "binding-surface set" for --accent-accessible, and it is what Task 2's PRIMARY_ACCESSIBLE_LIGHT_SURFACES / PRIMARY_ACCESSIBLE_DARK_SURFACES constants must be built from.
No new screens, no new components. Two existing markup anchors are re-pointed (§5) — those are the only visual changes this spec asks for beyond the token value itself.
Upstream: Orion's task package on OBJ-2425. Precedent: OBJ-1984 (accent). Origin: OBJ-2056 §9.
Method
Every number below comes from customTheme.ts's own hslToRgb8 / relativeLuminance / contrastRatio / compositeOver math, re-run over the picker's full reachable input space — hue 0–360 × saturation 0–100 at CUSTOM_THEME_SEED_LIGHTNESS, 36,461 inputs, matching ThemeCreator.vue:31-55. All seven rows of Orion's §1a table reproduce exactly (light /10 over --card: 23,542 failures, worst 3.97; dark /10 over --card: 14,891, worst 4.00; and so on), which is the check that the analysis is measuring the real deriver and not a paraphrase of it.
Component / screen inventory
ionic_frontend/src/lib/customTheme.ts— modified (Task 2, Codi)ionic_frontend/src/theme/supporter-themes.css— modified (Task 3, Codi — required, see §6)ionic_frontend/src/components/goal/GoalPartnerChip.vue— modified (§5.1, re-anchor)ionic_frontend/src/components/dashboard/streamlined/AmbientSidebar.vue— modified (§5.2, re-anchor)ionic_frontend/src/components/GoalEventForm.vue— reused, but see §7 (inline-style tint)- All other 78 anchors — reused unchanged
1. The binding-surface set — light
Ink is --primary-accessible; P is the derived --primary fill alpha-composited over the named backdrop. Every row clears 4.5:1 in the fixed set. "Binding" is how many of the 36,461 inputs that surface is what forces the ink to move; "sole" is how many it is the only thing forcing it.
| ID | Backdrop | Effective alpha | Anchor (file:line) | Binding | Sole |
|---|---|---|---|---|---|
L1 | --card | — (flat) | settings/CoachingAccordion.vue:36 | 0 | 0 |
L2 | --background | — (flat) | views/teams/JoinTeamView.vue:90 | 0 | 0 |
L3 | --muted | — (flat) | settings/rows/SettingsRowAction.vue:3 leading disc ← views/Settings.vue:268 | 0 | 0 |
L4 | --background | --card @ .70 (.glass-card) | ui/QueryStateCard.vue:90 | 0 | 0 |
L5 | --background | --secondary @ .30 | views/NotificationHistory.vue:13 | 0 | 0 |
L6 | --card | --muted @ .30 | communities/EditorialCarousel.vue:28 | 0 | 0 |
L7 | --card | P .05 | teams/TeamPrivacyContractView.vue:48,49,53,57 | 0 | 0 |
L8 | --card | P .08 | goal/GoalPartnerChip.vue:28,44 | 0 | 0 |
L9 | --card | P .10 | connected-apps/ProviderCard.vue:36 | 0 | 0 |
L10 | --background | P .10 | teams/PointsExplainerSheet.vue:30 (row is bg-background, :28) | 22 | 0 |
L11 | --background | .glass-card, then P .10 | ui/NotificationsPanel.vue:23 · dashboard/DashboardTertiaryDisclosure.vue:10 | 0 | 0 |
L12 | --background | --secondary @ .30, then P .10 | views/NotificationHistory.vue:34 | 341 | 0 |
L13 | --card | --muted @ .30, then P .10 | views/Communities.vue:205 (hover:bg-primary/10) · communities/FoundingMemberOpportunitiesSection.vue:31 | 0 | 0 |
L14 | --card | P .12 | GoalEventForm.vue:48 (inline hsl(var(--primary) / 0.12)) | 0 | 0 |
L15 | --card | P .145 — folded | ui/NotificationItem.vue:13-16 (/10 disc on the unread /5 row, :8) · dashboard/DashboardMigrationNote.vue:6-7 (/10 disc on the /5 panel, :3) | 118 | 0 |
L16 | --background | .glass-card, then P .15 | dashboard/streamlined/AmbientSidebar.vue:33-36 after the §5.2 re-anchor | 1,942 | 0 |
L17 | --card | P .20 | teams/TeamBillingTab.vue:45-46 | 31,278 | 29,336 |
17 surfaces. L17 is the constraint that does essentially all the work in light mode.
2. The binding-surface set — dark
| ID | Backdrop | Effective alpha | Anchor (file:line) | Binding | Sole |
|---|---|---|---|---|---|
D1–D17 | — | as L1–L17 | same anchors | see below | |
D18 | --card | #fff @ .03 | teams/PrivacyStrip.vue:7 (dark:bg-white/[0.03]) · teams/TeamMemberActivitySheet.vue:45 | 0 | 0 |
D19 | --card | #fff @ .03, then P .20 | teams/TeamBillingTab.vue:44-46 (dark:bg-white/[0.03] card + /20 disc) | 22,159 | 18,241 |
19 surfaces. Two dark-only notes:
.glass-cardresolves to--card@ .40 in dark (theme/tailwind.css:108-110), not .70. The constant must switch on mode; it is not one shared surface list with a mode-swapped base.D3(flat--muted) is sole-binding on 2,395 inputs. In dark,--mutedis203 33% 17%— lighter than both--card(11%) and--background(8%), so it is the hardest flat dark surface, and the current predicate never looks at it. This is a second, independent hole in the same predicate that Desi's original finding did not reach.
3. Rulings on Orion's open questions
(a) Is bg-primary/20 (GoalPartnerChip.vue:22) in the set? — Split answer.
The /20 alpha is in (L17/D19): it is the app's highest deliberately-authored primary tint and TeamBillingTab.vue:45 anchors it unavoidably, over --card in light and over --card + white/3 in dark.
The GoalPartnerChip nesting is out. That anchor is a /20 disc sitting inside a /8 chip (:7), which folds to an effective .264 at rest and .296 on hover (hover:bg-primary/12). Those two are not designed surfaces — they are an accident of composition, and they are expensive: keeping them costs +3 lightness points of median drift in light (3 → 6) and pushes the worst case to 12 light / 14 dark. A hover state on one 24px avatar disc would become the single constraint governing the brand ink of every custom theme in the app. Re-anchor it instead (§5.1).
The folding rule this establishes, so the next person does not have to re-litigate it: fold stacked same-colour tints into one effective alpha when the result lands at or below .20, the highest authored primary tint (that is L15, and it matches the accent precedent, whose folds land at .145 / .19). When a fold lands above .20, it is a composition bug — fix the markup, do not darken the token.
(b) Are tints over --background in the set? — Yes.
L10 (PointsExplainerSheet.vue:28-30 puts a /10 disc on a bg-background row) and L12 (NotificationHistory.vue:34, a /10 pill on the bg-secondary/30 page). Neither is sole-binding, so neither changes the derived value — but both are real, both cost nothing to include, and leaving them out would make the constant a lie about what the app renders.
(c) Remedy per failing preset surface — §6.
Does Task 3 fire? — Yes. 7 of 10 light presets and 5 of 10 dark presets fail on this set. Task 3 is required, with the concrete values in §6.
4. What the deriver must do (Task 2 contract)
PRIMARY_ACCESSIBLE_LIGHT_SURFACES → L1 … L17 (17 entries)
PRIMARY_ACCESSIBLE_DARK_SURFACES → D1 … D19 (19 entries)Both seeded exactly as today — primaryLightL / primaryDarkL — hue and saturation preserved, lightness-only movement, clampLightness's outward search unchanged.
Two entries need a shape the accent constants do not have, and this is the one place Task 2 is more than a copy of clearsAccentAccessibleSurfaces():
- Layer stacks.
L11,L12,L13,L16,D19composite a fixed token (--card@ .70,--secondary@ .30,--muted@ .30,#fff@ .03) and then the primary tint. The accent helper's{backdrop, alpha}pair cannot express this. Generalise it to an ordered layer list —{base, layers: [{token, alpha}]}— with the existing accent surfaces expressed as the zero-or-one-layer case, so there is still exactly one contrast helper with two callers. - Flat non-card/background backdrops.
L3/D3(--muted) is not reachable from a'card' | 'background'union. Widen the backdrop token, don't special-case it.
Measured outcome of this predicate over all 36,461 inputs:
| Mode | Surfaces | Unsolved | Inputs moved | Median shift | p95 | Max | Derived ink L range |
|---|---|---|---|---|---|---|---|
| light | 17 | 0 | 85.8% | 3 | 6 | 8 | 20–45 |
| dark | 19 | 0 | 67.3% | 6 | 10 | 13 | 34–77 |
Light matches Orion's prototyped "median 3, max 8" exactly. Dark is worse than Orion's prototype (max 13, not 10) — D3 and D19 are the difference, and neither was in the prototyped scope. No input is unsolvable, no ink collapses toward black or white, hue and saturation never move.
5. Markup re-anchors
5.1 GoalPartnerChip.vue:21-22 — shared-state avatar disc
The disc is currently bg-primary/20 nested inside the chip's bg-primary/8 (:7, hover /12).
Change the shared-state disc fill from bg-primary/20 to bg-card. It then mirrors the unshared state's structure exactly (:14, a bg-muted disc), and it reads better: a lifted disc cut out of the tinted chip separates the initials from the chip far more cleanly than a deeper wash of the same hue, which at /20-over-/8 is a muddy 3-point step nobody can see anyway. Ink stays text-primary-accessible. The surface becomes L1/D1, which is never binding.
Everything else about the chip is unchanged — the /8 fill, the hover:bg-primary/12, the border, the text. Only the disc moves.
5.2 AmbientSidebar.vue:33-35 — Level chip plinth
The chip is an inline gradient, linear-gradient(135deg, hsl(var(--primary) / 0.25), hsl(var(--coach) / 0.15)), carrying a text-primary-accessible icon. The .25 stop exceeds every token-authored primary tint in the app.
Lower the primary stop from / 0.25 to / 0.15 (the coach stop is unchanged — it is a different token and out of scope). That is L16, which the set covers. Keeping .25 instead would cost median 3 → 5 and max 8 → 11 in light, for one decorative 28px plinth in the streamlined sidebar's Level card.
Both re-anchors are ink-preserving and fill-only: no layout, no type, no motion, no copy.
6. Preset remedies (Task 3 — required)
Measured against the §1/§2 set. shift is the lightness delta; hue and saturation never move.
Light — 7 of 10 fail. Every failure's worst surface is L17 (primary/20 over --card).
| Preset | Current | Worst | Remedy | Shift | Worst after |
|---|---|---|---|---|---|
| sunset | 15 85% 42% | 4.04 | 15 85% 39% | −3 | 4.56 |
| ocean | 185 75% 29% | 4.18 | 185 75% 27% | −2 | 4.66 |
| forest | 155 70% 29% | 4.09 | 155 70% 27% | −2 | 4.60 |
| rose | 340 75% 47% | 3.95 | 340 75% 43% | −4 | 4.58 |
| ember | 5 80% 43% | 4.21 | 5 80% 41% | −2 | 4.53 |
| lavender | 270 60% 56% | 4.04 | 270 60% 53% | −3 | 4.55 |
| gold-rush | 43 90% 29% | 4.19 | 43 90% 27% | −2 | 4.69 |
| midnight | 250 70% 52% | 5.76 | — passes | 0 | 5.76 |
| slate | 220 15% 39% | 5.01 | — passes | 0 | 5.01 |
default (:root) | 203 78% 35% | 4.56 | — passes | 0 | 4.56 |
Dark — 5 of 10 fail. Every failure's worst surface is D19 (primary/20 over white/3 over --card).
| Preset | Current | Worst | Remedy | Shift | Worst after |
|---|---|---|---|---|---|
| midnight | 250 70% 69% | 3.64 | 250 70% 74% | +5 | 4.57 |
| rose | 340 75% 65% | 4.01 | 340 75% 69% | +4 | 4.56 |
| ember | 5 80% 62% | 3.87 | 5 80% 68% | +6 | 4.64 |
| lavender | 270 60% 72% | 4.48 | 270 60% 73% | +1 | 4.65 |
| slate | 220 15% 58% | 3.78 | 220 15% 64% | +6 | 4.62 |
| sunset, ocean, forest, gold-rush, default | — | ≥ 4.59 | — pass | 0 | — |
Ink only. No --primary fill value moves, so no gradient, button, hover or focus-ring knock-on.
One disagreement with Orion's §2 table, recorded because it changes a disposition: Orion read ocean's light /10-over---background as "4.50 squeaks". It rounds to 4.50 but lands under the floor. It does not change ocean's outcome — ocean fails L17 at 4.18 regardless and needs the remedy either way — but the guard must compare on the unrounded value, not a 2-dp display.
7. Token discipline — two anchors bypass it
Neither is in scope for this ticket; both are why a class-only grep undercounts the set.
GoalEventForm.vue:48-49paints its/12tint via:style="{ background: 'hsl(var(--primary) / 0.12)' }".AmbientSidebar.vue:35andMomentumStat.vue:69-75(TINT_BG) do the same via inline gradients and a style map.
They are real binding surfaces (L14, L16) and they are in the set. Flagged so a future sweep greps hsl(var(--primary) as well as bg-primary/.
8. State catalog
Not applicable in the usual sense — this spec changes one token's derived value, not a component's states. The states that do move, and their delta:
- Default / hover / active / focus: unchanged behaviour. Ink darkens ≤8 points (light) or lightens ≤13 (dark).
--focus-ringaliases--primary-accessible(design_system/css/tokens.css:35) and its ≥3:1 guard (tokenDrift.spec.ts:485-500) moves ink away from every surface in both modes, so those ratios only increase — re-run that block, don't rewrite it. - Dark mode: first-class and specified in full (§2). Dark was the more broken of the two modes and is the one that moves further.
- Motion-reduced: N/A — no motion in scope. Motion gate: n/a — nothing in this spec animates.
- Loading / empty / error / disabled: unchanged; they inherit the token like everything else.
9. Microcopy
None. No user-facing string changes in this spec.
10. Accessibility
- Floor: WCAG AA 4.5:1 on all 17 light / 19 dark surfaces, for all 36,461 reachable picker inputs. 0 failures, 0 unsolved — matching
--accent-accessible's bar exactly. - Non-obvious: flat
--mutedis the hardest flat dark surface (lighter than card and background), the opposite of the light-mode intuition. Anyone reasoning about dark contrast from the light-mode ordering will get this backwards. - Non-obvious:
.glass-card's alpha differs by mode (.70 light / .40 dark), so "the card surface" is two different composites. - Hue fidelity: the two worst-case picks Orion asked to be eyeballed read as the user's chosen hue after the shift — hue 336 stays magenta, hue 288 stays purple, hue 219 stays blue, hue 0 stays red, in both modes. See the proof sheets in
docs/ui-specs/mockups/obj-2425/. - Tap targets, focus order, ARIA: untouched.
11. Storybook story brief
No new component, so no new story. Codi should confirm the existing ThemeCreator story still renders the picker's nudge indicator correctly once the clamp moves further than it used to — ThemeCreator.vue:66 derives isNudged from --primary, which does not move, so this should be a no-op check rather than a change.
12. Acceptance criteria for Codi
- [ ]
PRIMARY_ACCESSIBLE_LIGHT_SURFACEShas 17 entries matching §1;PRIMARY_ACCESSIBLE_DARK_SURFACEShas 19 matching §2 - [ ] One generalised surface-clearing helper, two callers (accent + primary) —
clearsAccentAccessibleSurfaces()is widened, not copied - [ ] The helper supports ordered layer stacks and a
--mutedbackdrop (§4) - [ ] Step-5 grid sweep (1,533 inputs) asserts 0 failures on every surface, both modes; guard fails on the pre-fix tree
- [ ] Full 36,461-input sweep attached to the PR as one-off evidence
- [ ]
--primary,--primary-foreground,--ring,--accent*outputs byte-identical to today - [ ]
tokenDrift.spec.ts:485-500focus-ring guard re-run and green - [ ] Preset values updated per §6; the widened preset guard fails on the pre-fix values
- [ ]
GoalPartnerChip.vue:22disc →bg-card;AmbientSidebar.vue:35primary stop →/ 0.15 - [ ] Frontend statement coverage ≥80%
13. Findings routed out — not this ticket
--accent-accessiblehas the same--mutedhole.ACCENT_ACCESSIBLE_DARK_SURFACES(customTheme.ts:130-138) checkscardandbackgroundbut not--muted, which is the hardest flat dark surface.accent-accessibleink sits onbg-mutedatsettings/CoachingAccordion.vue:45and in the sameSettingsRowActionleading disc. Not measured here — OBJ-1984's set was ruled correct on its own terms and I did not re-open it. Worth a scoped check.tokenDrift.spec.ts:395-425guards one surface out of seventeen. Even after Task 3 the preset guard should carry the full set, or the next preset added will re-open this on a surface nobody is watching.