@sustainablewebsites/verdant-design 0.1.0
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/LICENSE +15 -0
- package/README.md +45 -0
- package/package.json +32 -0
- package/src/preset.mjs +240 -0
- package/src/theme-toggle.js +117 -0
- package/src/theme.mjs +116 -0
- package/src/tokens.mjs +120 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ivan Storck
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# @sustainablewebsites/verdant-design
|
|
2
|
+
|
|
3
|
+
Verdant is a [PandaCSS](https://panda-css.com) preset with defaults aligned to selected [W3C Web Sustainability Guidelines](https://w3c.github.io/sustainableweb-wsg/): system fonts instead of downloaded ones, only the CSS a page actually uses, contrast-verified semantic colour tokens, and dark mode, reduced motion and increased contrast respected out of the box.
|
|
4
|
+
|
|
5
|
+
It ships tokens, recipes, layout primitives and a theme contract — not a component library. You write your own markup.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -D @sustainablewebsites/verdant-design
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
// panda.config.ts
|
|
13
|
+
import { defineConfig } from '@pandacss/dev'
|
|
14
|
+
import { verdantPreset } from '@sustainablewebsites/verdant-design'
|
|
15
|
+
|
|
16
|
+
export default defineConfig({
|
|
17
|
+
preflight: true,
|
|
18
|
+
presets: ['@pandacss/dev/presets', verdantPreset],
|
|
19
|
+
include: ['./src/**/*.{js,jsx,ts,tsx}'],
|
|
20
|
+
outdir: 'styled-system',
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
To make an explicit Light/Dark choice work you also need the theme runtime shipped at
|
|
25
|
+
`@sustainablewebsites/verdant-design/theme-toggle.js`. Without it you get `prefers-color-scheme` only.
|
|
26
|
+
|
|
27
|
+
**[Full installation guide, token reference and known gaps →](https://github.com/ivanoats/verdant-wsg-demo/blob/main/docs/INSTALL.md)**
|
|
28
|
+
|
|
29
|
+
## What's in it
|
|
30
|
+
|
|
31
|
+
- **Tokens** — a 7-step spacing scale, 4 radii, 6 type sizes, system `sans`/`mono` stacks. No web fonts.
|
|
32
|
+
- **Semantic colours** — `surface.*`, `ink.*`, `accent.*`, `border.*`, status colours, each with light and dark values. 17 pairings contrast-verified in both themes; worst text pairing 4.80:1, worst functional 4.35:1.
|
|
33
|
+
- **Recipes** — `button`, `card`, `fieldInput`, `switchTrack`, `spinner`, `skeleton`. 44px minimum targets; animation bounded and gated behind `prefers-reduced-motion`.
|
|
34
|
+
- **Patterns** — `container`, `stack`, `grid`, replacing Panda's built-ins so defaults come from Verdant's scale.
|
|
35
|
+
- **Theme contract** — System/Light/Dark from one token set, plus a `prefers-contrast: more` set verified at 7:1 text and 4.5:1 boundaries.
|
|
36
|
+
|
|
37
|
+
## Status
|
|
38
|
+
|
|
39
|
+
0.x — the API may change between minor versions. See [known gaps](https://github.com/ivanoats/verdant-wsg-demo/blob/main/docs/INSTALL.md#known-gaps).
|
|
40
|
+
|
|
41
|
+
Live demo and reference implementation: [verdant-wsg-demo.netlify.app](https://verdant-wsg-demo.netlify.app/)
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
ISC
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sustainablewebsites/verdant-design",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A Green Web Design System with Sustainable Defaults following W3C WSG guidelines",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "ISC",
|
|
7
|
+
"author": "Ivan Storck",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ivanoats/verdant-wsg-demo.git",
|
|
11
|
+
"directory": "packages/verdant-design"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/ivanoats/verdant-wsg-demo/blob/main/docs/INSTALL.md",
|
|
14
|
+
"keywords": ["pandacss", "panda-preset", "design-system", "sustainability", "wsg", "accessibility"],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/preset.mjs",
|
|
17
|
+
"./tokens": "./src/tokens.mjs",
|
|
18
|
+
"./theme": "./src/theme.mjs",
|
|
19
|
+
"./theme-toggle.js": "./src/theme-toggle.js"
|
|
20
|
+
},
|
|
21
|
+
"files": ["src", "README.md", "LICENSE"],
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@pandacss/dev": ">=1.0.0"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/preset.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// The Verdant design system as a PandaCSS preset: tokens, semantic colours,
|
|
2
|
+
// recipes, layout primitives and the theme/contrast contract.
|
|
3
|
+
//
|
|
4
|
+
// Everything here is theme data. The site that consumes it supplies its own
|
|
5
|
+
// `include`/`outdir` (see the repo's panda.config.ts for a worked example).
|
|
6
|
+
import { definePreset } from '@pandacss/dev'
|
|
7
|
+
import { fontFamilyTokens, spacingTokens, radiiTokens, fontSizeTokens, lineHeightTokens } from './tokens.mjs'
|
|
8
|
+
import { explicitThemeVars, highContrastVars, semanticColorTokens, themeOverrideAttr, themeResolvedAttr } from './theme.mjs'
|
|
9
|
+
|
|
10
|
+
const important = (vars) =>
|
|
11
|
+
Object.fromEntries(Object.entries(vars).map(([name, value]) => [name, `${value} !important`]))
|
|
12
|
+
|
|
13
|
+
export const verdantPreset = definePreset({
|
|
14
|
+
name: 'verdant-design',
|
|
15
|
+
conditions: {
|
|
16
|
+
dark: '@media (prefers-color-scheme: dark)',
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
theme: {
|
|
20
|
+
extend: {
|
|
21
|
+
tokens: {
|
|
22
|
+
fonts: Object.fromEntries(Object.entries(fontFamilyTokens).map(([key, value]) => [key, { value }])),
|
|
23
|
+
spacing: Object.fromEntries(Object.entries(spacingTokens).map(([key, value]) => [key, { value }])),
|
|
24
|
+
radii: Object.fromEntries(Object.entries(radiiTokens).map(([key, value]) => [key, { value }])),
|
|
25
|
+
fontSizes: Object.fromEntries(Object.entries(fontSizeTokens).map(([key, value]) => [key, { value }])),
|
|
26
|
+
lineHeights: Object.fromEntries(Object.entries(lineHeightTokens).map(([key, value]) => [key, { value }])),
|
|
27
|
+
},
|
|
28
|
+
semanticTokens: {
|
|
29
|
+
colors: {
|
|
30
|
+
...semanticColorTokens,
|
|
31
|
+
},
|
|
32
|
+
shadows: {
|
|
33
|
+
sm: {
|
|
34
|
+
value: {
|
|
35
|
+
base: '0 1px 2px rgba(28,26,21,0.08), 0 1px 1px rgba(28,26,21,0.04)',
|
|
36
|
+
_dark: '0 1px 2px rgba(0,0,0,0.45), 0 1px 1px rgba(0,0,0,0.3)',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
recipes: {
|
|
42
|
+
button: {
|
|
43
|
+
className: 'btn',
|
|
44
|
+
base: {
|
|
45
|
+
fontSize: 'bodySm', lineHeight: 'bodySm', fontWeight: '600', letterSpacing: '0.02em',
|
|
46
|
+
minHeight: '44px', paddingBlock: '2', paddingInline: '4', borderRadius: 'md', border: '1px solid transparent',
|
|
47
|
+
cursor: 'pointer', outlineOffset: '2px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', textDecorationLine: 'none',
|
|
48
|
+
_motionSafe: { transition: 'background-color 120ms ease, border-color 120ms ease' },
|
|
49
|
+
_disabled: { opacity: '0.5', cursor: 'not-allowed' },
|
|
50
|
+
},
|
|
51
|
+
variants: {
|
|
52
|
+
variant: {
|
|
53
|
+
primary: {
|
|
54
|
+
background: 'accent', color: 'accent.ink', _hover: { background: 'accent.strong' },
|
|
55
|
+
// Toggle buttons: the pressed look follows aria-pressed, so the
|
|
56
|
+
// visual and announced state can't drift apart.
|
|
57
|
+
'&[aria-pressed=true]': { background: 'accent.strong' },
|
|
58
|
+
},
|
|
59
|
+
secondary: { background: 'transparent', color: 'ink', borderColor: 'border.control', _hover: { borderColor: 'accent', color: 'accent' } },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
defaultVariants: { variant: 'primary' },
|
|
63
|
+
},
|
|
64
|
+
card: {
|
|
65
|
+
className: 'card',
|
|
66
|
+
base: {
|
|
67
|
+
background: 'surface.200', border: '1px solid', borderColor: 'border',
|
|
68
|
+
borderRadius: 'md', padding: '4',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
fieldInput: {
|
|
72
|
+
className: 'fieldInput',
|
|
73
|
+
base: {
|
|
74
|
+
fontSize: 'body', lineHeight: 'body', fontFamily: 'sans',
|
|
75
|
+
width: '100%', minHeight: '44px', paddingBlock: '2', paddingInline: '3', border: '1px solid', borderColor: 'border.control',
|
|
76
|
+
borderRadius: 'sm', background: 'surface.200', color: 'ink',
|
|
77
|
+
_placeholder: { color: 'ink.placeholder', opacity: '1' },
|
|
78
|
+
_focusVisible: { outline: '2px solid', outlineColor: 'focusRing', outlineOffset: '1px', borderColor: 'transparent' },
|
|
79
|
+
_readOnly: { background: 'surface.100', color: 'ink.muted' },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
switchTrack: {
|
|
83
|
+
className: 'switchTrack',
|
|
84
|
+
base: {
|
|
85
|
+
position: 'relative', width: '44px', height: '44px', borderRadius: 'full',
|
|
86
|
+
background: 'transparent', border: 'none', cursor: 'pointer', padding: '0', flex: 'none',
|
|
87
|
+
_before: {
|
|
88
|
+
content: '""',
|
|
89
|
+
position: 'absolute',
|
|
90
|
+
top: '10px',
|
|
91
|
+
left: '0',
|
|
92
|
+
width: '44px',
|
|
93
|
+
height: '24px',
|
|
94
|
+
borderRadius: 'full',
|
|
95
|
+
background: 'border.control',
|
|
96
|
+
},
|
|
97
|
+
_motionSafe: { _before: { transition: 'background-color 150ms ease' } },
|
|
98
|
+
// State lives in aria-checked at runtime, so the track colour follows
|
|
99
|
+
// it here (inside the recipe layer, so it outranks the base colour).
|
|
100
|
+
'&[aria-checked="true"]': { _before: { background: 'accent' } },
|
|
101
|
+
},
|
|
102
|
+
variants: {
|
|
103
|
+
on: { true: { _before: { background: 'accent' } } },
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
spinner: {
|
|
107
|
+
className: 'spinner',
|
|
108
|
+
base: {
|
|
109
|
+
width: '28px', height: '28px', borderRadius: 'full',
|
|
110
|
+
border: '3px solid', borderColor: 'border', borderTopColor: 'accent',
|
|
111
|
+
},
|
|
112
|
+
variants: {
|
|
113
|
+
preview: {
|
|
114
|
+
true: {
|
|
115
|
+
_motionSafe: { animation: 'spin 900ms linear 4' },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
skeleton: {
|
|
121
|
+
className: 'skeleton',
|
|
122
|
+
base: {
|
|
123
|
+
borderRadius: 'sm', background: 'border', opacity: '0.5',
|
|
124
|
+
},
|
|
125
|
+
variants: {
|
|
126
|
+
preview: {
|
|
127
|
+
true: {
|
|
128
|
+
_motionSafe: { animation: 'pulse 1400ms ease-in-out 3' },
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
// Layout primitives. These replace Panda's built-in container, stack and
|
|
138
|
+
// grid so every default comes from Verdant's tokens: a 1080px page
|
|
139
|
+
// container with token padding, and 16px (spacing 4) gaps. Grid tracks use
|
|
140
|
+
// minmax(0, 1fr), and auto-fit tracks cap their minimum at 100%, so a wide
|
|
141
|
+
// fallback font can never push a column past a 320px viewport.
|
|
142
|
+
patterns: {
|
|
143
|
+
extend: {
|
|
144
|
+
container: {
|
|
145
|
+
description: 'Centered page container, capped at 1080px, with responsive token padding.',
|
|
146
|
+
properties: { maxWidth: { type: 'property', value: 'maxWidth' } },
|
|
147
|
+
defaultValues: { maxWidth: '1080px' },
|
|
148
|
+
transform(props) {
|
|
149
|
+
const { maxWidth, ...rest } = props
|
|
150
|
+
return { position: 'relative', width: '100%', maxWidth, marginInline: 'auto', paddingInline: { base: '4', md: '6' }, ...rest }
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
stack: {
|
|
154
|
+
description: 'Vertical (or horizontal) flow with a spacing-token gap.',
|
|
155
|
+
properties: {
|
|
156
|
+
align: { type: 'property', value: 'alignItems' },
|
|
157
|
+
justify: { type: 'property', value: 'justifyContent' },
|
|
158
|
+
direction: { type: 'property', value: 'flexDirection' },
|
|
159
|
+
gap: { type: 'property', value: 'gap' },
|
|
160
|
+
},
|
|
161
|
+
defaultValues: { direction: 'column', gap: '4' },
|
|
162
|
+
transform(props) {
|
|
163
|
+
const { align, justify, direction, gap, ...rest } = props
|
|
164
|
+
return { display: 'flex', flexDirection: direction, alignItems: align, justifyContent: justify, gap, ...rest }
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
grid: {
|
|
168
|
+
description: 'Responsive grid: fixed columns, or auto-fit columns with a minimum child width.',
|
|
169
|
+
properties: {
|
|
170
|
+
gap: { type: 'property', value: 'gap' },
|
|
171
|
+
columns: { type: 'number' },
|
|
172
|
+
minChildWidth: { type: 'string' },
|
|
173
|
+
},
|
|
174
|
+
defaultValues: { gap: '4' },
|
|
175
|
+
transform(props, { map }) {
|
|
176
|
+
const { gap, columns, minChildWidth, ...rest } = props
|
|
177
|
+
const tracks = () => {
|
|
178
|
+
if (columns != null) return map(columns, (v) => `repeat(${v}, minmax(0, 1fr))`)
|
|
179
|
+
if (minChildWidth != null) return map(minChildWidth, (v) => `repeat(auto-fit, minmax(min(${v}, 100%), 1fr))`)
|
|
180
|
+
return undefined
|
|
181
|
+
}
|
|
182
|
+
return { display: 'grid', gridTemplateColumns: tracks(), gap, ...rest }
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
globalCss: {
|
|
189
|
+
'html': { colorScheme: 'light dark' },
|
|
190
|
+
// Panda emits token variables in the `tokens` layer, which comes after
|
|
191
|
+
// `base` (where globalCss lives), so a plain declaration here would lose
|
|
192
|
+
// to them. `!important` in an earlier layer beats normal declarations in
|
|
193
|
+
// any later layer, which is exactly the override an explicit choice needs.
|
|
194
|
+
[`html[${themeOverrideAttr}="light"]`]: important(explicitThemeVars.light),
|
|
195
|
+
[`html[${themeOverrideAttr}="dark"]`]: important(explicitThemeVars.dark),
|
|
196
|
+
// prefers-contrast: more — stronger token values in whichever theme is
|
|
197
|
+
// showing, plus thicker boundaries. Same !important reasoning as above;
|
|
198
|
+
// these come later in the same layer, so they win over the plain theme.
|
|
199
|
+
'@media (prefers-contrast: more)': {
|
|
200
|
+
[`html:not([${themeOverrideAttr}]), html[${themeOverrideAttr}="light"]`]: important(highContrastVars.light),
|
|
201
|
+
[`html[${themeOverrideAttr}="dark"]`]: important(highContrastVars.dark),
|
|
202
|
+
'.fieldInput, .btn--variant_secondary, .card': { borderWidth: '2px !important' },
|
|
203
|
+
':focus-visible': { outlineWidth: '3px !important' },
|
|
204
|
+
'a': { textDecorationThickness: '0.12em' },
|
|
205
|
+
},
|
|
206
|
+
'@media (prefers-contrast: more) and (prefers-color-scheme: dark)': {
|
|
207
|
+
[`html:not([${themeOverrideAttr}])`]: important(highContrastVars.dark),
|
|
208
|
+
},
|
|
209
|
+
[`html[${themeResolvedAttr}="light"]`]: { colorScheme: 'light' },
|
|
210
|
+
[`html[${themeResolvedAttr}="dark"]`]: { colorScheme: 'dark' },
|
|
211
|
+
'body': { margin: '0', background: 'surface.100', color: 'ink', fontFamily: 'sans', fontSize: 'body', lineHeight: 'body' },
|
|
212
|
+
'a': {
|
|
213
|
+
color: 'accent',
|
|
214
|
+
textDecoration: 'underline',
|
|
215
|
+
textUnderlineOffset: '0.15em',
|
|
216
|
+
textDecorationThickness: '0.08em',
|
|
217
|
+
},
|
|
218
|
+
'a:hover': { color: 'accent.strong' },
|
|
219
|
+
':focus-visible': { outline: '2px solid', outlineColor: 'focusRing', outlineOffset: '2px' },
|
|
220
|
+
'@keyframes spin': { to: { transform: 'rotate(360deg)' } },
|
|
221
|
+
'@keyframes pulse': { '0%, 100%': { opacity: '0.35' }, '50%': { opacity: '0.65' } },
|
|
222
|
+
// Verdant's art grows in once, then holds still. Only referenced from
|
|
223
|
+
// inside prefers-reduced-motion: no-preference (see scripts/art.mjs).
|
|
224
|
+
'@keyframes leafGrow': { from: { transform: 'scale(0)' }, to: { transform: 'scale(1)' } },
|
|
225
|
+
'@keyframes stemGrow': { from: { transform: 'scaleY(0)' }, to: { transform: 'scaleY(1)' } },
|
|
226
|
+
'@keyframes hillRise': { from: { opacity: '0', transform: 'translateY(40px)' }, to: { opacity: '1', transform: 'translateY(0)' } },
|
|
227
|
+
'@keyframes sprout': { from: { transform: 'scale(0.2)', opacity: '0' }, to: { transform: 'scale(1)', opacity: '1' } },
|
|
228
|
+
'@keyframes sunRise': { from: { opacity: '0', transform: 'translateY(24px)' }, to: { opacity: '1', transform: 'translateY(0)' } },
|
|
229
|
+
'.switchTrack .knob': {
|
|
230
|
+
position: 'absolute', top: '12px', left: '2px', width: '20px', height: '20px',
|
|
231
|
+
borderRadius: 'var(--radii-full)', background: 'var(--colors-surface-200)',
|
|
232
|
+
},
|
|
233
|
+
'@media (prefers-reduced-motion: no-preference)': {
|
|
234
|
+
'.switchTrack .knob': { transition: 'transform 150ms ease' },
|
|
235
|
+
},
|
|
236
|
+
'.switchTrack[aria-checked="true"] .knob': { transform: 'translateX(20px)' },
|
|
237
|
+
},
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
export default verdantPreset
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Verdant theme runtime. Load this synchronously in <head>, before the
|
|
2
|
+
// stylesheet, so the stored preference is applied before first paint and there
|
|
3
|
+
// is no flash of the wrong theme.
|
|
4
|
+
//
|
|
5
|
+
// The preset's globalCss keys off data-theme-override, so without this script a
|
|
6
|
+
// page gets prefers-color-scheme only and an explicit Light/Dark choice cannot
|
|
7
|
+
// take effect. See docs/INSTALL.md.
|
|
8
|
+
//
|
|
9
|
+
// The constants below mirror src/theme.mjs; tests/theme-contract.test.mjs
|
|
10
|
+
// fails the build if the two ever drift apart.
|
|
11
|
+
(function () {
|
|
12
|
+
const STORAGE_KEY = "verdant-theme-preference";
|
|
13
|
+
const OVERRIDE_ATTR = "data-theme-override";
|
|
14
|
+
const PREFERENCE_ATTR = "data-theme-preference";
|
|
15
|
+
const RESOLVED_ATTR = "data-theme-resolved";
|
|
16
|
+
const CONTROL_NAME = "theme-preference";
|
|
17
|
+
const THEME_COLOR = { light: "#faf8f3", dark: "#15140f" };
|
|
18
|
+
const RADIO_SELECTOR = `input[name="${CONTROL_NAME}"]`;
|
|
19
|
+
const media = window.matchMedia ? window.matchMedia("(prefers-color-scheme: dark)") : null;
|
|
20
|
+
|
|
21
|
+
// Declared before the functions that close over it: applyPreference runs
|
|
22
|
+
// immediately below, and the listeners read it long after.
|
|
23
|
+
let currentPreference = "system";
|
|
24
|
+
|
|
25
|
+
function systemTheme() {
|
|
26
|
+
return media?.matches ? "dark" : "light";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readPreference() {
|
|
30
|
+
try {
|
|
31
|
+
const saved = window.localStorage.getItem(STORAGE_KEY);
|
|
32
|
+
if (saved === "light" || saved === "dark") return saved;
|
|
33
|
+
} catch {
|
|
34
|
+
// Private mode or blocked storage: fall through to the system default.
|
|
35
|
+
}
|
|
36
|
+
return "system";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function persistPreference(value) {
|
|
40
|
+
try {
|
|
41
|
+
if (value === "system") window.localStorage.removeItem(STORAGE_KEY);
|
|
42
|
+
else window.localStorage.setItem(STORAGE_KEY, value);
|
|
43
|
+
} catch {
|
|
44
|
+
// Storage is unavailable; the choice still applies for this page view.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Browser chrome follows the resolved theme: an explicit choice pins every
|
|
49
|
+
// theme-color meta to that theme; System restores the media-query fallbacks.
|
|
50
|
+
function syncThemeColor(value, resolved) {
|
|
51
|
+
for (const meta of document.querySelectorAll('meta[name="theme-color"]')) {
|
|
52
|
+
if (!meta.hasAttribute("data-media")) {
|
|
53
|
+
meta.setAttribute("data-media", meta.getAttribute("media") || "");
|
|
54
|
+
meta.setAttribute("data-content", meta.getAttribute("content") || "");
|
|
55
|
+
}
|
|
56
|
+
if (value === "system") {
|
|
57
|
+
meta.setAttribute("media", meta.getAttribute("data-media"));
|
|
58
|
+
meta.setAttribute("content", meta.getAttribute("data-content"));
|
|
59
|
+
} else {
|
|
60
|
+
meta.removeAttribute("media");
|
|
61
|
+
meta.setAttribute("content", THEME_COLOR[resolved]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function applyPreference(value) {
|
|
67
|
+
const resolved = value === "system" ? systemTheme() : value;
|
|
68
|
+
const root = document.documentElement;
|
|
69
|
+
root.setAttribute(PREFERENCE_ATTR, value);
|
|
70
|
+
root.setAttribute(RESOLVED_ATTR, resolved);
|
|
71
|
+
if (value === "system") root.removeAttribute(OVERRIDE_ATTR);
|
|
72
|
+
else root.setAttribute(OVERRIDE_ATTR, value);
|
|
73
|
+
syncThemeColor(value, resolved);
|
|
74
|
+
return resolved;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function syncControls() {
|
|
78
|
+
for (const radio of document.querySelectorAll(RADIO_SELECTOR)) {
|
|
79
|
+
radio.checked = radio.value === currentPreference;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
currentPreference = readPreference();
|
|
84
|
+
applyPreference(currentPreference);
|
|
85
|
+
|
|
86
|
+
// Declared once rather than per radio: every control shares one handler, and
|
|
87
|
+
// the chosen value comes from the event, not from the loop.
|
|
88
|
+
function handlePreferenceChange(event) {
|
|
89
|
+
currentPreference = event.target.value;
|
|
90
|
+
persistPreference(currentPreference);
|
|
91
|
+
applyPreference(currentPreference);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
document.addEventListener("DOMContentLoaded", function () {
|
|
95
|
+
syncControls();
|
|
96
|
+
for (const radio of document.querySelectorAll(RADIO_SELECTOR)) {
|
|
97
|
+
radio.addEventListener("change", handlePreferenceChange);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// A page restored from the back/forward cache keeps its old DOM state:
|
|
102
|
+
// re-read the stored choice in case it changed on another page meanwhile.
|
|
103
|
+
window.addEventListener("pageshow", function (event) {
|
|
104
|
+
if (!event.persisted) return;
|
|
105
|
+
currentPreference = readPreference();
|
|
106
|
+
applyPreference(currentPreference);
|
|
107
|
+
syncControls();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (media) {
|
|
111
|
+
const onSystemChange = function () {
|
|
112
|
+
if (currentPreference === "system") applyPreference("system");
|
|
113
|
+
};
|
|
114
|
+
if (typeof media.addEventListener === "function") media.addEventListener("change", onSystemChange);
|
|
115
|
+
else if (typeof media.addListener === "function") media.addListener(onSystemChange);
|
|
116
|
+
}
|
|
117
|
+
})();
|
package/src/theme.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
export const themePreferenceStorageKey = 'verdant-theme-preference'
|
|
2
|
+
export const themePreferenceControlName = 'theme-preference'
|
|
3
|
+
export const themePreferenceValues = ['system', 'light', 'dark']
|
|
4
|
+
export const themePreferenceAttr = 'data-theme-preference'
|
|
5
|
+
export const themeResolvedAttr = 'data-theme-resolved'
|
|
6
|
+
export const themeOverrideAttr = 'data-theme-override'
|
|
7
|
+
|
|
8
|
+
export const themeVarName = (name) =>
|
|
9
|
+
`--colors-${name.replace(/\./g, '-').replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}`
|
|
10
|
+
|
|
11
|
+
export const themeTokens = {
|
|
12
|
+
'surface.100': { light: '#faf8f3', dark: '#15140f' },
|
|
13
|
+
'surface.200': { light: '#ffffff', dark: '#1e1c15' },
|
|
14
|
+
border: { light: '#c7bda9', dark: '#514b3b' },
|
|
15
|
+
'border.control': { light: '#7f735b', dark: '#8f866f' },
|
|
16
|
+
ink: { light: '#1c1a15', dark: '#f1ede2' },
|
|
17
|
+
'ink.muted': { light: '#5b5548', dark: '#b6ae9c' },
|
|
18
|
+
'ink.placeholder': { light: '#75726a', dark: '#8d8a81' },
|
|
19
|
+
accent: { light: '#2f6b4a', dark: '#7fcfa3' },
|
|
20
|
+
'accent.strong': { light: '#234f38', dark: '#5fb98c' },
|
|
21
|
+
'accent.ink': { light: '#ffffff', dark: '#10241a' },
|
|
22
|
+
focusRing: { light: '#a5670a', dark: '#e8a83e' },
|
|
23
|
+
foliage: { light: '#3ca24a', dark: '#45ad55' },
|
|
24
|
+
'foliage.far': { light: '#cdeaae', dark: '#1c3a22' },
|
|
25
|
+
'foliage.mid': { light: '#9ed65f', dark: '#2d6b34' },
|
|
26
|
+
'foliage.deep': { light: '#1f6a31', dark: '#2a7d3a' },
|
|
27
|
+
'foliage.bright': { light: '#6fcd4f', dark: '#86dc62' },
|
|
28
|
+
sunlight: { light: '#f4b63f', dark: '#e8a83e' },
|
|
29
|
+
positive: { light: '#1f7a6c', dark: '#5cc9b7' },
|
|
30
|
+
info: { light: '#2b5f8e', dark: '#8cbcea' },
|
|
31
|
+
warning: { light: '#8a5a00', dark: '#f2c14e' },
|
|
32
|
+
critical: { light: '#c1440e', dark: '#ff8f5e' },
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const hasChildren = (key) => Object.keys(themeTokens).some((candidate) => candidate.startsWith(`${key}.`))
|
|
36
|
+
|
|
37
|
+
const nestToken = (root, key, value) => {
|
|
38
|
+
const parts = key.includes('.') ? key.split('.') : hasChildren(key) ? [key, 'DEFAULT'] : [key]
|
|
39
|
+
let cursor = root
|
|
40
|
+
for (const part of parts.slice(0, -1)) {
|
|
41
|
+
cursor[part] ||= {}
|
|
42
|
+
cursor = cursor[part]
|
|
43
|
+
}
|
|
44
|
+
cursor[parts.at(-1)] = value
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const semanticColorTokens = Object.entries(themeTokens).reduce((colors, [name, value]) => {
|
|
48
|
+
nestToken(colors, name, { value: { base: value.light, _dark: value.dark } })
|
|
49
|
+
return colors
|
|
50
|
+
}, {})
|
|
51
|
+
|
|
52
|
+
const themeVarsFor = (mode) => Object.fromEntries(
|
|
53
|
+
Object.entries(themeTokens).map(([name, value]) => [themeVarName(name), value[mode]])
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
export const explicitThemeVars = {
|
|
57
|
+
light: themeVarsFor('light'),
|
|
58
|
+
dark: themeVarsFor('dark'),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const themeTokenCount = Object.keys(themeTokens).length
|
|
62
|
+
|
|
63
|
+
// Stronger values for people whose OS asks for more contrast
|
|
64
|
+
// (prefers-contrast: more). Only the tokens that carry supporting text,
|
|
65
|
+
// status, boundaries and focus change; ink and surfaces are already at the
|
|
66
|
+
// extremes. The build verifies these against stricter targets: 7:1 for text
|
|
67
|
+
// and 4.5:1 for control borders and focus rings.
|
|
68
|
+
export const highContrastTokens = {
|
|
69
|
+
'ink.muted': { light: '#3f3a30', dark: '#d6cfbf' },
|
|
70
|
+
'ink.placeholder': { light: '#4f4b42', dark: '#bdb8ab' },
|
|
71
|
+
border: { light: '#7f735b', dark: '#8f866f' },
|
|
72
|
+
'border.control': { light: '#4f4636', dark: '#b3a88e' },
|
|
73
|
+
focusRing: { light: '#8a5200', dark: '#ffc15a' },
|
|
74
|
+
accent: { light: '#245a3d', dark: '#9be0bb' },
|
|
75
|
+
'accent.strong': { light: '#1a3f2c', dark: '#7fd0a5' },
|
|
76
|
+
positive: { light: '#145e52', dark: '#7fdccb' },
|
|
77
|
+
info: { light: '#1f4f7a', dark: '#a9cff2' },
|
|
78
|
+
warning: { light: '#6e4700', dark: '#ffd57a' },
|
|
79
|
+
critical: { light: '#922e08', dark: '#ffab85' },
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const highContrastVarsFor = (mode) => Object.fromEntries(
|
|
83
|
+
Object.entries(highContrastTokens).map(([name, value]) => [themeVarName(name), value[mode]])
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
export const highContrastVars = {
|
|
87
|
+
light: highContrastVarsFor('light'),
|
|
88
|
+
dark: highContrastVarsFor('dark'),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const interfacePaletteOrder = [
|
|
92
|
+
'accent',
|
|
93
|
+
'accent.strong',
|
|
94
|
+
'positive',
|
|
95
|
+
'info',
|
|
96
|
+
'warning',
|
|
97
|
+
'critical',
|
|
98
|
+
'focusRing',
|
|
99
|
+
'accent.ink',
|
|
100
|
+
'ink',
|
|
101
|
+
'ink.muted',
|
|
102
|
+
'ink.placeholder',
|
|
103
|
+
'border',
|
|
104
|
+
'border.control',
|
|
105
|
+
'surface.200',
|
|
106
|
+
'surface.100',
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
export const illustrationPaletteOrder = [
|
|
110
|
+
'foliage.bright',
|
|
111
|
+
'foliage',
|
|
112
|
+
'foliage.deep',
|
|
113
|
+
'foliage.mid',
|
|
114
|
+
'foliage.far',
|
|
115
|
+
'sunlight',
|
|
116
|
+
]
|
package/src/tokens.mjs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { themeTokens, interfacePaletteOrder, illustrationPaletteOrder } from './theme.mjs'
|
|
2
|
+
|
|
3
|
+
export const fontFamilyTokens = {
|
|
4
|
+
sans: 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
|
5
|
+
mono: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const spacingTokens = {
|
|
9
|
+
1: '4px',
|
|
10
|
+
2: '8px',
|
|
11
|
+
3: '12px',
|
|
12
|
+
4: '16px',
|
|
13
|
+
6: '24px',
|
|
14
|
+
8: '32px',
|
|
15
|
+
12: '48px',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const radiiTokens = {
|
|
19
|
+
sm: '4px',
|
|
20
|
+
md: '8px',
|
|
21
|
+
lg: '16px',
|
|
22
|
+
full: '999px',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const fontSizeTokens = {
|
|
26
|
+
displayLg: '32px',
|
|
27
|
+
displayMd: '24px',
|
|
28
|
+
displaySm: '20px',
|
|
29
|
+
body: '16px',
|
|
30
|
+
bodySm: '14px',
|
|
31
|
+
label: '13px',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const lineHeightTokens = {
|
|
35
|
+
displayLg: '40px',
|
|
36
|
+
displayMd: '32px',
|
|
37
|
+
displaySm: '28px',
|
|
38
|
+
body: '24px',
|
|
39
|
+
bodySm: '20px',
|
|
40
|
+
label: '16px',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const includeExisting = (keys) => keys.filter((key) => key in themeTokens)
|
|
44
|
+
const orderedInterfaceKeys = includeExisting(interfacePaletteOrder)
|
|
45
|
+
const orderedIllustrationKeys = includeExisting(illustrationPaletteOrder)
|
|
46
|
+
const orderedTokenSet = new Set([...orderedInterfaceKeys, ...orderedIllustrationKeys])
|
|
47
|
+
const interfaceExtras = Object.keys(themeTokens).filter((key) => !orderedTokenSet.has(key))
|
|
48
|
+
|
|
49
|
+
export const paletteSections = [
|
|
50
|
+
{
|
|
51
|
+
title: 'Interface',
|
|
52
|
+
note: 'Public colors for text, controls, state, and supporting UI tones. Use the verified pairings below rather than assuming every token combination is readable.',
|
|
53
|
+
keys: [...orderedInterfaceKeys, ...interfaceExtras],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
title: 'Illustration only',
|
|
57
|
+
note: 'Decorative foliage and sunlight tokens. They are available for art, but not approved as default text, controls, or status meaning.',
|
|
58
|
+
keys: orderedIllustrationKeys,
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
// Every pairing the UI actually uses, checked at build time: scripts/build.mjs
|
|
63
|
+
// fails the build if any drops below its minimum in either theme. `border`
|
|
64
|
+
// itself is a decorative divider (below 3:1 by design); controls use
|
|
65
|
+
// `border.control`.
|
|
66
|
+
const pairingRows = [
|
|
67
|
+
// [category, title, foreground, background, minimum contrast, note]
|
|
68
|
+
['text', 'Body text on site canvas', 'ink', 'surface.100', 4.5, 'Default long-form copy and labels on the page background.'],
|
|
69
|
+
['text', 'Body text on raised surface', 'ink', 'surface.200', 4.5, 'Card and field content when a component steps onto the raised surface.'],
|
|
70
|
+
['text', 'Supporting text on site canvas', 'ink.muted', 'surface.100', 4.5, 'Secondary copy such as intros, captions and helper text.'],
|
|
71
|
+
['text', 'Supporting text on raised surface', 'ink.muted', 'surface.200', 4.5, 'Card bodies, palette tiles, hints and the bands that sit on surface-200.'],
|
|
72
|
+
['text', 'Placeholder text in fields', 'ink.placeholder', 'surface.200', 4.5, 'Example values inside inputs, which sit on the raised surface.'],
|
|
73
|
+
['text', 'Links and accents on site canvas', 'accent', 'surface.100', 4.5, 'Inline links, eyebrows and stat values on the page background.'],
|
|
74
|
+
['text', 'Links and accents on raised surface', 'accent', 'surface.200', 4.5, 'Links and accent text inside cards and bands.'],
|
|
75
|
+
['text', 'Primary button label', 'accent.ink', 'accent', 4.5, 'Default primary action styling.'],
|
|
76
|
+
['text', 'Primary button label on hover', 'accent.ink', 'accent.strong', 4.5, 'Hover state for the primary action.'],
|
|
77
|
+
['text', 'Positive status on raised surface', 'positive', 'surface.200', 4.5, 'Success status inside cards, e.g. the scorecard.'],
|
|
78
|
+
['text', 'Info status on raised surface', 'info', 'surface.200', 4.5, 'Neutral guidance or sample-only notes inside cards.'],
|
|
79
|
+
['text', 'Warning status on raised surface', 'warning', 'surface.200', 4.5, 'Non-blocking cautions inside cards, e.g. open items on the scorecard.'],
|
|
80
|
+
['text', 'Critical status on raised surface', 'critical', 'surface.200', 4.5, 'Errors, failures and destructive actions only.'],
|
|
81
|
+
['functional', 'Control border on site canvas', 'border.control', 'surface.100', 3, 'Input, secondary button and switch-track outlines on the page background.'],
|
|
82
|
+
['functional', 'Control border on raised surface', 'border.control', 'surface.200', 3, 'Input and secondary button outlines on the raised surface.'],
|
|
83
|
+
['functional', 'Focus ring on site canvas', 'focusRing', 'surface.100', 3, 'Visible focus indicator against the main page background.'],
|
|
84
|
+
['functional', 'Focus ring on raised surface', 'focusRing', 'surface.200', 3, 'Visible focus indicator inside cards and bands.'],
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
// Stricter targets for the prefers-contrast: more token set: WCAG AAA for
|
|
88
|
+
// text and 4.5:1 for control borders and focus rings.
|
|
89
|
+
export const highContrastMinimum = { text: 7, functional: 4.5 }
|
|
90
|
+
|
|
91
|
+
export const verifiedPairings = pairingRows.map(([category, title, foreground, background, minimum, note]) => (
|
|
92
|
+
{ category, title, foreground, background, minimum, note }
|
|
93
|
+
))
|
|
94
|
+
|
|
95
|
+
export const publicTokenScope = {
|
|
96
|
+
spacing: Object.entries(spacingTokens).map(([token, value]) => ({ token, value })),
|
|
97
|
+
radii: Object.entries(radiiTokens).map(([token, value]) => ({ token, value })),
|
|
98
|
+
typography: {
|
|
99
|
+
families: Object.entries(fontFamilyTokens).map(([token, value]) => ({ token, value })),
|
|
100
|
+
sizes: Object.keys(fontSizeTokens).map((token) => ({
|
|
101
|
+
token,
|
|
102
|
+
fontSize: fontSizeTokens[token],
|
|
103
|
+
lineHeight: lineHeightTokens[token],
|
|
104
|
+
})),
|
|
105
|
+
},
|
|
106
|
+
layout: {
|
|
107
|
+
primitives: [
|
|
108
|
+
'`container()` — centered, capped at 1080px (override with `maxWidth`), inline padding of spacing `4`, then `6` from `md`.',
|
|
109
|
+
'`stack()` — a flex column by default with a spacing-`4` gap; takes `direction`, `align`, `justify` and `gap`.',
|
|
110
|
+
'`grid()` — `columns` (a number or responsive object) makes `minmax(0, 1fr)` tracks; `minChildWidth` (a CSS length) makes auto-fit tracks capped at 100%; default gap is spacing `4`.',
|
|
111
|
+
],
|
|
112
|
+
inherited: [
|
|
113
|
+
'Responsive rules use Panda defaults (`base`, `sm`, `md`, `lg`) because Verdant does not publish a custom breakpoint token scale.',
|
|
114
|
+
],
|
|
115
|
+
pageSpecific: [
|
|
116
|
+
'Hero wordmark: 56px at base and 88px from `md` upward; this is page artwork, not a reusable type token.',
|
|
117
|
+
'Components and 404 page titles: 40px at base and 56px from `md` upward; also page-specific, not public tokens.',
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
}
|