@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.
- package/AGENTS.md +27 -4
- package/README.md +7 -2
- package/dist/themes/README.md +60 -15
- package/dist/themes/ThemeProvider.svelte +11 -0
- package/dist/themes/__tests__/happyvertical-theme.test.js +231 -0
- package/dist/themes/__tests__/token-aliases.test.js +1 -0
- package/dist/themes/components/ThemeSwitcher.svelte +1 -0
- package/dist/themes/components/ThemeSwitcher.svelte.d.ts.map +1 -1
- package/dist/themes/css-generator.d.ts.map +1 -1
- package/dist/themes/css-generator.js +12 -5
- package/dist/themes/fonts/LICENSE.md +4 -2
- package/dist/themes/fonts/archivo-latin-ext.woff2 +0 -0
- package/dist/themes/fonts/archivo-latin.woff2 +0 -0
- package/dist/themes/fonts/b612-bold-latin.woff2 +0 -0
- package/dist/themes/fonts/b612-latin.woff2 +0 -0
- package/dist/themes/fonts/b612-mono-latin.woff2 +0 -0
- package/dist/themes/happyvertical/index.d.ts +68 -0
- package/dist/themes/happyvertical/index.d.ts.map +1 -0
- package/dist/themes/happyvertical/index.js +402 -0
- package/dist/themes/index.d.ts +3 -1
- package/dist/themes/index.d.ts.map +1 -1
- package/dist/themes/index.js +3 -1
- package/dist/themes/registry.d.ts.map +1 -1
- package/dist/themes/registry.js +3 -0
- package/dist/themes/styles/all.css +6 -4
- package/dist/themes/styles/happyvertical-fonts.css +83 -0
- package/dist/themes/styles/happyvertical.css +561 -0
- package/dist/themes/styles/index.d.ts +3 -0
- package/dist/themes/styles/index.d.ts.map +1 -1
- package/dist/themes/styles/index.js +3 -0
- package/dist/themes/types.d.ts +10 -2
- package/dist/themes/types.d.ts.map +1 -1
- package/dist/themes/types.js +2 -1
- 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;
|
|
71
|
-
components must consume `--smrt-*` tokens rather than installing their
|
|
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,
|
|
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>
|
package/dist/themes/README.md
CHANGED
|
@@ -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,
|
|
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
|
-
- **
|
|
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/
|
|
280
|
+
import '@happyvertical/smrt-ui/themes/styles/all.css';
|
|
281
281
|
|
|
282
282
|
// Or import specific theme only
|
|
283
|
-
import '@smrt/
|
|
284
|
-
import '@smrt/
|
|
285
|
-
import '@smrt/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
664
|
+
The legacy theme system (`@happyvertical/smrt-ui/theme`) is still available but deprecated. To migrate:
|
|
620
665
|
|
|
621
|
-
1. Import from `@smrt/
|
|
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/
|
|
628
|
-
+ import { ThemeProvider } from '@smrt/
|
|
629
|
-
+ import '@smrt/
|
|
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]));
|
|
@@ -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;
|
|
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;
|
|
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
|
|
130
|
+
* Generate helper tokens that do not vary by color scheme (issue #1431).
|
|
131
131
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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
|
|
6
|
-
|
|
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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"}
|