@happyvertical/smrt-ui 0.40.65 → 0.40.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/AGENTS.md +27 -4
  2. package/README.md +7 -2
  3. package/dist/themes/README.md +60 -15
  4. package/dist/themes/ThemeProvider.svelte +11 -0
  5. package/dist/themes/__tests__/happyvertical-theme.test.js +231 -0
  6. package/dist/themes/__tests__/token-aliases.test.js +1 -0
  7. package/dist/themes/components/ThemeSwitcher.svelte +1 -0
  8. package/dist/themes/components/ThemeSwitcher.svelte.d.ts.map +1 -1
  9. package/dist/themes/css-generator.d.ts.map +1 -1
  10. package/dist/themes/css-generator.js +12 -5
  11. package/dist/themes/fonts/LICENSE.md +4 -2
  12. package/dist/themes/fonts/archivo-latin-ext.woff2 +0 -0
  13. package/dist/themes/fonts/archivo-latin.woff2 +0 -0
  14. package/dist/themes/fonts/b612-bold-latin.woff2 +0 -0
  15. package/dist/themes/fonts/b612-latin.woff2 +0 -0
  16. package/dist/themes/fonts/b612-mono-latin.woff2 +0 -0
  17. package/dist/themes/happyvertical/index.d.ts +68 -0
  18. package/dist/themes/happyvertical/index.d.ts.map +1 -0
  19. package/dist/themes/happyvertical/index.js +402 -0
  20. package/dist/themes/index.d.ts +3 -1
  21. package/dist/themes/index.d.ts.map +1 -1
  22. package/dist/themes/index.js +3 -1
  23. package/dist/themes/registry.d.ts.map +1 -1
  24. package/dist/themes/registry.js +3 -0
  25. package/dist/themes/styles/all.css +6 -4
  26. package/dist/themes/styles/happyvertical-fonts.css +83 -0
  27. package/dist/themes/styles/happyvertical.css +561 -0
  28. package/dist/themes/styles/index.d.ts +3 -0
  29. package/dist/themes/styles/index.d.ts.map +1 -1
  30. package/dist/themes/styles/index.js +3 -0
  31. package/dist/themes/types.d.ts +10 -2
  32. package/dist/themes/types.d.ts.map +1 -1
  33. package/dist/themes/types.js +2 -1
  34. package/package.json +2 -2
package/AGENTS.md CHANGED
@@ -55,9 +55,11 @@ components are exempt — they *are* the primitives.
55
55
  | `./i18n` | i18n **client**: `useI18n`, `<Trans>`, `defineMessages`, `renderTemplate` (no `smrt-languages` import — the server resolver stays in `smrt-svelte/i18n/server`) |
56
56
  | `./registry` | `ModuleUIRegistry` for cross-package component discovery |
57
57
  | `./theme` | deprecated compatibility path forwarding to the canonical theme system |
58
- | `./themes` | canonical `ThemeProvider`, context, preset token system (material/glass/studio/smrt), and CSS generation |
58
+ | `./themes` | canonical `ThemeProvider`, context, preset token system (material/glass/studio/smrt/happyvertical), and CSS generation |
59
59
  | `./themes/styles/smrt.css` | static SMRT theme CSS (dark-first amber instrument-panel look) + signature `.smrt-*` flourish utilities |
60
60
  | `./themes/styles/fonts.css` | optional self-hosted `@font-face` for the SMRT type stack (Space Grotesk / Inter / JetBrains Mono) |
61
+ | `./themes/styles/happyvertical.css` | static HappyVertical "Day Shift" brand CSS (enamel/faceplate calm instrument panel) + amber focus rule + `.hv-*` utilities |
62
+ | `./themes/styles/happyvertical-fonts.css` | optional self-hosted `@font-face` for the Day Shift type stack (Archivo / B612 / B612 Mono) |
61
63
  | `./styles/tokens.css` | base token stylesheet |
62
64
 
63
65
  Also internal: `actions/` (`ripple`, `permission`), `display/`, `data/`,
@@ -67,9 +69,30 @@ Also internal: `actions/` (`ripple`, `permission`), `display/`, `data/`,
67
69
 
68
70
  The package publishes `./playground` and keeps its workspace source at
69
71
  `src/svelte/playground.ts`. Previews inherit the shared host's active
70
- Material/Glass/Studio/SMRT preset and light/dark color scheme; preview
71
- components must consume `--smrt-*` tokens rather than installing their own
72
- theme provider or static preset stylesheet.
72
+ Material/Glass/Studio/SMRT/HappyVertical preset and light/dark color scheme;
73
+ preview components must consume `--smrt-*` tokens rather than installing their
74
+ own theme provider or static preset stylesheet.
75
+
76
+ ## Theme presets
77
+
78
+ Adding or editing a preset touches four things that must stay in lockstep:
79
+ `src/themes/<id>/index.ts` (the `Theme`), the `ThemePreset` union in
80
+ `src/themes/types.ts`, the `registry.ts` entry, and the static
81
+ `src/themes/styles/<id>.css`. **The static stylesheet's `[data-theme="<id>"]`
82
+ blocks are `generateThemeCSS()` output verbatim** — regenerate them after any
83
+ token edit, never hand-patch a value. Two guards enforce this: the whole-block
84
+ comparison in `__tests__/happyvertical-theme.test.ts` (per-preset, strictest),
85
+ and the name-surface diff in `__tests__/token-aliases.test.ts` plus
86
+ `scripts/check-svelte-tokens.mjs` (repo-wide). A preset that tunes shadows per
87
+ scheme sets `darkElevation`; one whose type stack names its own monospace face
88
+ sets `fontFamilyMono` (#1586, #1431).
89
+
90
+ The **happyvertical** preset ("Day Shift", #2318) is the HappyVertical brand
91
+ identity and carries hand-authored light *and* dark palettes — never regenerate
92
+ its dark scheme from the light one. Its brand rules are executable in that test:
93
+ green is a lamp/marker/fill and never a text color, dark keeps a blue-graphite
94
+ cast with both accent hues, focus is a 2px amber outline with offset, and every
95
+ other text pairing clears WCAG AA in both schemes.
73
96
 
74
97
  ## Rules
75
98
 
package/README.md CHANGED
@@ -105,8 +105,13 @@ separate so the UI can show a proposal before it changes user state.
105
105
  ## Themes
106
106
 
107
107
  `@happyvertical/smrt-ui/themes` is the canonical theme API and includes the
108
- Material, Glass, Studio, and s-m-r-t presets. The old `/theme` path forwards to the
109
- same provider and context for compatibility.
108
+ Material, Glass, Studio, s-m-r-t, and HappyVertical ("Day Shift") presets. The
109
+ old `/theme` path forwards to the same provider and context for compatibility.
110
+
111
+ Day Shift is the HappyVertical brand identity: a calm instrument panel with an
112
+ enamel ground, faceplate panels on hairline bezels, and a single amber accent
113
+ that also serves as the focus ring. Both its light and dark schemes are
114
+ hand-authored, and every text pairing clears WCAG AA.
110
115
 
111
116
  ```svelte
112
117
  <script>
@@ -1,10 +1,10 @@
1
1
  # SMRT Svelte Themes
2
2
 
3
- A comprehensive, multi-theme system for SMRT Svelte with support for Material Design, Apple Glass, Google AI Studio, and the SMRT instrument-panel aesthetic. All themes include both light and dark modes.
3
+ A comprehensive, multi-theme system for SMRT Svelte with support for Material Design, Apple Glass, Google AI Studio, the SMRT instrument-panel aesthetic, and the HappyVertical brand identity. All themes include both light and dark modes.
4
4
 
5
5
  ## Features
6
6
 
7
- - **4 Theme Presets**: Material (improved M3), Glass (Apple-style), Studio (Google AI Studio flat), SMRT (dark-first amber instrument panel)
7
+ - **5 Theme Presets**: Material (improved M3), Glass (Apple-style), Studio (Google AI Studio flat), SMRT (dark-first amber instrument panel), HappyVertical (the "Day Shift" brand identity — calm enamel/faceplate panel, one amber accent)
8
8
  - **Light & Dark Modes**: Automatic system detection with manual override
9
9
  - **Runtime Theme Switching**: Change themes without page reload
10
10
  - **CSS Custom Properties**: Full theming via CSS variables
@@ -277,19 +277,21 @@ Import the theme styles in your app entry point:
277
277
 
278
278
  ```ts
279
279
  // Import all themes (for runtime switching)
280
- import '@smrt/svelte/themes/styles/all.css';
280
+ import '@happyvertical/smrt-ui/themes/styles/all.css';
281
281
 
282
282
  // Or import specific theme only
283
- import '@smrt/svelte/themes/styles/material.css';
284
- import '@smrt/svelte/themes/styles/glass.css';
285
- import '@smrt/svelte/themes/styles/studio.css';
283
+ import '@happyvertical/smrt-ui/themes/styles/material.css';
284
+ import '@happyvertical/smrt-ui/themes/styles/glass.css';
285
+ import '@happyvertical/smrt-ui/themes/styles/studio.css';
286
+ import '@happyvertical/smrt-ui/themes/styles/smrt.css';
287
+ import '@happyvertical/smrt-ui/themes/styles/happyvertical.css';
286
288
  ```
287
289
 
288
290
  ## ThemeProvider Props
289
291
 
290
292
  | Prop | Type | Default | Description |
291
293
  |------|------|---------|-------------|
292
- | `preset` | `'material' \| 'glass' \| 'studio'` | `'material'` | Theme preset |
294
+ | `preset` | `'material' \| 'glass' \| 'studio' \| 'smrt' \| 'happyvertical'` | `'material'` | Theme preset |
293
295
  | `colorScheme` | `'light' \| 'dark' \| 'system'` | `'system'` | Color scheme preference |
294
296
  | `primaryColor` | `string` | - | Override primary accent color |
295
297
  | `borderRadius` | `'none' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'` | `'md'` | Border radius scale |
@@ -303,7 +305,7 @@ Use the context to switch themes programmatically:
303
305
 
304
306
  ```svelte
305
307
  <script>
306
- import { getThemeContext, ThemeSwitcher } from '@smrt/svelte/themes';
308
+ import { getThemeContext, ThemeSwitcher } from '@happyvertical/smrt-ui/themes';
307
309
 
308
310
  const theme = getThemeContext();
309
311
 
@@ -407,6 +409,49 @@ deep low shadows, and a Space Grotesk / Inter / JetBrains Mono type stack.
407
409
  <span class="smrt-glow">amber drop glow</span>
408
410
  ```
409
411
 
412
+ ### HappyVertical ("Day Shift")
413
+
414
+ The HappyVertical brand identity: a calm instrument panel. A warm-grey enamel
415
+ ground (`#E8EBE9` / `#14181B`) carries raised faceplate panels separated by
416
+ hairline bezels, graphite ink, and exactly one budgeted accent — a signal amber
417
+ (`#9C5300` / `#FFB454`) that doubles as the focus ring. The execution rule is
418
+ "calm is less for the brain to process, not theatre": no glow, no gradients,
419
+ and nothing animates unless it represents a state change.
420
+
421
+ - **Colors**: Enamel/faceplate surfaces, amber primary, instrument-blue
422
+ tertiary, green reserved for status lamps
423
+ - **Elevation**: Flat with hairline bezels; minimal soft shadows above level 2
424
+ - **Typography**: Archivo 500/600 display over B612 body, B612 Mono for legends
425
+ - **Motion**: ~120ms state transitions on a single calm easing
426
+ - **Use for**: HappyVertical Work and any surface that should read as calm
427
+ instrumentation rather than a marketing page
428
+
429
+ Both schemes are hand-authored (the dark scheme is not generated from the light
430
+ one), every text pairing clears WCAG AA, and structure plus the focus ring clear
431
+ 3:1 non-text. **Green is never a text color here** — it is a status lamp,
432
+ marker, or container fill.
433
+
434
+ ```svelte
435
+ <script>
436
+ // Bundled woff2 — Archivo / B612 / B612 Mono (SIL OFL). Omit to fall back to
437
+ // Helvetica / Verdana / ui-monospace stacks.
438
+ import '@happyvertical/smrt-ui/themes/styles/happyvertical-fonts.css';
439
+ </script>
440
+
441
+ <ThemeProvider preset="happyvertical" colorScheme="system">
442
+ <YourApp />
443
+ </ThemeProvider>
444
+ ```
445
+
446
+ **Day Shift Utility Classes** (opt-in, deliberately few):
447
+
448
+ ```html
449
+ <div class="hv-panel">Faceplate panel on a hairline bezel</div>
450
+ <div class="hv-legend">Channels</div> <!-- mono legend + hairline rule -->
451
+ <span class="hv-lamp"></span> <!-- green status lamp; also -idle / -attention -->
452
+ <a class="hv-quiet-link" href="/notes">Quiet link, amber on hover</a>
453
+ ```
454
+
410
455
  ## Project Integration Patterns
411
456
 
412
457
  ### SvelteKit App
@@ -583,7 +628,7 @@ For SSR or static sites, import CSS directly and set data attributes:
583
628
 
584
629
  ```svelte
585
630
  <script>
586
- import { getThemeContext } from '@smrt/svelte/themes';
631
+ import { getThemeContext } from '@happyvertical/smrt-ui/themes';
587
632
 
588
633
  const theme = getThemeContext();
589
634
 
@@ -600,7 +645,7 @@ For SSR or static sites, import CSS directly and set data attributes:
600
645
 
601
646
  ```svelte
602
647
  <script>
603
- import { getThemeContext } from '@smrt/svelte/themes';
648
+ import { getThemeContext } from '@happyvertical/smrt-ui/themes';
604
649
 
605
650
  const theme = getThemeContext();
606
651
 
@@ -616,17 +661,17 @@ For SSR or static sites, import CSS directly and set data attributes:
616
661
 
617
662
  ## Migration from Legacy Theme
618
663
 
619
- The legacy theme system (`@smrt/svelte/theme`) is still available but deprecated. To migrate:
664
+ The legacy theme system (`@happyvertical/smrt-ui/theme`) is still available but deprecated. To migrate:
620
665
 
621
- 1. Import from `@smrt/svelte/themes` instead
666
+ 1. Import from `@happyvertical/smrt-ui/themes` instead
622
667
  2. Wrap app with new `<ThemeProvider>` from themes
623
668
  3. Import theme CSS files
624
669
  4. Update any theme context usage to new API
625
670
 
626
671
  ```diff
627
- - import { ThemeProvider } from '@smrt/svelte/theme';
628
- + import { ThemeProvider } from '@smrt/svelte/themes';
629
- + import '@smrt/svelte/themes/styles/all.css';
672
+ - import { ThemeProvider } from '@happyvertical/smrt-ui/theme';
673
+ + import { ThemeProvider } from '@happyvertical/smrt-ui/themes';
674
+ + import '@happyvertical/smrt-ui/themes/styles/all.css';
630
675
  ```
631
676
 
632
677
  ## Troubleshooting
@@ -367,4 +367,15 @@ $effect(() => {
367
367
  --smrt-glass-surface: var(--smrt-color-surface);
368
368
  --smrt-glass-border: var(--smrt-color-outline);
369
369
  }
370
+
371
+ /* HappyVertical ("Day Shift") focus indicator (#2318).
372
+ * The brand focus ring is part of the preset, so it has to travel on BOTH
373
+ * delivery paths: this mirrors the identical rule in
374
+ * themes/styles/happyvertical.css so an app using ThemeProvider without
375
+ * importing the static stylesheet still gets it. Component-scoped
376
+ * :focus-visible rules are more specific and continue to win. */
377
+ :global([data-theme='happyvertical'] :focus-visible) {
378
+ outline: 2px solid var(--smrt-color-primary);
379
+ outline-offset: 2px;
380
+ }
370
381
  </style>
@@ -0,0 +1,231 @@
1
+ /**
2
+ * HappyVertical "Day Shift" preset contract (#2318).
3
+ *
4
+ * The brand identity ships as a preset, so its guarantees have to be executable
5
+ * rather than described: every text/surface pairing clears WCAG AA in BOTH
6
+ * hand-authored schemes, structure and the amber focus ring clear 3:1 non-text,
7
+ * green is never a text color, and the static stylesheet is the generator's
8
+ * output verbatim (#1586 lockstep — checked on the whole block, not just
9
+ * elevation).
10
+ */
11
+ import { readdirSync, readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { describe, expect, it } from 'vitest';
14
+ import { generateThemeCSS, generateThemeVariables } from '../css-generator.js';
15
+ import { happyverticalTheme } from '../happyvertical/index.js';
16
+ // Resolved from process.cwd() (NOT import.meta.url): the smrt-vitest plugin runs
17
+ // each package's suite with cwd === the package root.
18
+ const staticCss = readFileSync(join(process.cwd(), 'src/themes/styles/happyvertical.css'), 'utf8');
19
+ const themeProviderSource = readFileSync(join(process.cwd(), 'src/themes/ThemeProvider.svelte'), 'utf8');
20
+ /** Relative luminance per WCAG 2.1. */
21
+ function luminance(hex) {
22
+ const value = Number.parseInt(hex.replace('#', ''), 16);
23
+ const channels = [(value >> 16) & 255, (value >> 8) & 255, value & 255];
24
+ const [r, g, b] = channels.map((channel) => {
25
+ const s = channel / 255;
26
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
27
+ });
28
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
29
+ }
30
+ /** WCAG contrast ratio between two opaque hex colors. */
31
+ function contrast(a, b) {
32
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
33
+ return (hi + 0.05) / (lo + 0.05);
34
+ }
35
+ /** on<Role> ↔ <Role> pairs that carry normal text. */
36
+ const ON_PAIRS = [
37
+ ['onPrimary', 'primary'],
38
+ ['onPrimaryContainer', 'primaryContainer'],
39
+ ['onSecondary', 'secondary'],
40
+ ['onSecondaryContainer', 'secondaryContainer'],
41
+ ['onTertiary', 'tertiary'],
42
+ ['onTertiaryContainer', 'tertiaryContainer'],
43
+ ['onError', 'error'],
44
+ ['onErrorContainer', 'errorContainer'],
45
+ ['onWarning', 'warning'],
46
+ ['onWarningContainer', 'warningContainer'],
47
+ ['onSuccess', 'success'],
48
+ ['onSuccessContainer', 'successContainer'],
49
+ ['onSurface', 'surface'],
50
+ ['onSurfaceVariant', 'surfaceVariant'],
51
+ ['onBackground', 'background'],
52
+ ['inverseOnSurface', 'inverseSurface'],
53
+ ];
54
+ const SURFACES = [
55
+ 'surface',
56
+ 'surfaceVariant',
57
+ 'surfaceContainer',
58
+ 'surfaceContainerLow',
59
+ 'surfaceContainerHigh',
60
+ 'surfaceContainerHighest',
61
+ 'surfaceContainerLowest',
62
+ 'surfaceDim',
63
+ 'surfaceBright',
64
+ 'background',
65
+ ];
66
+ const INK = ['onSurface', 'onSurfaceVariant'];
67
+ /**
68
+ * Accent roles that may be rendered as text on a surface. `success` is
69
+ * deliberately absent: the brand rule is that green is a lamp, marker, or fill
70
+ * and never a text color, so it is only held to the 3:1 marker floor below.
71
+ */
72
+ const ACCENT_TEXT = [
73
+ 'primary',
74
+ 'error',
75
+ 'warning',
76
+ 'tertiary',
77
+ 'secondary',
78
+ ];
79
+ const SCHEMES = [
80
+ ['light', happyverticalTheme.light],
81
+ ['dark', happyverticalTheme.dark],
82
+ ];
83
+ describe('happyvertical preset — WCAG AA (#2318)', () => {
84
+ for (const [scheme, palette] of SCHEMES) {
85
+ it(`${scheme}: every on<Role> pairing clears 4.5:1`, () => {
86
+ for (const [on, base] of ON_PAIRS) {
87
+ expect(contrast(palette[on], palette[base]), `${scheme} ${on} on ${base}`).toBeGreaterThanOrEqual(4.5);
88
+ }
89
+ });
90
+ it(`${scheme}: ink clears 4.5:1 on every surface slot`, () => {
91
+ for (const ink of INK) {
92
+ for (const surface of SURFACES) {
93
+ expect(contrast(palette[ink], palette[surface]), `${scheme} ${ink} on ${surface}`).toBeGreaterThanOrEqual(4.5);
94
+ }
95
+ }
96
+ });
97
+ it(`${scheme}: accent text clears 4.5:1 on every surface slot`, () => {
98
+ for (const accent of ACCENT_TEXT) {
99
+ for (const surface of SURFACES) {
100
+ expect(contrast(palette[accent], palette[surface]), `${scheme} ${accent} on ${surface}`).toBeGreaterThanOrEqual(4.5);
101
+ }
102
+ }
103
+ });
104
+ it(`${scheme}: the bezel is never the same value as a surface it borders`, () => {
105
+ // outline-variant is a deliberately quiet hairline, but it still has to
106
+ // exist: authored at #2A3238, it originally collided byte-for-byte with
107
+ // surfaceContainerHighest and surfaceBright, so a bezel-bordered panel on
108
+ // either had a 1.00:1 border — no border at all.
109
+ for (const surface of SURFACES) {
110
+ expect(palette[surface], `${scheme} ${surface} must differ from outline-variant`).not.toBe(palette.outlineVariant);
111
+ }
112
+ });
113
+ it(`${scheme}: outline, focus ring, and status lamp clear 3:1 non-text`, () => {
114
+ for (const surface of SURFACES) {
115
+ for (const structural of ['outline', 'primary', 'success']) {
116
+ expect(contrast(palette[structural], palette[surface]), `${scheme} ${structural} on ${surface}`).toBeGreaterThanOrEqual(3);
117
+ }
118
+ }
119
+ });
120
+ }
121
+ });
122
+ describe('happyvertical preset — brand invariants (#2318)', () => {
123
+ it('hand-authors both schemes rather than deriving dark from light', () => {
124
+ // A generated dark scheme mirrors the light hues; Day Shift's night palette
125
+ // is authored independently and shares no surface value with the day one.
126
+ for (const surface of SURFACES) {
127
+ expect(happyverticalTheme.dark[surface]).not.toBe(happyverticalTheme.light[surface]);
128
+ }
129
+ });
130
+ it('keeps the dark scheme blue-graphite rather than neutral near-black', () => {
131
+ // Blue-graphite means the blue channel leads the red one on every dark
132
+ // surface; a collapse to neutral/near-black would flatten that difference.
133
+ for (const surface of SURFACES) {
134
+ const hex = happyverticalTheme.dark[surface];
135
+ const value = Number.parseInt(hex.replace('#', ''), 16);
136
+ const red = (value >> 16) & 255;
137
+ const blue = value & 255;
138
+ expect(blue, `dark ${surface} (${hex}) should keep a blue cast`).toBeGreaterThan(red);
139
+ }
140
+ });
141
+ it('carries both accent hues into dark — amber and green', () => {
142
+ expect(happyverticalTheme.dark.primary).toBe('#ffb454');
143
+ expect(happyverticalTheme.dark.success).toBe('#53c989');
144
+ expect(happyverticalTheme.dark.primary).not.toBe(happyverticalTheme.dark.success);
145
+ });
146
+ it('is not undermined by a component painting text with the success token', () => {
147
+ // "Green is never text" is only a real guarantee if nothing renders it as
148
+ // one: light `#1e7a4b` measures 4.44:1 on the enamel ground, just under AA.
149
+ // Omitting `success` from ACCENT_TEXT above states the rule; this scan
150
+ // enforces it for the components this package owns.
151
+ const svelteFiles = [];
152
+ const walk = (dir) => {
153
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
154
+ const full = join(dir, entry.name);
155
+ if (entry.isDirectory())
156
+ walk(full);
157
+ else if (entry.name.endsWith('.svelte'))
158
+ svelteFiles.push(full);
159
+ }
160
+ };
161
+ walk(join(process.cwd(), 'src'));
162
+ // Match on the DECLARATION VALUE rather than requiring `var(` to sit
163
+ // adjacent to `color:`, so wrappers count too — `color-mix(…)`,
164
+ // `light-dark(…)`, and `var(--x, var(--smrt-color-success))` all paint
165
+ // text just as directly. `(?![\w-])` keeps `--smrt-color-success-container`
166
+ // (a fill, with its own ink) from reading as a hit.
167
+ const declaration = /(?<![-\w])color\s*:\s*([^;{}]*)/g;
168
+ const successToken = /--smrt-color-success(?![\w-])/;
169
+ // One level of indirection: `--alert-color: var(--smrt-color-success)` and
170
+ // then `color: var(--alert-color)` is Alert.svelte's exact shape today, one
171
+ // edit away from painting text green invisibly.
172
+ const customProperty = /(--[\w-]+)\s*:\s*([^;{}]*)/g;
173
+ const offenders = svelteFiles.filter((file) => {
174
+ const source = readFileSync(file, 'utf8');
175
+ const greenProperties = new Set();
176
+ for (const [, name, value] of source.matchAll(customProperty)) {
177
+ if (successToken.test(value))
178
+ greenProperties.add(name);
179
+ }
180
+ for (const [, value] of source.matchAll(declaration)) {
181
+ if (successToken.test(value))
182
+ return true;
183
+ for (const property of greenProperties) {
184
+ if (value.includes(`var(${property}`))
185
+ return true;
186
+ }
187
+ }
188
+ return false;
189
+ });
190
+ expect(offenders.map((file) => file.replace(process.cwd(), '')), 'success is a lamp/marker/fill, never a text color').toEqual([]);
191
+ });
192
+ it('uses one calm easing for every motion token', () => {
193
+ const curves = new Set(Object.values(happyverticalTheme.easing));
194
+ expect(curves.size).toBe(1);
195
+ });
196
+ it('sets ~120ms as the standard state-transition duration', () => {
197
+ expect(happyverticalTheme.duration.normal).toBe('120ms');
198
+ });
199
+ it('names the B612 Mono stack as the preset monospace family', () => {
200
+ const light = generateThemeVariables(happyverticalTheme, false);
201
+ const dark = generateThemeVariables(happyverticalTheme, true);
202
+ expect(light['--smrt-font-family-mono']).toContain('B612 Mono');
203
+ expect(dark['--smrt-font-family-mono']).toContain('B612 Mono');
204
+ });
205
+ it('defines a 2px amber focus outline with offset on BOTH delivery paths', () => {
206
+ // The preset ships two ways — a static stylesheet import and
207
+ // <ThemeProvider preset="happyvertical">. A focus rule that exists in only
208
+ // one of them means half the consumers silently lose the brand indicator.
209
+ const sources = [
210
+ [staticCss, /\[data-theme="happyvertical"\] :focus-visible \{([^}]*)\}/],
211
+ [
212
+ themeProviderSource,
213
+ /:global\(\[data-theme='happyvertical'\] :focus-visible\) \{([^}]*)\}/,
214
+ ],
215
+ ];
216
+ for (const [source, pattern] of sources) {
217
+ const focusBlock = source.match(pattern);
218
+ expect(focusBlock, 'focus-visible rule should exist').not.toBeNull();
219
+ expect(focusBlock?.[1]).toContain('outline: 2px solid var(--smrt-color-primary)');
220
+ expect(focusBlock?.[1]).toMatch(/outline-offset:\s*2px/);
221
+ }
222
+ });
223
+ });
224
+ describe('happyvertical static CSS lockstep (#1586)', () => {
225
+ for (const scheme of ['light', 'dark']) {
226
+ it(`${scheme} block matches generateThemeCSS output verbatim`, () => {
227
+ const generated = generateThemeCSS(happyverticalTheme, scheme === 'dark');
228
+ expect(staticCss).toContain(generated);
229
+ });
230
+ }
231
+ });
@@ -103,6 +103,7 @@ const STATIC_STYLE_PATHS = {
103
103
  glass: join(packageRoot, 'src/themes/styles/glass.css'),
104
104
  studio: join(packageRoot, 'src/themes/styles/studio.css'),
105
105
  smrt: join(packageRoot, 'src/themes/styles/smrt.css'),
106
+ happyvertical: join(packageRoot, 'src/themes/styles/happyvertical.css'),
106
107
  };
107
108
  function collectDefinedTokens(css) {
108
109
  return new Set([...css.matchAll(/(--smrt-[a-z0-9_-]+)\s*:/gi)].map((match) => match[1]));
@@ -39,6 +39,7 @@ const themeIcons: Record<ThemePreset, string> = {
39
39
  glass: '💎',
40
40
  studio: '◻️',
41
41
  smrt: '🟠',
42
+ happyvertical: '🔶',
42
43
  };
43
44
  </script>
44
45
 
@@ -1 +1 @@
1
- {"version":3,"file":"ThemeSwitcher.svelte.d.ts","sourceRoot":"","sources":["../../../src/themes/components/ThemeSwitcher.svelte.ts"],"names":[],"mappings":"AAQA,UAAU,KAAK;IACb,wBAAwB;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gCAAgC;IAChC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,yDAAyD;IACzD,OAAO,CAAC,EAAE,QAAQ,GAAG,SAAS,GAAG,WAAW,CAAC;IAC7C,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAgFD,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"ThemeSwitcher.svelte.d.ts","sourceRoot":"","sources":["../../../src/themes/components/ThemeSwitcher.svelte.ts"],"names":[],"mappings":"AAQA,UAAU,KAAK;IACb,wBAAwB;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gCAAgC;IAChC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,yDAAyD;IACzD,OAAO,CAAC,EAAE,QAAQ,GAAG,SAAS,GAAG,WAAW,CAAC;IAC7C,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAiFD,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"css-generator.d.ts","sourceRoot":"","sources":["../../src/themes/css-generator.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAWH,OAAO,KAAK,EAQV,KAAK,EAEN,MAAM,YAAY,CAAC;AAEpB;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,4CAA4C;IAC5C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kDAAkD;IAClD,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AA+LD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,OAAO,EACf,OAAO,GAAE,kBAAuB,GAC/B,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA0CxB;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAChC,MAAM,CAIR;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,OAAO,EACf,OAAO,GAAE,kBAAuB,GAC/B,MAAM,CAaR;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,MAAM,CAAC,CAS5D"}
1
+ {"version":3,"file":"css-generator.d.ts","sourceRoot":"","sources":["../../src/themes/css-generator.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAWH,OAAO,KAAK,EAQV,KAAK,EAEN,MAAM,YAAY,CAAC;AAEpB;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,4CAA4C;IAC5C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kDAAkD;IAClD,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAwMD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,OAAO,EACf,OAAO,GAAE,kBAAuB,GAC/B,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA0CxB;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAChC,MAAM,CAIR;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,OAAO,EACf,OAAO,GAAE,kBAAuB,GAC/B,MAAM,CAaR;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,MAAM,CAAC,CAS5D"}
@@ -127,18 +127,25 @@ function generateGlassVariables(glass, prefix) {
127
127
  };
128
128
  }
129
129
  /**
130
- * Generate theme-independent helper tokens (issue #1431).
130
+ * Generate helper tokens that do not vary by color scheme (issue #1431).
131
131
  *
132
- * Monospace font family, named font weights, and z-index stacking levels are
133
- * the same across presets and color schemes. Emitting them as tokens lets
132
+ * Named font weights and z-index stacking levels are identical across every
133
+ * preset and scheme. The monospace family defaults to the same shared stack,
134
+ * but a preset whose type stack names its own mono face overrides it through
135
+ * `Theme.fontFamilyMono` (#2318) — so this token is per-preset, though still
136
+ * the same in both of that preset's schemes. Emitting them as tokens lets
134
137
  * components stop hardcoding magic values and keeps overlay layering / mono
135
138
  * surfaces themeable.
136
139
  */
137
- function generateStaticTokens(prefix) {
140
+ function generateStaticTokens(prefix, fontFamilyMono) {
138
141
  const vars = {};
139
142
  for (const [alias, value] of Object.entries(fontFamilyAliases)) {
140
143
  vars[`${prefix}-font-family-${alias}`] = value;
141
144
  }
145
+ // A preset whose type stack names its own monospace face overrides the
146
+ // shared stack; every other preset keeps the #1431 default.
147
+ if (fontFamilyMono)
148
+ vars[`${prefix}-font-family-mono`] = fontFamilyMono;
142
149
  for (const [name, value] of Object.entries(fontWeightTokens)) {
143
150
  vars[`${prefix}-typography-weight-${name}`] = value;
144
151
  }
@@ -181,7 +188,7 @@ export function generateThemeVariables(theme, isDark, options = {}) {
181
188
  // Easing
182
189
  ...generateEasingVariables(theme.easing, prefix),
183
190
  // Theme-independent helper tokens (mono font, named weights, z-index)
184
- ...generateStaticTokens(prefix),
191
+ ...generateStaticTokens(prefix, theme.fontFamilyMono),
185
192
  // Glass effects (if present)
186
193
  ...generateGlassVariables(theme.glass, prefix),
187
194
  };
@@ -2,14 +2,16 @@
2
2
 
3
3
  The `.woff2` files in this directory are redistributed under the
4
4
  **SIL Open Font License, Version 1.1** (<https://openfontlicense.org>). They are
5
- the latin / latin-ext variable subsets served by Google Fonts, bundled here so
6
- the SMRT theme can load its type stack without a CDN.
5
+ the latin / latin-ext subsets served by Google Fonts, bundled here so the SMRT
6
+ and HappyVertical themes can load their type stacks without a CDN.
7
7
 
8
8
  | File(s) | Family | Copyright | Upstream |
9
9
  |---------|--------|-----------|----------|
10
10
  | `space-grotesk-*.woff2` | Space Grotesk | Copyright © 2020 The Space Grotesk Project Authors | <https://github.com/floriankarsten/space-grotesk> |
11
11
  | `inter-*.woff2` | Inter | Copyright © 2016 The Inter Project Authors | <https://github.com/rsms/inter> |
12
12
  | `jetbrains-mono-*.woff2` | JetBrains Mono | Copyright © 2020 The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> |
13
+ | `archivo-*.woff2` | Archivo | Copyright © 2020 The Archivo Project Authors | <https://github.com/Omnibus-Type/Archivo> |
14
+ | `b612-*.woff2` | B612 / B612 Mono | Copyright © 2012 The B612 Project Authors | <https://github.com/polarsys/b612> |
13
15
 
14
16
  Each family is licensed under the SIL OFL 1.1. The fonts are unmodified subsets;
15
17
  no Reserved Font Names are used for any derivative. See each upstream repository
@@ -0,0 +1,68 @@
1
+ /**
2
+ * HappyVertical Theme — "Day Shift"
3
+ *
4
+ * The HappyVertical brand identity (happyvertical/smrt#2318), signed off
5
+ * 2026-08-12. A calm instrument panel: a warm-grey enamel ground, raised
6
+ * faceplate panels separated by hairline bezels, graphite ink, and exactly one
7
+ * budgeted accent — a signal amber. The execution rule is the owner's, verbatim:
8
+ * **"calm is less for the brain to process, not theatre."** No decorative
9
+ * vocabulary, no glow, nothing animates unless it represents a state change.
10
+ *
11
+ * Unlike a generated dark scheme, **both schemes are hand-authored**. The dark
12
+ * scheme keeps a blue-graphite cast (rather than collapsing to near-black) and
13
+ * carries both accent hues — amber and green — at dark-surface strengths.
14
+ *
15
+ * Named roles (issue vocabulary → token slots):
16
+ *
17
+ * | role | light | dark | slots |
18
+ * |-------------|-----------|-----------|------------------------------------|
19
+ * | enamel | `#E8EBE9` | `#14181B` | `background`, the ground |
20
+ * | faceplate | `#F4F6F5` | `#1C2226` | `surface`, raised panels |
21
+ * | graphite | `#20272B` | `#E4E9E7` | `on-surface` / `on-background` |
22
+ * | etch | `#5A6660` | `#97A29C` | `on-surface-variant` |
23
+ * | bezel | `#C9CFCC` | `#2A3238` | `outline-variant`, hairlines |
24
+ * | amber | `#9C5300` | `#FFB454` | `primary`, incl. the focus ring |
25
+ * | green | `#1E7A4B` | `#53C989` | `success` |
26
+ *
27
+ * `background` is the enamel ground and `surface` is the raised faceplate, so a
28
+ * `Card` (which paints `--smrt-color-surface`) reads as a panel sitting on the
29
+ * ground exactly as in the sign-off mock. The `surface-container-*` ramp is
30
+ * anchored on those two — lightest at `lowest` in light, inverted in dark — and
31
+ * deliberately extends one hand-tuned step past each anchor (`lowest`/`bright`
32
+ * a shade above faceplate, `highest`/`dim` a shade below enamel). Confining all
33
+ * seven levels strictly between enamel and faceplate would compress them into a
34
+ * ~12-value band, and components that stack container levels (DataTable rows
35
+ * under a header, nested panels) would lose their separation entirely.
36
+ *
37
+ * **Green is never text.** It is a status lamp, marker, or container fill only;
38
+ * light `#1E7A4B` measures 4.44:1 on the enamel ground, just under AA. The
39
+ * accent-as-text contrast set in `__tests__/happyvertical-theme.test.ts`
40
+ * excludes `success` and a companion scan enforces that no component in this
41
+ * package paints text with it. Every other text/surface pairing in both schemes
42
+ * clears WCAG AA (4.5:1), and `outline` plus the amber focus ring clear 3:1
43
+ * non-text on every surface slot.
44
+ *
45
+ * `outline` and `outline-variant` are deliberately different strengths, and the
46
+ * distinction is the a11y contract: `outline` is the boundary token and is
47
+ * tuned to clear 3:1 on every surface, while `outline-variant` is the bezel —
48
+ * the decorative hairline between panels — which measures 1.25–1.54:1 in light
49
+ * and 1.03–1.43:1 in dark. That is the look the design signs off on and the
50
+ * same register every other preset here uses for its dividers. Never "fix" the
51
+ * bezel by raising its value; a structure that must be perceivable (a control
52
+ * boundary, a focus ring) should consume `outline` or `primary` instead.
53
+ *
54
+ * The dark bezel sits at the top of the surface ramp, so the ramp deliberately
55
+ * stops below it: `surfaceContainerHighest` and `surfaceBright` were originally
56
+ * authored AT `#2A3238` and a bezel-bordered panel on either was invisible at
57
+ * exactly 1.00:1. They are now distinct, and a test pins that no surface may
58
+ * ever equal `outline-variant` again. Separating them further would mean
59
+ * lifting the top of the ramp past the bezel, which drops `on-surface-variant`
60
+ * below AA on those surfaces — the wrong trade.
61
+ */
62
+ import type { Theme } from '../types.js';
63
+ /**
64
+ * HappyVertical "Day Shift" theme definition
65
+ */
66
+ export declare const happyverticalTheme: Theme;
67
+ export default happyverticalTheme;
68
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/themes/happyvertical/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4DG;AAGH,OAAO,KAAK,EAMV,KAAK,EAEN,MAAM,aAAa,CAAC;AAoWrB;;GAEG;AACH,eAAO,MAAM,kBAAkB,EAAE,KAchC,CAAC;AAEF,eAAe,kBAAkB,CAAC"}