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"}
}
}
}
+160
View File
@@ -0,0 +1,160 @@
// Generated from design/hope-palette.v1.json v1.0.0; SHA256 bb76134be477916d776cda4259f45096fc2dcfcf873918b8703a0b716391a350.
// DO NOT EDIT. python3 scripts/generate_hope_themes.py
import SwiftUI
import UIKit
struct HopeRGB: Sendable {
let red: Double
let green: Double
let blue: Double
init(_ red: Double, _ green: Double, _ blue: Double) {
self.red = red; self.green = green; self.blue = blue
}
var uiColor: UIColor { UIColor(red: CGFloat(red), green: CGFloat(green), blue: CGFloat(blue), alpha: 1) }
}
enum HopeAppearance: String, CaseIterable, Sendable {
case system, light, dark
}
enum HopePalette: String, CaseIterable, Sendable {
case opal
case graphite
case ocean
case sage
case lavender
static let defaultPalette: HopePalette = .opal
var label: String {
switch self {
case .opal: return "Opal Pink"
case .graphite: return "Graphite"
case .ocean: return "Ocean"
case .sage: return "Sage"
case .lavender: return "Lavender"
}
}
}
enum HopeThemeRole: String, CaseIterable, Sendable {
case ground
case surface
case personaSurface
case ink
case quiet
case accent
case onAccent
case alert
case onAlert
case focus
case controlBorder
case separator
case selectedWash
case errorWash
}
enum HopeTheme {
static let version = "1.0.0"
static func rgb(_ role: HopeThemeRole, palette: HopePalette = .defaultPalette, dark: Bool) -> HopeRGB {
switch palette {
case .opal:
switch role {
case .ground: return dark ? HopeRGB(0.12549019607843137, 0.09411764705882353, 0.12549019607843137) : HopeRGB(1, 0.9921568627450981, 0.9921568627450981)
case .surface: return dark ? HopeRGB(0.18823529411764706, 0.13725490196078433, 0.18823529411764706) : HopeRGB(1, 0.9686274509803922, 0.9803921568627451)
case .personaSurface: return dark ? HopeRGB(0.23137254901960785, 0.1568627450980392, 0.21176470588235294) : HopeRGB(1, 0.9490196078431372, 0.9686274509803922)
case .ink: return dark ? HopeRGB(1, 0.9490196078431372, 0.9686274509803922) : HopeRGB(0.18823529411764706, 0.16862745098039217, 0.25098039215686274)
case .quiet: return dark ? HopeRGB(0.796078431372549, 0.7137254901960784, 0.7803921568627451) : HopeRGB(0.4392156862745098, 0.3764705882352941, 0.4549019607843137)
case .accent: return dark ? HopeRGB(0.9411764705882353, 0.6588235294117647, 0.7843137254901961) : HopeRGB(0.6509803921568628, 0.25882352941176473, 0.4392156862745098)
case .onAccent: return dark ? HopeRGB(0.12549019607843137, 0.09411764705882353, 0.12549019607843137) : HopeRGB(1, 1, 1)
case .focus: return dark ? HopeRGB(0.9411764705882353, 0.6588235294117647, 0.7843137254901961) : HopeRGB(0.6509803921568628, 0.25882352941176473, 0.4392156862745098)
case .controlBorder: return dark ? HopeRGB(0.796078431372549, 0.7137254901960784, 0.7803921568627451) : HopeRGB(0.4392156862745098, 0.3764705882352941, 0.4549019607843137)
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
case .onAlert: return dark ? HopeRGB(0.12549019607843137, 0.09411764705882353, 0.12549019607843137) : HopeRGB(1, 1, 1)
case .separator: return dark ? HopeRGB(0.3018823529411765, 0.2509019607843137, 0.2974901960784314) : HopeRGB(0.8863529411764706, 0.8566274509803922, 0.8782745098039215)
case .selectedWash: return dark ? HopeRGB(0.2635294117647059, 0.1894117647058824, 0.24784313725490195) : HopeRGB(0.9650980392156863, 0.8976470588235295, 0.9262745098039216)
case .errorWash: return dark ? HopeRGB(0.2531764705882353, 0.17459450980392158, 0.2271764705882353) : HopeRGB(0.9740000000000001, 0.9077772549019608, 0.9245207843137255)
}
case .graphite:
switch role {
case .ground: return dark ? HopeRGB(0.09803921568627451, 0.10588235294117647, 0.12549019607843137) : HopeRGB(0.9921568627450981, 0.9921568627450981, 0.996078431372549)
case .surface: return dark ? HopeRGB(0.1450980392156863, 0.1568627450980392, 0.1843137254901961) : HopeRGB(0.9529411764705882, 0.9568627450980393, 0.9647058823529412)
case .personaSurface: return dark ? HopeRGB(0.18823529411764706, 0.20392156862745098, 0.23921568627450981) : HopeRGB(0.9294117647058824, 0.9333333333333333, 0.9490196078431372)
case .ink: return dark ? HopeRGB(0.9529411764705882, 0.9568627450980393, 0.9686274509803922) : HopeRGB(0.15294117647058825, 0.16470588235294117, 0.2)
case .quiet: return dark ? HopeRGB(0.7372549019607844, 0.7568627450980392, 0.803921568627451) : HopeRGB(0.3686274509803922, 0.3843137254901961, 0.43137254901960786)
case .accent: return dark ? HopeRGB(0.7490196078431373, 0.788235294117647, 0.8627450980392157) : HopeRGB(0.3137254901960784, 0.34901960784313724, 0.4117647058823529)
case .onAccent: return dark ? HopeRGB(0.09803921568627451, 0.10588235294117647, 0.12549019607843137) : HopeRGB(1, 1, 1)
case .focus: return dark ? HopeRGB(0.7490196078431373, 0.788235294117647, 0.8627450980392157) : HopeRGB(0.3137254901960784, 0.34901960784313724, 0.4117647058823529)
case .controlBorder: return dark ? HopeRGB(0.7372549019607844, 0.7568627450980392, 0.803921568627451) : HopeRGB(0.3686274509803922, 0.3843137254901961, 0.43137254901960786)
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
case .onAlert: return dark ? HopeRGB(0.09803921568627451, 0.10588235294117647, 0.12549019607843137) : HopeRGB(1, 1, 1)
case .separator: return dark ? HopeRGB(0.2581960784313726, 0.2688627450980392, 0.29411764705882354) : HopeRGB(0.8409411764705882, 0.8459607843137256, 0.8576470588235294)
case .selectedWash: return dark ? HopeRGB(0.2054901960784314, 0.22, 0.2521568627450981) : HopeRGB(0.8890196078431373, 0.8960784313725491, 0.9094117647058824)
case .errorWash: return dark ? HopeRGB(0.2134901960784314, 0.19263372549019608, 0.22356862745098044) : HopeRGB(0.9307058823529412, 0.8969537254901961, 0.9100894117647059)
}
case .ocean:
switch role {
case .ground: return dark ? HopeRGB(0.08235294117647059, 0.12941176470588237, 0.16862745098039217) : HopeRGB(0.9882352941176471, 0.996078431372549, 1)
case .surface: return dark ? HopeRGB(0.12549019607843137, 0.19215686274509805, 0.24705882352941178) : HopeRGB(0.9411764705882353, 0.9686274509803922, 0.984313725490196)
case .personaSurface: return dark ? HopeRGB(0.1568627450980392, 0.23921568627450981, 0.30196078431372547) : HopeRGB(0.9058823529411765, 0.9490196078431372, 0.9764705882352941)
case .ink: return dark ? HopeRGB(0.9294117647058824, 0.9686274509803922, 1) : HopeRGB(0.13725490196078433, 0.20392156862745098, 0.25882352941176473)
case .quiet: return dark ? HopeRGB(0.7019607843137254, 0.796078431372549, 0.8705882352941177) : HopeRGB(0.3254901960784314, 0.40784313725490196, 0.47058823529411764)
case .accent: return dark ? HopeRGB(0.592156862745098, 0.807843137254902, 0.9333333333333333) : HopeRGB(0.15294117647058825, 0.396078431372549, 0.5490196078431373)
case .onAccent: return dark ? HopeRGB(0.08235294117647059, 0.12941176470588237, 0.16862745098039217) : HopeRGB(1, 1, 1)
case .focus: return dark ? HopeRGB(0.592156862745098, 0.807843137254902, 0.9333333333333333) : HopeRGB(0.15294117647058825, 0.396078431372549, 0.5490196078431373)
case .controlBorder: return dark ? HopeRGB(0.7019607843137254, 0.796078431372549, 0.8705882352941177) : HopeRGB(0.3254901960784314, 0.40784313725490196, 0.47058823529411764)
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
case .onAlert: return dark ? HopeRGB(0.08235294117647059, 0.12941176470588237, 0.16862745098039217) : HopeRGB(1, 1, 1)
case .separator: return dark ? HopeRGB(0.2380392156862745, 0.30086274509803923, 0.35247058823529415) : HopeRGB(0.828627450980392, 0.8615686274509804, 0.8827450980392156)
case .selectedWash: return dark ? HopeRGB(0.17215686274509806, 0.25372549019607843, 0.31568627450980397) : HopeRGB(0.8623529411764705, 0.9113725490196078, 0.9407843137254902)
case .errorWash: return dark ? HopeRGB(0.19545098039215686, 0.22510431372549022, 0.28129411764705886) : HopeRGB(0.9198823529411766, 0.9077772549019608, 0.9281286274509805)
}
case .sage:
switch role {
case .ground: return dark ? HopeRGB(0.09803921568627451, 0.13725490196078433, 0.10980392156862745) : HopeRGB(0.9882352941176471, 0.996078431372549, 0.9882352941176471)
case .surface: return dark ? HopeRGB(0.14901960784313725, 0.20392156862745098, 0.16470588235294117) : HopeRGB(0.9411764705882353, 0.9647058823529412, 0.9450980392156862)
case .personaSurface: return dark ? HopeRGB(0.18823529411764706, 0.25882352941176473, 0.21176470588235294) : HopeRGB(0.9058823529411765, 0.9411764705882353, 0.9137254901960784)
case .ink: return dark ? HopeRGB(0.9411764705882353, 0.9725490196078431, 0.9450980392156862) : HopeRGB(0.1607843137254902, 0.2196078431372549, 0.17647058823529413)
case .quiet: return dark ? HopeRGB(0.7254901960784313, 0.8117647058823529, 0.7450980392156863) : HopeRGB(0.34901960784313724, 0.4196078431372549, 0.36470588235294116)
case .accent: return dark ? HopeRGB(0.6470588235294118, 0.8313725490196079, 0.6862745098039216) : HopeRGB(0.23529411764705882, 0.4392156862745098, 0.30196078431372547)
case .onAccent: return dark ? HopeRGB(0.09803921568627451, 0.13725490196078433, 0.10980392156862745) : HopeRGB(1, 1, 1)
case .focus: return dark ? HopeRGB(0.6470588235294118, 0.8313725490196079, 0.6862745098039216) : HopeRGB(0.23529411764705882, 0.4392156862745098, 0.30196078431372547)
case .controlBorder: return dark ? HopeRGB(0.7254901960784313, 0.8117647058823529, 0.7450980392156863) : HopeRGB(0.34901960784313724, 0.4196078431372549, 0.36470588235294116)
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
case .onAlert: return dark ? HopeRGB(0.09803921568627451, 0.13725490196078433, 0.10980392156862745) : HopeRGB(1, 1, 1)
case .separator: return dark ? HopeRGB(0.25992156862745097, 0.3115294117647059, 0.2739607843137255) : HopeRGB(0.8319215686274509, 0.8603921568627451, 0.8374901960784313)
case .selectedWash: return dark ? HopeRGB(0.19882352941176473, 0.26666666666666666, 0.2168627450980392) : HopeRGB(0.8705882352941177, 0.9121568627450981, 0.8807843137254902)
case .errorWash: return dark ? HopeRGB(0.21709803921568627, 0.2359278431372549, 0.2055294117647059) : HopeRGB(0.9198823529411766, 0.9041694117647059, 0.8920501960784314)
}
case .lavender:
switch role {
case .ground: return dark ? HopeRGB(0.12549019607843137, 0.10588235294117647, 0.1607843137254902) : HopeRGB(0.996078431372549, 0.9921568627450981, 1)
case .surface: return dark ? HopeRGB(0.1803921568627451, 0.15294117647058825, 0.23529411764705882) : HopeRGB(0.9647058823529412, 0.9490196078431372, 0.9882352941176471)
case .personaSurface: return dark ? HopeRGB(0.23137254901960785, 0.19607843137254902, 0.30196078431372547) : HopeRGB(0.9333333333333333, 0.9098039215686274, 0.9725490196078431)
case .ink: return dark ? HopeRGB(0.9686274509803922, 0.9450980392156862, 1) : HopeRGB(0.20392156862745098, 0.17647058823529413, 0.2627450980392157)
case .quiet: return dark ? HopeRGB(0.7843137254901961, 0.7294117647058823, 0.8745098039215686) : HopeRGB(0.40784313725490196, 0.3686274509803922, 0.4823529411764706)
case .accent: return dark ? HopeRGB(0.8117647058823529, 0.7058823529411765, 0.9568627450980393) : HopeRGB(0.4392156862745098, 0.3215686274509804, 0.611764705882353)
case .onAccent: return dark ? HopeRGB(0.12549019607843137, 0.10588235294117647, 0.1607843137254902) : HopeRGB(1, 1, 1)
case .focus: return dark ? HopeRGB(0.8117647058823529, 0.7058823529411765, 0.9568627450980393) : HopeRGB(0.4392156862745098, 0.3215686274509804, 0.611764705882353)
case .controlBorder: return dark ? HopeRGB(0.7843137254901961, 0.7294117647058823, 0.8745098039215686) : HopeRGB(0.40784313725490196, 0.3686274509803922, 0.4823529411764706)
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
case .onAlert: return dark ? HopeRGB(0.12549019607843137, 0.10588235294117647, 0.1607843137254902) : HopeRGB(1, 1, 1)
case .separator: return dark ? HopeRGB(0.29074509803921567, 0.263843137254902, 0.3423529411764706) : HopeRGB(0.8581960784313726, 0.8408627450980392, 0.8866666666666667)
case .selectedWash: return dark ? HopeRGB(0.24352941176470588, 0.20823529411764707, 0.3074509803921569) : HopeRGB(0.9121568627450981, 0.8862745098039215, 0.9505882352941177)
case .errorWash: return dark ? HopeRGB(0.24596078431372548, 0.18902588235294118, 0.27047058823529413) : HopeRGB(0.941529411764706, 0.8897380392156863, 0.9317364705882354)
}
}
}
static func color(_ role: HopeThemeRole, palette: HopePalette = .defaultPalette,
appearance: HopeAppearance = .system) -> Color {
let light = rgb(role, palette: palette, dark: false).uiColor
let dark = rgb(role, palette: palette, dark: true).uiColor
switch appearance {
case .light: return Color(uiColor: light)
case .dark: return Color(uiColor: dark)
case .system:
return Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark ? dark : light
})
}
}
}
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Generate deterministic web/Swift theme artifacts; no third-party dependencies."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SPEC = ROOT / "design/hope-palette.v1.json"
OUTPUTS = {"css": ROOT / "web/hope-themes.css", "swift": ROOT / "generated/HopeTheme.generated.swift"}
def rgb(value):
"""Normalized sRGB; decimal channels preserve the native alert exactly."""
if isinstance(value, str) and re.fullmatch(r"#[0-9A-F]{6}", value):
return tuple(int(value[i:i + 2], 16) / 255 for i in (1, 3, 5))
if isinstance(value, list) and len(value) == 3 and all(
isinstance(c, (int, float)) and not isinstance(c, bool) and 0 <= c <= 1 for c in value
):
return tuple(value)
raise ValueError(f"Invalid opaque sRGB value: {value!r}")
def resolve(spec, palette, appearance):
colors = {role: rgb(value) for role, value in spec["palettes"][palette][appearance].items()}
for role, reference in spec["sharedRoles"][appearance].items():
colors[role] = colors[reference[1:]] if reference.startswith("$") else rgb(spec["primitives"][reference])
for role, derivation in spec["derivedRoles"].items():
fg, bg, alpha = colors[derivation["foreground"]], colors[derivation["background"]], derivation["alpha"]
colors[role] = tuple(f * alpha + b * (1 - alpha) for f, b in zip(fg, bg))
return colors
def luminance(color):
channels = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in color]
return sum(c * weight for c, weight in zip(channels, (0.2126, 0.7152, 0.0722)))
def contrast(foreground, background):
a, b = sorted((luminance(foreground), luminance(background)))
return (b + 0.05) / (a + 0.05)
def contrast_checks(spec):
results = []
for palette in spec["palettes"]:
for appearance in ("light", "dark"):
colors = resolve(spec, palette, appearance)
pairs = [(fg, bg, 4.5) for fg in ("ink", "quiet", "accent", "alert")
for bg in ("ground", "surface", "personaSurface")]
pairs += [("ink", "selectedWash", 4.5), ("quiet", "selectedWash", 4.5),
("alert", "errorWash", 4.5), ("onAccent", "accent", 4.5),
("onAlert", "alert", 4.5)]
pairs += [(fg, bg, 3.0) for fg in ("focus", "controlBorder")
for bg in ("ground", "surface", "personaSurface")]
for fg, bg, minimum in pairs:
results.append({"palette": palette, "appearance": appearance, "foreground": fg,
"background": bg, "ratio": contrast(colors[fg], colors[bg]), "minimum": minimum})
return results
def validate(spec):
if spec["schemaVersion"] != 1 or not re.fullmatch(r"\d+\.\d+\.\d+", spec["version"]):
raise ValueError("Unsupported schema or invalid semantic version")
if spec["defaultPalette"] not in spec["palettes"] or spec["defaultAppearance"] != "system":
raise ValueError("Invalid theme defaults")
required = set(spec["roles"])
for palette, data in spec["palettes"].items():
if not re.fullmatch(r"[a-z][a-z0-9-]*", palette) or not data["label"]:
raise ValueError("Invalid palette identifier/label")
for appearance in ("light", "dark"):
if set(data[appearance]) | set(spec["sharedRoles"][appearance]) != required:
raise ValueError(f"Incomplete semantic roles: {palette}/{appearance}")
resolve(spec, palette, appearance)
for derivation in spec["derivedRoles"].values():
if not 0 <= derivation["alpha"] <= 1:
raise ValueError("Invalid derived opacity")
if not set(spec["components"].values()) <= required | set(spec["derivedRoles"]):
raise ValueError("Unknown component role")
failures = [row for row in contrast_checks(spec) if row["ratio"] < row["minimum"]]
if failures:
raise ValueError("Contrast verification failed: " + json.dumps(failures))
def kebab(value):
return re.sub(r"[A-Z]", lambda match: "-" + match[0].lower(), value)
def number(value):
return repr(float(value)) if not float(value).is_integer() else str(int(value))
def css_color(color):
return "rgb(" + " ".join(number(c * 255) for c in color) + ")"
def css_block(spec, palette, appearance, selector):
lines = [selector + " {", f" color-scheme: {appearance};"]
for role, color in resolve(spec, palette, appearance).items():
lines.append(f" --hope-primitive-{kebab(role)}: {css_color(color)};")
lines.append(f" --hope-{kebab(role)}: var(--hope-primitive-{kebab(role)});")
# Opal's native API is retained as an alias, not a new meaning for other palettes.
lines += [" --hope-rosewood: var(--hope-accent);", " --hope-on-rosewood: var(--hope-on-accent);"]
for component, role in spec["components"].items():
lines.append(f" --hope-{kebab(component)}: var(--hope-{kebab(role)});")
return "\n".join(lines + ["}"])
def render_css(spec, digest):
chunks = [f"/* Generated from design/hope-palette.v1.json v{spec['version']}; SHA256 {digest}.\n * DO NOT EDIT. python3 scripts/generate_hope_themes.py\n * Set data-palette and data-appearance on <html>; missing/unknown values fall back to Opal/system.\n */"]
default = spec["defaultPalette"]
# Root is also the fallback for stale/unknown persisted palette or appearance IDs.
chunks.append(css_block(spec, default, "light", ":root"))
for palette in spec["palettes"]:
chunks.append(css_block(spec, palette, "light", f':root[data-palette="{palette}"]'))
system_blocks = [css_block(spec, default, "dark", ':root:not([data-appearance="light"]):not([data-appearance="dark"])')]
for palette in spec["palettes"]:
system_blocks.append(css_block(spec, palette, "dark", f':root[data-palette="{palette}"]:not([data-appearance="light"]):not([data-appearance="dark"])'))
chunks.append("@media (prefers-color-scheme: dark) {\n" + "\n".join(system_blocks) + "\n}")
for appearance in ("light", "dark"):
chunks.append(css_block(spec, default, appearance, f':root[data-appearance="{appearance}"]'))
for palette in spec["palettes"]:
chunks.append(css_block(spec, palette, appearance, f':root[data-palette="{palette}"][data-appearance="{appearance}"]'))
return "\n\n".join(chunks) + "\n"
def swift_color(color):
return "HopeRGB(" + ", ".join(number(c) for c in color) + ")"
def render_swift(spec, digest):
lines = [f"// Generated from design/hope-palette.v1.json v{spec['version']}; SHA256 {digest}.",
"// DO NOT EDIT. python3 scripts/generate_hope_themes.py", "import SwiftUI", "import UIKit", "",
"struct HopeRGB: Sendable {", " let red: Double", " let green: Double", " let blue: Double",
" init(_ red: Double, _ green: Double, _ blue: Double) {",
" self.red = red; self.green = green; self.blue = blue", " }",
" var uiColor: UIColor { UIColor(red: CGFloat(red), green: CGFloat(green), blue: CGFloat(blue), alpha: 1) }", "}", "",
"enum HopeAppearance: String, CaseIterable, Sendable {", " case system, light, dark", "}", "",
"enum HopePalette: String, CaseIterable, Sendable {"]
for palette in spec["palettes"]:
lines.append(f" case {palette}")
lines += [f" static let defaultPalette: HopePalette = .{spec['defaultPalette']}", " var label: String {", " switch self {"]
for palette, data in spec["palettes"].items():
lines.append(f" case .{palette}: return {json.dumps(data['label'])}")
lines += [" }", " }", "}", "", "enum HopeThemeRole: String, CaseIterable, Sendable {"]
for role in list(spec["roles"]) + list(spec["derivedRoles"]):
lines.append(f" case {role}")
lines += ["}", "", "enum HopeTheme {", f" static let version = {json.dumps(spec['version'])}",
" static func rgb(_ role: HopeThemeRole, palette: HopePalette = .defaultPalette, dark: Bool) -> HopeRGB {", " switch palette {"]
for palette in spec["palettes"]:
lines += [f" case .{palette}:", " switch role {"]
light, dark = resolve(spec, palette, "light"), resolve(spec, palette, "dark")
for role in light:
lines.append(f" case .{role}: return dark ? {swift_color(dark[role])} : {swift_color(light[role])}")
lines += [" }"]
lines += [" }", " }", "", " static func color(_ role: HopeThemeRole, palette: HopePalette = .defaultPalette,", " appearance: HopeAppearance = .system) -> Color {",
" let light = rgb(role, palette: palette, dark: false).uiColor",
" let dark = rgb(role, palette: palette, dark: true).uiColor",
" switch appearance {", " case .light: return Color(uiColor: light)", " case .dark: return Color(uiColor: dark)",
" case .system:", " return Color(uiColor: UIColor { traits in", " traits.userInterfaceStyle == .dark ? dark : light", " })", " }", " }", "}"]
return "\n".join(lines) + "\n"
def artifacts(spec, source_bytes):
validate(spec)
digest = hashlib.sha256(source_bytes).hexdigest()
return {"css": render_css(spec, digest), "swift": render_swift(spec, digest)}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="Check committed outputs without rewriting")
parser.add_argument("--contrast", action="store_true", help="Print every verified contrast pair")
args = parser.parse_args()
source = SPEC.read_bytes()
spec = json.loads(source)
outputs = artifacts(spec, source)
if args.contrast:
for row in contrast_checks(spec):
print(f"{row['palette']}/{row['appearance']} {row['foreground']}/{row['background']}: {row['ratio']:.3f}:1 >= {row['minimum']}:1")
stale = []
for key, path in OUTPUTS.items():
if args.check:
if not path.exists() or path.read_text() != outputs[key]:
stale.append(str(path.relative_to(ROOT)))
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(outputs[key])
if stale:
parser.exit(1, "Stale theme artifacts: " + ", ".join(stale) + "\n")
minimum = min(row['ratio'] for row in contrast_checks(spec) if row['minimum'] == 4.5)
print(f"Hope themes v{spec['version']}: {len(spec['palettes'])} palettes, light/dark; minimum text contrast {minimum:.3f}:1; artifacts {'verified' if args.check else 'generated'}.")
if __name__ == "__main__":
main()
+117
View File
@@ -0,0 +1,117 @@
"""Offline token contract, native identity, generator drift and browser cascade tests."""
import copy
import importlib.util
import json
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[2]
module_spec = importlib.util.spec_from_file_location("hope_theme_generator", ROOT / "scripts/generate_hope_themes.py")
generator = importlib.util.module_from_spec(module_spec)
module_spec.loader.exec_module(generator)
class HopeThemeTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.source = generator.SPEC.read_bytes()
cls.spec = json.loads(cls.source)
def test_contract_defaults_and_identifiers(self):
self.assertEqual(self.spec["defaultPalette"], "opal")
self.assertEqual(self.spec["defaultAppearance"], "system")
self.assertEqual(list(self.spec["palettes"]), ["opal", "graphite", "ocean", "sage", "lavender"])
generator.validate(self.spec)
def test_opal_exact_native_identity(self):
# Fixed from voice-ios/Sources/App/Nearlight.swift; no external checkout needed in CI.
expected = {
"light": ["#FFFDFD", "#FFF7FA", "#FFF2F7", "#302B40", "#706074", "#A64270", "#FFFFFF"],
"dark": ["#201820", "#302330", "#3B2836", "#FFF2F7", "#CBB6C7", "#F0A8C8", "#201820"],
}
for appearance, values in expected.items():
for role, value in zip(("ground", "surface", "personaSurface", "ink", "quiet", "accent", "onAccent"), values):
self.assertEqual(self.spec["palettes"]["opal"][appearance][role], value)
self.assertEqual(self.spec["primitives"]["alertLight"], [0.675, 0.208, 0.282])
self.assertEqual(self.spec["primitives"]["alertDark"], [1.0, 0.604, 0.675])
def test_all_required_contrast_pairs(self):
checks = generator.contrast_checks(self.spec)
self.assertEqual(len(checks), 230)
for row in checks:
with self.subTest(**row):
self.assertGreaterEqual(row["ratio"], row["minimum"])
self.assertAlmostEqual(generator.contrast((1, 1, 1), (0, 0, 0)), 21)
def test_bad_contrast_rejected(self):
changed = copy.deepcopy(self.spec)
changed["palettes"]["opal"]["light"]["quiet"] = "#FFFDFD"
with self.assertRaisesRegex(ValueError, "Contrast verification failed"):
generator.validate(changed)
def test_missing_role_rejected(self):
changed = copy.deepcopy(self.spec)
del changed["palettes"]["ocean"]["dark"]["focus"]
with self.assertRaisesRegex(ValueError, "Incomplete semantic roles"):
generator.validate(changed)
def test_invalid_primitive_rejected(self):
for value in ("#abc", [1.1, 0, 0], [True, 0, 0], "transparent"):
with self.subTest(value=value), self.assertRaises(ValueError):
generator.rgb(value)
def test_alert_is_not_palette_accent(self):
for appearance in ("light", "dark"):
alerts = {generator.resolve(self.spec, palette, appearance)["alert"] for palette in self.spec["palettes"]}
self.assertEqual(len(alerts), 1)
def test_derived_roles_are_opaque_surface_composites(self):
colors = generator.resolve(self.spec, "opal", "light")
for role, fg, alpha in (("separator", "ink", .14), ("selectedWash", "accent", .10), ("errorWash", "alert", .08)):
self.assertEqual(colors[role], tuple(f * alpha + b * (1 - alpha) for f, b in zip(colors[fg], colors["surface"])))
def test_generated_artifacts_are_current_and_deterministic(self):
first = generator.artifacts(self.spec, self.source)
self.assertEqual(first, generator.artifacts(self.spec, self.source))
for key, path in generator.OUTPUTS.items():
self.assertEqual(path.read_text(), first[key], str(path))
def test_swift_channels_round_trip(self):
for palette in self.spec["palettes"]:
for appearance in ("light", "dark"):
for color in generator.resolve(self.spec, palette, appearance).values():
self.assertEqual(tuple(float(generator.number(c)) for c in color), color)
class HopeThemeBrowserTests(unittest.TestCase):
def test_browser_palette_appearance_cascade(self):
try:
from playwright.sync_api import sync_playwright
except ImportError:
self.skipTest("Optional Playwright unavailable; install it to verify browser cascade")
spec = json.loads(generator.SPEC.read_bytes())
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
try:
page = browser.new_page()
page.set_content('<html><head></head><body><span id="probe">Hope</span></body></html>')
page.add_style_tag(content=generator.OUTPUTS["css"].read_text())
page.add_style_tag(content='#probe { color: var(--hope-ink); background-color: var(--hope-ground); }')
for system in ("light", "dark"):
page.emulate_media(color_scheme=system)
for palette in [None, "obsolete"] + list(spec["palettes"]):
for appearance in (None, "obsolete", "system", "light", "dark"):
page.evaluate('([p, a]) => { for (const [key, value] of [["palette", p], ["appearance", a]]) { if (value === null) delete document.documentElement.dataset[key]; else document.documentElement.dataset[key] = value; } }', [palette, appearance])
actual = page.locator("#probe").evaluate('(el) => { const s = getComputedStyle(el); return [s.color, s.backgroundColor]; }')
resolved_palette = palette if palette in spec["palettes"] else "opal"
resolved_appearance = appearance if appearance in ("light", "dark") else system
colors = generator.resolve(spec, resolved_palette, resolved_appearance)
expected = ["rgb(" + ", ".join(str(round(c * 255)) for c in colors[role]) + ")" for role in ("ink", "ground")]
with self.subTest(system=system, palette=palette, appearance=appearance):
self.assertEqual(actual, expected)
finally:
browser.close()
if __name__ == "__main__":
unittest.main()
+1057
View File
File diff suppressed because it is too large. Load diff