feat: add shared versioned Hope palette tokens

This commit is contained in:
snowspeeder committed 2026-10-03 08:36:28 -04:00
1 parent 437745bcca
commit 985c812a75
6 files changed
+1663

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
# Hope shared themes · v1
## Identity and scope
Hope is an authenticated voice companion, not a model dashboard. Users may talk in a dim room or review text in daylight; appearance therefore follows the device by default, with explicit light/dark overrides. The restrained Nearlight system uses opaque tinted surfaces, readable ink and one action accent. **Opal Pink remains the default**, preserving the existing iOS identity rather than the historical web navy palette.
The source of truth is [hope-palette.v1.json](./hope-palette.v1.json), schema version `1`, token version `1.0.0`. Palette IDs are stable persisted/API values: `opal` (display label **Opal Pink**), `graphite`, `ocean`, `sage`, `lavender`. Appearance IDs are `system`, `light`, `dark`; default `system`. Labels are presentation, never storage keys. Theme settings must not affect authentication, voice, conversation or error state. Consumers should normalize unknown IDs to Opal/system.
The extracted native source is [Nearlight.swift](../../voice-ios/Sources/App/Nearlight.swift), lines 11–43. Opal's exact ground/surface/personaSurface/ink/quiet/rosewood/onRosewood values are preserved. Alert channels are deliberately stored as normalized decimal sRGB `[0.675, 0.208, 0.282]` and `[1.0, 0.604, 0.675]`: rounding them to hex would change the existing UIColor values. All other opaque primitives use six-digit sRGB hex. The generator emits round-trip precision channel values, not an approximate color conversion.
## Architecture and outputs
1. **Primitive values**: explicit sRGB palette values and shared alert primitives.
2. **Semantic roles**: `ground`, `surface`, `personaSurface`, `ink`, `quiet`, `accent`, `onAccent`, `alert`, `onAlert`, `focus`, `controlBorder`; aliases are documented in the JSON. `accent` replaces color-specific naming for new code; existing `rosewood` semantics remain a compatibility alias.
3. **Component aliases**: action, input, persona and error roles refer to semantic roles, never raw values.
[generate_hope_themes.py](../scripts/generate_hope_themes.py) uses the standard library only. It validates required roles and contrast **before writing**, generates deterministic artifacts, and embeds source version/SHA256. Change the source and regenerate; do not hand-edit outputs. A changed schema requires a new schema version; removed/repurposed role or palette IDs require a major token version. Additive roles/palettes use a minor version; compatible corrections use a patch version. Native identity changes require explicit product approval.
### Web handoff
Load [hope-themes.css](../web/hope-themes.css) after existing base styles, then consume prefixed variables:
```html
<html data-palette="opal" data-appearance="system">
```
```css
body { background: var(--hope-ground); color: var(--hope-ink); }
button.primary { background: var(--hope-action-background); color: var(--hope-action-foreground); }
input { background: var(--hope-input-background); color: var(--hope-input-foreground); border-color: var(--hope-input-border); }
input::placeholder { color: var(--hope-input-placeholder); opacity: 1; }
:focus-visible { outline: 2px solid var(--hope-focus); outline-offset: 3px; }
```
Attributes belong on the document root, not arbitrary descendants. Missing/stale palette IDs inherit Opal. Missing/stale/system appearance follows `prefers-color-scheme`; explicit light/dark overrides the OS. `color-scheme` matches the effective appearance so native controls match. Theme styles define tokens only; they do not modify layout, attach event listeners, store preferences, or override legacy unprefixed CSS automatically. Consumers own preference loading/persistence and loading the sheet before rendering to avoid a theme flash.
### iOS handoff
[HopeTheme.generated.swift](../generated/HopeTheme.generated.swift) is staged **in this backend repository**, not installed into the iOS target. The iOS teammate may copy the unchanged artifact into the app build and map existing Nearlight roles:
```swift
HopeTheme.color(.ground, palette: .opal, appearance: .system)
HopeTheme.color(.accent, palette: palette, appearance: appearance) // Nearlight.rosewood
HopeTheme.color(.onAccent, palette: palette, appearance: appearance) // Nearlight.onRosewood
```
`HopePalette` and `HopeAppearance` are raw-string enums for persistence. `HopePalette.label` supplies display labels; `HopeTheme.version` identifies the source version. System colors use the native trait-aware UIColor provider; explicit overrides return a fixed color. UIKit/SwiftUI are required. Spacing, typography, radii, motion and native materials remain the responsibility of the existing Nearlight system; this task does not alter them.
## Accessibility and state rules
- Primary/supporting/placeholder/accent/error text is tested at **≥4.5:1** on ground, surface and persona surfaces for every palette and appearance. Action/destructive labels also meet 4.5:1; focus and essential control boundaries meet **≥3:1** against those surfaces.
- All 230 declared foreground/background checks pass. Lowest normal-text contrast is **4.558:1** (Sage light `quiet` on `selectedWash`). Checks use WCAG relative luminance and sRGB transfer functions. Run `--contrast` for the complete measured report. This verifies declared token pairings, **not** every rendered UI, arbitrary layering or hardware display.
- `separator` is decorative only. Native's `ink × 14%`, `accent × 10%` selected wash and `alert × 8%` error wash are precomposited over `surface` into opaque shared tokens. On that surface they preserve the original appearance. Do not reuse the baked wash expecting translucency over a different parent; use a reviewed derivation if needed.
- Essential boundaries use `controlBorder`, not the low-contrast separator. A focus ring needs thickness and offset, not color alone. In forced-colors mode consumers must retain browser/system focus and control boundaries.
- **Errors are not represented only by palette/color**: keep explicit messages, icons or labels, accessibility announcements and recovery actions. `alert` is shared across palettes, not the theme's accent. Selected controls also need checkmarks/pressed or selected semantics. Never use color alone for listening, speaking, offline, loading, success or destructive state.
- Disabled controls may use native disabled semantics; do not dim readable supporting text into a disabled color. Consumers retain reduced-motion handling and Dynamic Type/text zoom support.
## Offline verification (no NAS, no deployment)
```sh
python3 scripts/generate_hope_themes.py
python3 scripts/generate_hope_themes.py --check
python3 scripts/generate_hope_themes.py --check --contrast
python3 -m unittest discover -s tests/unit -p test_hope_themes.py -v
swiftc -frontend -parse generated/HopeTheme.generated.swift
```
[Token tests](../tests/unit/test_hope_themes.py) fix the native identity, ensure shared alert semantics and opaque derivations, reject malformed primitives/missing roles/low contrast, verify deterministic artifact drift, and confirm round-trip Swift channels. The optional Playwright test runs local Chromium against in-memory HTML/CSS with **70 palette/appearance/OS combinations**, including missing/stale values and overrides; it does not call an API or server. It skips if the Playwright module is absent; install Playwright and its Chromium browser for full verification. Swift parsing verifies syntax only; native UIKit compilation and final device rendering belong to the iOS integration qualification. Do not run the legacy top-level live-default test suite for token changes.
+62
View File
@@ -0,0 +1,62 @@
{
"schemaVersion": 1,
"version": "1.0.0",
"defaultPalette": "opal",
"defaultAppearance": "system",
"primitives": {"white": "#FFFFFF", "alertLight": [0.675, 0.208, 0.282], "alertDark": [1.0, 0.604, 0.675]},
"roles": {
"ground": "Screen background",
"surface": "Opaque controls, grouped rows and dock",
"personaSurface": "Opaque square persona stage",
"ink": "Primary text",
"quiet": "Supporting text, placeholders and inactive icons; never reduce opacity for body copy",
"accent": "Primary actions and current selection; Nearlight.rosewood compatibility",
"onAccent": "Primary action label",
"alert": "Error text and destructive actions; independent of palette",
"onAlert": "Label on solid destructive action",
"focus": "Keyboard focus ring against surrounding surfaces",
"controlBorder": "Essential control boundary; not the decorative separator"
},
"sharedRoles": {
"light": {"alert": "alertLight", "onAlert": "white"},
"dark": {"alert": "alertDark", "onAlert": "$ground"}
},
"derivedRoles": {
"separator": {"foreground": "ink", "alpha": 0.14, "background": "surface", "purpose": "Decorative separator only; not an essential boundary"},
"selectedWash": {"foreground": "accent", "alpha": 0.10, "background": "surface", "purpose": "Selected background; pair with selected semantics/checkmark"},
"errorWash": {"foreground": "alert", "alpha": 0.08, "background": "surface", "purpose": "Error background; pair with icon, explicit message and recovery"}
},
"components": {
"actionBackground": "accent", "actionForeground": "onAccent",
"inputBackground": "surface", "inputForeground": "ink", "inputPlaceholder": "quiet",
"inputBorder": "controlBorder", "inputFocus": "focus", "personaBackground": "personaSurface",
"errorBackground": "errorWash", "errorForeground": "alert"
},
"palettes": {
"opal": {
"label": "Opal Pink",
"light": {"ground": "#FFFDFD", "surface": "#FFF7FA", "personaSurface": "#FFF2F7", "ink": "#302B40", "quiet": "#706074", "accent": "#A64270", "onAccent": "#FFFFFF", "focus": "#A64270", "controlBorder": "#706074"},
"dark": {"ground": "#201820", "surface": "#302330", "personaSurface": "#3B2836", "ink": "#FFF2F7", "quiet": "#CBB6C7", "accent": "#F0A8C8", "onAccent": "#201820", "focus": "#F0A8C8", "controlBorder": "#CBB6C7"}
},
"graphite": {
"label": "Graphite",
"light": {"ground": "#FDFDFE", "surface": "#F3F4F6", "personaSurface": "#EDEEF2", "ink": "#272A33", "quiet": "#5E626E", "accent": "#505969", "onAccent": "#FFFFFF", "focus": "#505969", "controlBorder": "#5E626E"},
"dark": {"ground": "#191B20", "surface": "#25282F", "personaSurface": "#30343D", "ink": "#F3F4F7", "quiet": "#BCC1CD", "accent": "#BFC9DC", "onAccent": "#191B20", "focus": "#BFC9DC", "controlBorder": "#BCC1CD"}
},
"ocean": {
"label": "Ocean",
"light": {"ground": "#FCFEFF", "surface": "#F0F7FB", "personaSurface": "#E7F2F9", "ink": "#233442", "quiet": "#536878", "accent": "#27658C", "onAccent": "#FFFFFF", "focus": "#27658C", "controlBorder": "#536878"},
"dark": {"ground": "#15212B", "surface": "#20313F", "personaSurface": "#283D4D", "ink": "#EDF7FF", "quiet": "#B3CBDE", "accent": "#97CEEE", "onAccent": "#15212B", "focus": "#97CEEE", "controlBorder": "#B3CBDE"}
},
"sage": {
"label": "Sage",
"light": {"ground": "#FCFEFC", "surface": "#F0F6F1", "personaSurface": "#E7F0E9", "ink": "#29382D", "quiet": "#596B5D", "accent": "#3C704D", "onAccent": "#FFFFFF", "focus": "#3C704D", "controlBorder": "#596B5D"},
"dark": {"ground": "#19231C", "surface": "#26342A", "personaSurface": "#304236", "ink": "#F0F8F1", "quiet": "#B9CFBE", "accent": "#A5D4AF", "onAccent": "#19231C", "focus": "#A5D4AF", "controlBorder": "#B9CFBE"}
},
"lavender": {
"label": "Lavender",
"light": {"ground": "#FEFDFF", "surface": "#F6F2FC", "personaSurface": "#EEE8F8", "ink": "#342D43", "quiet": "#685E7B", "accent": "#70529C", "onAccent": "#FFFFFF", "focus": "#70529C", "controlBorder": "#685E7B"},
"dark": {"ground": "#201B29", "surface": "#2E273C", "personaSurface": "#3B324D", "ink": "#F7F1FF", "quiet": "#C8BADF", "accent": "#CFB4F4", "onAccent": "#201B29", "focus": "#CFB4F4", "controlBorder": "#C8BADF"}
}
}
}