astralkit 0.8.0 → 0.9.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.
@@ -1,330 +1,365 @@
1
- /**
2
- * astralkit/presets — dials.
3
- *
4
- * BASE_COLORS — the neutral scale under surfaces/borders/text.
5
- * ACCENT_THEMES — the brand accent hue over the full primary role family.
6
- * TYPE_SCALES — authored size hierarchies (one dial, whole ramp).
7
- * radiusToVariables — the full derived radius set (the :root substitution rule).
8
- *
9
- * Every color dial is MODE-AWARE: pass { mode: 'dark' } and you get the
10
- * authored dark mapping, following the SDK's own dark language — near-black
11
- * tinted surface ramps, WHITE-ALPHA hovers/borders (never gray hexes), text
12
- * from the scale's light end, accents at 500 with darkened states. Built to
13
- * match [data-theme="dark"] in astralkit-theme.css and the dark palettes.
14
- */
15
-
16
- // ── Base colors ──────────────────────────────────────────────────────
17
-
18
- export const DEFAULT_BASE_COLOR_ID = 'neutral'
19
-
20
- const lightBase = (s) => ({
21
- '--color-ak-surface': s[50],
22
- '--color-ak-surface-2': s[100],
23
- '--color-ak-surface-3': s[200],
24
- '--color-ak-surface-4': s[300],
25
- '--color-ak-hover': s[100],
26
- '--color-ak-active': s[200],
27
- '--color-ak-border': s[200],
28
- '--color-ak-border-subtle': s[100],
29
- '--color-ak-border-strong': s[400],
30
- '--color-ak-text': s[950],
31
- '--color-ak-text-secondary': s[600],
32
- '--color-ak-text-muted': s[400],
33
- '--color-ak-on-surface': s[950],
34
- '--color-ak-on-surface-variant': s[600],
35
- })
36
-
37
- // Dark: tinted near-black ramp from the scale's deep end; hovers/actives and
38
- // borders stay white-alpha exactly like the SDK's own dark block.
39
- const darkBase = (s) => ({
40
- '--color-ak-bg': s[950],
41
- '--color-ak-surface': s[900],
42
- '--color-ak-surface-2': s[800],
43
- '--color-ak-surface-3': s[800],
44
- '--color-ak-surface-4': s[700],
45
- '--color-ak-hover': 'rgba(255,255,255,0.04)',
46
- '--color-ak-active': 'rgba(255,255,255,0.08)',
47
- '--color-ak-border': 'rgba(255,255,255,0.10)',
48
- '--color-ak-border-subtle': 'rgba(255,255,255,0.06)',
49
- '--color-ak-border-strong': 'rgba(255,255,255,0.16)',
50
- '--color-ak-text': s[50],
51
- '--color-ak-text-secondary': s[400],
52
- '--color-ak-text-muted': s[600],
53
- '--color-ak-on-surface': s[50],
54
- '--color-ak-on-surface-variant': s[400],
55
- })
56
-
57
- const base = (id, name, description, scale) => ({
58
- id,
59
- name,
60
- description,
61
- variables: lightBase(scale),
62
- darkVariables: darkBase(scale),
63
- swatch: scale[600],
64
- })
65
-
66
- export const BASE_COLORS = [
67
- { id: 'neutral', name: 'Neutral', description: 'The SDK default neutral scale.', variables: {}, darkVariables: {}, swatch: '#a3a3a3' },
68
- base('stone', 'Stone', 'Warm gray — earthy, editorial.', {
69
- 50: '#fafaf9', 100: '#f5f5f4', 200: '#e7e5e4', 300: '#d6d3d1', 400: '#a8a29e', 600: '#57534e', 700: '#44403c', 800: '#292524', 900: '#1c1917', 950: '#0c0a09',
70
- }),
71
- base('zinc', 'Zinc', 'Cool gray — technical, precise.', {
72
- 50: '#fafafa', 100: '#f4f4f5', 200: '#e4e4e7', 300: '#d4d4d8', 400: '#a1a1aa', 600: '#52525b', 700: '#3f3f46', 800: '#27272a', 900: '#18181b', 950: '#09090b',
73
- }),
74
- base('mauve', 'Mauve', 'Violet-tinted neutral.', {
75
- 50: 'oklch(98.5% 0 0)', 100: 'oklch(96% 0.003 325.6)', 200: 'oklch(92.2% 0.005 325.62)', 300: 'oklch(86.5% 0.012 325.68)', 400: 'oklch(71.1% 0.019 323.02)', 600: 'oklch(43.5% 0.029 321.78)', 700: 'oklch(36.4% 0.029 323.89)', 800: 'oklch(26.3% 0.024 320.12)', 900: 'oklch(21.2% 0.019 322.12)', 950: 'oklch(14.5% 0.008 326)',
76
- }),
77
- base('olive', 'Olive', 'Green-tinted neutral.', {
78
- 50: 'oklch(98.8% 0.003 106.5)', 100: 'oklch(96.6% 0.005 106.5)', 200: 'oklch(93% 0.007 106.5)', 300: 'oklch(88% 0.011 106.6)', 400: 'oklch(73.7% 0.021 106.9)', 600: 'oklch(46.6% 0.025 107.3)', 700: 'oklch(39.4% 0.023 107.4)', 800: 'oklch(28.6% 0.016 107.4)', 900: 'oklch(22.8% 0.013 107.4)', 950: 'oklch(15.3% 0.006 107.1)',
79
- }),
80
- base('mist', 'Mist', 'Blue-tinted neutral.', {
81
- 50: 'oklch(98.7% 0.002 197.1)', 100: 'oklch(96.3% 0.002 197.1)', 200: 'oklch(92.5% 0.005 214.3)', 300: 'oklch(87.2% 0.007 219.6)', 400: 'oklch(72.3% 0.014 214.4)', 600: 'oklch(45% 0.017 213.2)', 700: 'oklch(37.8% 0.015 216)', 800: 'oklch(27.5% 0.011 216.9)', 900: 'oklch(21.8% 0.008 223.9)', 950: 'oklch(14.8% 0.004 228.8)',
82
- }),
83
- base('taupe', 'Taupe', 'Brown-tinted neutral.', {
84
- 50: 'oklch(98.6% 0.002 67.8)', 100: 'oklch(96% 0.002 17.2)', 200: 'oklch(92.2% 0.005 34.3)', 300: 'oklch(86.8% 0.007 39.5)', 400: 'oklch(71.4% 0.014 41.2)', 600: 'oklch(43.8% 0.017 39.3)', 700: 'oklch(36.7% 0.016 35.7)', 800: 'oklch(26.8% 0.011 36.5)', 900: 'oklch(21.4% 0.009 43.1)', 950: 'oklch(14.7% 0.004 49.3)',
85
- }),
86
- ]
87
-
88
- export function baseColorToVariables(base, opts) {
89
- const mode = (opts && opts.mode) || 'light'
90
- return { ...(mode === 'dark' ? base.darkVariables : base.variables) }
91
- }
92
-
93
- // ── Accent themes ────────────────────────────────────────────────────
94
-
95
- export const DEFAULT_ACCENT_THEME_ID = 'default'
96
-
97
- // ink = on-primary for hues where white text fails contrast on the fill.
98
- const lightAccent = (s, ink) => ({
99
- '--color-ak-primary': s[600],
100
- // Focus + glow follow the accent, mirroring the palette recipe
101
- // (palettes: border-focus = ring||accent||primary, glow = primary).
102
- // Without these the base theme's ink focus (#0b0b0b) and the
103
- // utilities' indigo glow fallback (#818cf8) survive an accent swap.
104
- '--color-ak-border-focus': s[600],
105
- '--color-ak-glow-primary': s[600],
106
- // Full hue parity with the palette layer. Accents apply ON TOP of a
107
- // palette, so every primary-hue-derived token a palette sets must be
108
- // overridden here or the palette's hue bleeds through (green gradients
109
- // under an indigo accent). Neutral/surface tokens stay with the base
110
- // dial on purpose.
111
- '--color-ak-primary-50': s[50], '--color-ak-primary-100': s[100],
112
- '--color-ak-primary-200': s[200], '--color-ak-primary-300': s[300],
113
- '--color-ak-primary-400': s[400], '--color-ak-primary-500': s[500],
114
- '--color-ak-primary-600': s[600], '--color-ak-primary-700': s[700],
115
- '--color-ak-primary-800': s[800], '--color-ak-primary-900': s[900],
116
- '--color-ak-primary-950': s[950],
117
- '--color-ak-accent': s[600],
118
- '--color-ak-on-accent': ink ? '#1c1917' : '#ffffff',
119
- '--color-ak-accent-soft': s[100],
120
- '--color-ak-primary-selection': s[600],
121
- '--color-ak-on-primary-selection': ink ? '#1c1917' : '#ffffff',
122
- '--color-ak-primary-container-emphasis': s[200],
123
- '--color-ak-text-link': ink ? s[700] : s[600],
124
- '--color-ak-hero-accent': s[600],
125
- '--color-ak-gradient-start': s[400],
126
- '--color-ak-gradient-mid': s[600],
127
- '--color-ak-gradient-end': s[800],
128
- '--gradient-ak-primary': `linear-gradient(135deg, ${s[400]} 0%, ${s[600]} 52%, ${s[800]} 100%)`,
129
- '--gradient-ak-primary-hover': `linear-gradient(135deg, ${s[500]} 0%, ${s[700]} 48%, ${s[900]} 100%)`,
130
- '--color-ak-primary-hover': s[700],
131
- '--color-ak-primary-active': s[800],
132
- '--color-ak-on-primary': ink ? '#1c1917' : '#ffffff',
133
- '--color-ak-primary-subtle': s[50],
134
- '--color-ak-primary-subtle-hover': s[100],
135
- '--color-ak-primary-container': s[100],
136
- '--color-ak-on-primary-container': s[900],
137
- '--color-ak-primary-outline': s[400],
138
- '--color-ak-selection-ink': s[700],
139
- })
140
-
141
- // Dark: brighter 500 fill, darkened hover/active states (contrast-stable
142
- // with the same on-primary), hue-tinted dark containers, light readable inks.
143
- const darkAccent = (s, ink) => ({
144
- '--color-ak-primary': s[500],
145
- '--color-ak-primary-hover': s[600],
146
- '--color-ak-primary-active': s[700],
147
- '--color-ak-border-focus': s[500],
148
- '--color-ak-glow-primary': s[400],
149
- // Dark parity — same token set, dark mappings (fills brighten, deeps
150
- // carry containers). See the light builder for why parity matters.
151
- '--color-ak-primary-50': s[950], '--color-ak-primary-100': s[900],
152
- '--color-ak-primary-200': s[800], '--color-ak-primary-300': s[700],
153
- '--color-ak-primary-400': s[600], '--color-ak-primary-500': s[500],
154
- '--color-ak-primary-600': s[400], '--color-ak-primary-700': s[300],
155
- '--color-ak-primary-800': s[200], '--color-ak-primary-900': s[100],
156
- '--color-ak-primary-950': s[50],
157
- '--color-ak-accent': s[500],
158
- '--color-ak-on-accent': ink ? '#1c1917' : '#ffffff',
159
- '--color-ak-accent-soft': s[900],
160
- '--color-ak-primary-selection': s[500],
161
- '--color-ak-on-primary-selection': ink ? '#1c1917' : '#ffffff',
162
- '--color-ak-primary-container-emphasis': s[800],
163
- '--color-ak-text-link': s[400],
164
- '--color-ak-hero-accent': s[500],
165
- '--color-ak-gradient-start': s[400],
166
- '--color-ak-gradient-mid': s[500],
167
- '--color-ak-gradient-end': s[700],
168
- '--gradient-ak-primary': `linear-gradient(135deg, ${s[400]} 0%, ${s[500]} 52%, ${s[700]} 100%)`,
169
- '--gradient-ak-primary-hover': `linear-gradient(135deg, ${s[300]} 0%, ${s[400]} 48%, ${s[600]} 100%)`,
170
- '--color-ak-on-primary': ink ? '#1c1917' : '#ffffff',
171
- '--color-ak-primary-subtle': s[900],
172
- '--color-ak-primary-subtle-hover': s[800],
173
- '--color-ak-primary-container': s[900],
174
- '--color-ak-on-primary-container': s[100],
175
- '--color-ak-primary-outline': s[700],
176
- '--color-ak-selection-ink': s[400],
177
- })
178
-
179
- const acc = (id, name, scale, ink) => ({
180
- id,
181
- name,
182
- description: '',
183
- variables: lightAccent(scale, ink),
184
- darkVariables: darkAccent(scale, ink),
185
- swatch: scale[600],
186
- })
187
-
188
- export const ACCENT_THEMES = [
189
- { id: 'default', name: 'Default', description: 'The active palette’s own accent.', variables: {}, darkVariables: {}, swatch: '#a3a3a3' },
190
- acc('blue', 'Blue', { 50: '#eff6ff', 100: '#dbeafe', 200: '#bfdbfe', 300: '#93c5fd', 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8', 800: '#1e40af', 900: '#1e3a8a', 950: '#172554' }),
191
- acc('indigo', 'Indigo', { 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 300: '#a5b4fc', 400: '#818cf8', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca', 800: '#3730a3', 900: '#312e81', 950: '#1e1b4b' }),
192
- acc('violet', 'Violet', { 50: '#f5f3ff', 100: '#ede9fe', 200: '#ddd6fe', 300: '#c4b5fd', 400: '#a78bfa', 500: '#8b5cf6', 600: '#7c3aed', 700: '#6d28d9', 800: '#5b21b6', 900: '#4c1d95', 950: '#2e1065' }),
193
- acc('purple', 'Purple', { 50: '#faf5ff', 100: '#f3e8ff', 200: '#e9d5ff', 300: '#d8b4fe', 400: '#c084fc', 500: '#a855f7', 600: '#9333ea', 700: '#7e22ce', 800: '#6b21a8', 900: '#581c87', 950: '#3b0764' }),
194
- acc('pink', 'Pink', { 50: '#fdf2f8', 100: '#fce7f3', 200: '#fbcfe8', 300: '#f9a8d4', 400: '#f472b6', 500: '#ec4899', 600: '#db2777', 700: '#be185d', 800: '#9d174d', 900: '#831843', 950: '#500724' }),
195
- acc('rose', 'Rose', { 50: '#fff1f2', 100: '#ffe4e6', 200: '#fecdd3', 300: '#fda4af', 400: '#fb7185', 500: '#f43f5e', 600: '#e11d48', 700: '#be123c', 800: '#9f1239', 900: '#881337', 950: '#4c0519' }),
196
- acc('red', 'Red', { 50: '#fef2f2', 100: '#fee2e2', 200: '#fecaca', 300: '#fca5a5', 400: '#f87171', 500: '#ef4444', 600: '#dc2626', 700: '#b91c1c', 800: '#991b1b', 900: '#7f1d1d', 950: '#450a0a' }),
197
- acc('orange', 'Orange', { 50: '#fff7ed', 100: '#ffedd5', 200: '#fed7aa', 300: '#fdba74', 400: '#fb923c', 500: '#f97316', 600: '#ea580c', 700: '#c2410c', 800: '#9a3412', 900: '#7c2d12', 950: '#431407' }),
198
- acc('amber', 'Amber', { 50: '#fffbeb', 100: '#fef3c7', 200: '#fde68a', 300: '#fcd34d', 400: '#fbbf24', 500: '#f59e0b', 600: '#d97706', 700: '#b45309', 800: '#92400e', 900: '#78350f', 950: '#451a03' }, true),
199
- acc('emerald', 'Emerald', { 50: '#ecfdf5', 100: '#d1fae5', 200: '#a7f3d0', 300: '#6ee7b9', 400: '#34d399', 500: '#10b981', 600: '#059669', 700: '#047857', 800: '#065f46', 900: '#064e3b', 950: '#022c22' }),
200
- acc('teal', 'Teal', { 50: '#f0fdfa', 100: '#ccfbf1', 200: '#99f6e4', 300: '#5eead4', 400: '#2dd4bf', 500: '#14b8a6', 600: '#0d9488', 700: '#0f766e', 800: '#115e59', 900: '#134e4a', 950: '#042f2e' }),
201
- acc('cyan', 'Cyan', { 50: '#ecfeff', 100: '#cffafe', 200: '#a5f3fc', 300: '#67e8f9', 400: '#22d3ee', 500: '#06b6d4', 600: '#0891b2', 700: '#0e7490', 800: '#155e75', 900: '#164e63', 950: '#083344' }),
202
- ]
203
-
204
- export function accentThemeToVariables(theme, opts) {
205
- const mode = (opts && opts.mode) || 'light'
206
- return { ...(mode === 'dark' ? theme.darkVariables : theme.variables) }
207
- }
208
-
209
- // ── Radius (the corrected knob) ──────────────────────────────────────
210
-
211
- /**
212
- * Emit the FULL derived radius set for a knob value. Container-level
213
- * overrides of --ak-radius alone do nothing: CSS substitutes the var()
214
- * chains where they are DEFINED (:root), so descendants inherit resolved
215
- * values. Spreading this record re-declares the whole chain at the
216
- * container. Multipliers mirror the SDK theme exactly.
217
- */
218
- export function radiusToVariables(value) {
219
- const rem = typeof value === 'number' ? value : parseFloat(value)
220
- // PILL MODE (Extra Round): controls go full-pill via CSS's self-clamping
221
- // 9999px (border-radius larger than the box clamps to a perfect pill),
222
- // while CONTAINER-tier tokens cap at tasteful roundness so cards and
223
- // panels never become blobs.
224
- if (rem >= 62) {
225
- return {
226
- '--ak-radius': '9999px',
227
- '--radius-ak-xs': '0.375rem',
228
- '--radius-ak-sm': '0.5rem',
229
- '--radius-ak-md': '9999px',
230
- '--radius-ak-lg': '1.25rem',
231
- '--radius-ak-xl': '1.5rem',
232
- '--radius-ak-2xl': '2rem',
233
- '--radius-ak-3xl': '2.5rem',
234
- '--radius-ak-input': '9999px',
235
- '--radius-ak-control-sm': '0.625rem',
236
- }
237
- }
238
- const step = (m) => `${+(rem * m).toFixed(4)}rem`
239
- return {
240
- '--ak-radius': `${rem}rem`,
241
- '--radius-ak-xs': step(0.25),
242
- '--radius-ak-sm': step(0.5),
243
- '--radius-ak-md': step(1),
244
- '--radius-ak-lg': step(1.5),
245
- '--radius-ak-xl': step(2),
246
- '--radius-ak-2xl': step(3),
247
- '--radius-ak-3xl': step(4),
248
- '--radius-ak-input': step(1.25),
249
- '--radius-ak-control-sm': `${Math.min(rem, 0.625)}rem`,
250
- }
251
- }
252
-
253
- // ── Type scales ────────────────────────────────────────────────────
254
-
255
- export const DEFAULT_TYPE_SCALE_ID = 'default'
256
-
257
- /**
258
- * TYPE_SCALES — true modular scales, named by their musical interval ratio.
259
- * The base and everything below it are PINNED (16px body floor, UI-legible
260
- * smalls); the ratio drives every step ABOVE the base via fixed exponents,
261
- * so the dropdown is strictly monotonic: each option renders every heading
262
- * step larger than the option before it. Unitless leadings adapt free.
263
- */
264
- // Default small sizes — they keep these PROPORTIONS to the base and lift
265
- // with it (multiplying by the same gentle base lift keeps every token
266
- // monotonic across the dropdown; dividing by the ratio made smalls shrink
267
- // as scales grew).
268
- const SMALL_DEFAULTS = {
269
- '--text-ak-2xs': 0.6875,
270
- '--text-ak-xs': 0.75,
271
- '--text-ak-xs_5': 0.8125,
272
- '--text-ak-sm': 0.875,
273
- '--text-ak-sm_5': 0.9375,
274
- }
275
-
276
- // Exponent of the ratio applied per token above the base (anchored at 16px).
277
- const STEP_EXPONENTS = {
278
- '--text-ak-lg': 1, '--text-ak-xl': 1.5, '--text-ak-xl_5': 1.75,
279
- '--text-ak-2xl': 2, '--text-ak-2xl_5': 2.25, '--text-ak-3xl': 2.75,
280
- '--text-ak-4xl': 3.25, '--text-ak-4xl_5': 3.5, '--text-ak-5xl': 3.75,
281
- '--text-ak-6xl': 4.25, '--text-ak-6xl_5': 4.5, '--text-ak-7xl': 4.75,
282
- '--text-ak-7xl_5': 5, '--text-ak-8xl': 5.25, '--text-ak-9xl': 5.75,
283
- '--text-ak-10xl': 6,
284
- }
285
-
286
- function modularRamp(ratio) {
287
- // The base itself gets a GENTLE ratio lift (exp 0.25): 16px floor at
288
- // Default, ~18px at Golden — so body copy, default buttons and links all
289
- // breathe with the scale instead of only the display steps.
290
- const lift = Math.pow(ratio, 0.25)
291
- const out = { '--text-ak-base': `${+lift.toFixed(4)}rem` }
292
- for (const token of Object.keys(SMALL_DEFAULTS)) {
293
- out[token] = `${+(SMALL_DEFAULTS[token] * lift).toFixed(4)}rem`
294
- }
295
- for (const token of Object.keys(STEP_EXPONENTS)) {
296
- out[token] = `${+(Math.pow(ratio, STEP_EXPONENTS[token])).toFixed(4)}rem`
297
- }
298
- return out
299
- }
300
-
301
- export const TYPE_SCALES = [
302
- {
303
- id: 'default', name: 'Default', description: 'The authored SDK ramp.',
304
- variables: {},
305
- },
306
- {
307
- id: 'major-third', name: 'Major Third', description: '1.250 — compact, businesslike hierarchy.',
308
- variables: modularRamp(1.25),
309
- },
310
- {
311
- id: 'perfect-fourth', name: 'Perfect Fourth', description: '1.333 — the classic editorial workhorse.',
312
- variables: modularRamp(1.333),
313
- },
314
- {
315
- id: 'augmented-fourth', name: 'Augmented Fourth', description: '1.414 — assertive, modern contrast.',
316
- variables: modularRamp(1.414),
317
- },
318
- {
319
- id: 'perfect-fifth', name: 'Perfect Fifth', description: '1.500 — bold, display-forward hierarchy.',
320
- variables: modularRamp(1.5),
321
- },
322
- {
323
- id: 'golden', name: 'Golden Ratio', description: '1.618 — maximum drama for heroes and landings.',
324
- variables: modularRamp(1.618),
325
- },
326
- ]
327
-
328
- export function typeScaleToVariables(scale) {
329
- return { ...scale.variables }
330
- }
1
+ /**
2
+ * astralkit/presets — dials.
3
+ *
4
+ * BASE_COLORS — the neutral scale under surfaces/borders/text.
5
+ * ACCENT_THEMES — the brand accent hue over the full primary role family.
6
+ * TYPE_SCALES — authored size hierarchies (one dial, whole ramp).
7
+ * radiusToVariables — the full derived radius set (the :root substitution rule).
8
+ *
9
+ * Every color dial is MODE-AWARE: pass { mode: 'dark' } and you get the
10
+ * authored dark mapping, following the SDK's own dark language — near-black
11
+ * tinted surface ramps, WHITE-ALPHA hovers/borders (never gray hexes), text
12
+ * from the scale's light end, accents at 500 with darkened states. Built to
13
+ * match [data-theme="dark"] in astralkit-theme.css and the dark palettes.
14
+ */
15
+
16
+ // WCAG relative-luminance contrast — the palette layer's vivid-first rule
17
+ // needs it here too (selection-ink: brand color when it clears 4.5:1 on the
18
+ // tint, else the darker step).
19
+ const _lum = (hex) => {
20
+ const h = hex.replace('#', '')
21
+ const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) }
22
+ return 0.2126 * f(parseInt(h.slice(0, 2), 16)) + 0.7152 * f(parseInt(h.slice(2, 4), 16)) + 0.0722 * f(parseInt(h.slice(4, 6), 16))
23
+ }
24
+ const _contrast = (a, b) => {
25
+ const [l1, l2] = [_lum(a), _lum(b)].sort((x, y) => y - x)
26
+ return (l1 + 0.05) / (l2 + 0.05)
27
+ }
28
+ const vividInk = (vivid, fallback, tint) => (_contrast(vivid, tint) >= 4.5 ? vivid : fallback)
29
+
30
+ // ── Base colors ──────────────────────────────────────────────────────
31
+
32
+ export const DEFAULT_BASE_COLOR_ID = 'neutral'
33
+
34
+ const lightBase = (s) => ({
35
+ // Family rule (2026-08-24): cards stay WHITE; the tint lives in the canvas
36
+ // and panels. Ladder sits one step lighter than the original mapping,
37
+ // border-strong is the input-border role (300), text is the 900 ink step.
38
+ '--color-ak-bg': s[50],
39
+ '--color-ak-surface': '#ffffff',
40
+ '--color-ak-surface-2': s[50],
41
+ '--color-ak-surface-3': s[100],
42
+ '--color-ak-surface-4': s[200],
43
+ '--color-ak-hover': s[100],
44
+ '--color-ak-active': s[200],
45
+ '--color-ak-border': s[200],
46
+ '--color-ak-border-subtle': s[100],
47
+ '--color-ak-border-strong': s[300],
48
+ '--color-ak-text': s[900],
49
+ '--color-ak-text-secondary': s[600],
50
+ '--color-ak-text-muted': s[400],
51
+ '--color-ak-on-surface': s[900],
52
+ '--color-ak-on-surface-variant': s[600],
53
+ })
54
+
55
+ // Dark: tinted near-black ramp from the scale's deep end; hovers/actives and
56
+ // borders stay white-alpha exactly like the SDK's own dark block.
57
+ const darkBase = (s) => ({
58
+ '--color-ak-bg': s[950],
59
+ '--color-ak-surface': s[900],
60
+ '--color-ak-surface-2': s[800],
61
+ '--color-ak-surface-3': s[800],
62
+ '--color-ak-surface-4': s[700],
63
+ '--color-ak-hover': 'rgba(255,255,255,0.04)',
64
+ '--color-ak-active': 'rgba(255,255,255,0.08)',
65
+ '--color-ak-border': 'rgba(255,255,255,0.10)',
66
+ '--color-ak-border-subtle': 'rgba(255,255,255,0.06)',
67
+ '--color-ak-border-strong': 'rgba(255,255,255,0.16)',
68
+ '--color-ak-text': s[50],
69
+ '--color-ak-text-secondary': s[400],
70
+ '--color-ak-text-muted': s[600],
71
+ '--color-ak-on-surface': s[50],
72
+ '--color-ak-on-surface-variant': s[400],
73
+ })
74
+
75
+ const base = (id, name, description, scale) => ({
76
+ id,
77
+ name,
78
+ description,
79
+ variables: lightBase(scale),
80
+ darkVariables: darkBase(scale),
81
+ swatch: scale[600],
82
+ })
83
+
84
+ export const BASE_COLORS = [
85
+ { id: 'neutral', name: 'Neutral', description: 'The SDK default neutral scale.', variables: {}, darkVariables: {}, swatch: '#a3a3a3' },
86
+ base('stone', 'Stone', 'Warm gray — earthy, editorial.', {
87
+ 50: '#fafaf9', 100: '#f5f5f4', 200: '#e7e5e4', 300: '#d6d3d1', 400: '#a8a29e', 600: '#57534e', 700: '#44403c', 800: '#292524', 900: '#1c1917', 950: '#0c0a09',
88
+ }),
89
+ base('zinc', 'Zinc', 'Cool gray — technical, precise.', {
90
+ 50: '#fafafa', 100: '#f4f4f5', 200: '#e4e4e7', 300: '#d4d4d8', 400: '#a1a1aa', 600: '#52525b', 700: '#3f3f46', 800: '#27272a', 900: '#18181b', 950: '#09090b',
91
+ }),
92
+ base('mauve', 'Mauve', 'Violet-tinted neutral.', {
93
+ 50: 'oklch(98.5% 0 0)', 100: 'oklch(96% 0.003 325.6)', 200: 'oklch(92.2% 0.005 325.62)', 300: 'oklch(86.5% 0.012 325.68)', 400: 'oklch(71.1% 0.019 323.02)', 600: 'oklch(43.5% 0.029 321.78)', 700: 'oklch(36.4% 0.029 323.89)', 800: 'oklch(26.3% 0.024 320.12)', 900: 'oklch(21.2% 0.019 322.12)', 950: 'oklch(14.5% 0.008 326)',
94
+ }),
95
+ base('olive', 'Olive', 'Green-tinted neutral.', {
96
+ 50: 'oklch(98.8% 0.003 106.5)', 100: 'oklch(96.6% 0.005 106.5)', 200: 'oklch(93% 0.007 106.5)', 300: 'oklch(88% 0.011 106.6)', 400: 'oklch(73.7% 0.021 106.9)', 600: 'oklch(46.6% 0.025 107.3)', 700: 'oklch(39.4% 0.023 107.4)', 800: 'oklch(28.6% 0.016 107.4)', 900: 'oklch(22.8% 0.013 107.4)', 950: 'oklch(15.3% 0.006 107.1)',
97
+ }),
98
+ base('mist', 'Mist', 'Blue-tinted neutral.', {
99
+ 50: 'oklch(98.7% 0.002 197.1)', 100: 'oklch(96.3% 0.002 197.1)', 200: 'oklch(92.5% 0.005 214.3)', 300: 'oklch(87.2% 0.007 219.6)', 400: 'oklch(72.3% 0.014 214.4)', 600: 'oklch(45% 0.017 213.2)', 700: 'oklch(37.8% 0.015 216)', 800: 'oklch(27.5% 0.011 216.9)', 900: 'oklch(21.8% 0.008 223.9)', 950: 'oklch(14.8% 0.004 228.8)',
100
+ }),
101
+ base('taupe', 'Taupe', 'Brown-tinted neutral.', {
102
+ 50: 'oklch(98.6% 0.002 67.8)', 100: 'oklch(96% 0.002 17.2)', 200: 'oklch(92.2% 0.005 34.3)', 300: 'oklch(86.8% 0.007 39.5)', 400: 'oklch(71.4% 0.014 41.2)', 600: 'oklch(43.8% 0.017 39.3)', 700: 'oklch(36.7% 0.016 35.7)', 800: 'oklch(26.8% 0.011 36.5)', 900: 'oklch(21.4% 0.009 43.1)', 950: 'oklch(14.7% 0.004 49.3)',
103
+ }),
104
+ ]
105
+
106
+ export function baseColorToVariables(base, opts) {
107
+ const mode = (opts && opts.mode) || 'light'
108
+ return { ...(mode === 'dark' ? base.darkVariables : base.variables) }
109
+ }
110
+
111
+ // ── Accent themes ────────────────────────────────────────────────────
112
+
113
+ export const DEFAULT_ACCENT_THEME_ID = 'default'
114
+
115
+ // ink = on-primary for hues where white text fails contrast on the fill.
116
+ const lightAccent = (s, ink) => ({
117
+ '--color-ak-primary': s[600],
118
+ // Focus + glow follow the accent, mirroring the palette recipe
119
+ // (palettes: border-focus = ring||accent||primary, glow = primary).
120
+ // Without these the base theme's ink focus (#0b0b0b) and the
121
+ // utilities' indigo glow fallback (#818cf8) survive an accent swap.
122
+ '--color-ak-border-focus': s[600],
123
+ '--color-ak-glow-primary': s[600],
124
+ // Full hue parity with the palette layer. Accents apply ON TOP of a
125
+ // palette, so every primary-hue-derived token a palette sets must be
126
+ // overridden here or the palette's hue bleeds through (green gradients
127
+ // under an indigo accent). Neutral/surface tokens stay with the base
128
+ // dial on purpose.
129
+ '--color-ak-primary-50': s[50], '--color-ak-primary-100': s[100],
130
+ '--color-ak-primary-200': s[200], '--color-ak-primary-300': s[300],
131
+ '--color-ak-primary-400': s[400], '--color-ak-primary-500': s[500],
132
+ '--color-ak-primary-600': s[600], '--color-ak-primary-700': s[700],
133
+ '--color-ak-primary-800': s[800], '--color-ak-primary-900': s[900],
134
+ '--color-ak-primary-950': s[950],
135
+ '--color-ak-accent': s[600],
136
+ '--color-ak-on-accent': ink ? '#1c1917' : '#ffffff',
137
+ '--color-ak-accent-soft': s[100],
138
+ '--color-ak-primary-selection': s[600],
139
+ '--color-ak-on-primary-selection': ink ? '#1c1917' : '#ffffff',
140
+ '--color-ak-primary-container-emphasis': s[200],
141
+ '--color-ak-text-link': ink ? s[700] : s[600],
142
+ '--color-ak-hero-accent': s[600],
143
+ '--color-ak-gradient-start': s[400],
144
+ '--color-ak-gradient-mid': s[600],
145
+ '--color-ak-gradient-end': s[800],
146
+ '--gradient-ak-primary': `linear-gradient(135deg, ${s[400]} 0%, ${s[600]} 52%, ${s[800]} 100%)`,
147
+ '--gradient-ak-primary-hover': `linear-gradient(135deg, ${s[500]} 0%, ${s[700]} 48%, ${s[900]} 100%)`,
148
+ '--color-ak-primary-hover': s[700],
149
+ '--color-ak-primary-active': s[800],
150
+ '--color-ak-on-primary': ink ? '#1c1917' : '#ffffff',
151
+ // Palette parity: the tint sits at the 100 step (astral's #ede7ff is a
152
+ // 100-level tint, not a 50 wash) and the ink on it is the VIVID 600 when
153
+ // it clears 4.5:1, falling back to 700.
154
+ '--color-ak-primary-subtle': s[100],
155
+ '--color-ak-primary-subtle-hover': s[200],
156
+ '--color-ak-primary-container': s[100],
157
+ '--color-ak-on-primary-container': s[900],
158
+ '--color-ak-primary-outline': s[400],
159
+ '--color-ak-selection-ink': vividInk(s[600], s[700], s[100]),
160
+ })
161
+
162
+ // Dark: brighter 500 fill, darkened hover/active states (contrast-stable
163
+ // with the same on-primary), hue-tinted dark containers, light readable inks.
164
+ const darkAccent = (s, ink) => ({
165
+ // Non-ink accents wear WHITE text: the bright 500 fill fails 4.5:1 on
166
+ // nearly every hue, so those fills sit at 600 (the light fill — passes by
167
+ // construction). Ink accents keep the bright 500 with ink text.
168
+ '--color-ak-primary': ink ? s[500] : s[600],
169
+ '--color-ak-primary-hover': ink ? s[600] : s[700],
170
+ '--color-ak-primary-active': ink ? s[700] : s[800],
171
+ '--color-ak-border-focus': s[500],
172
+ '--color-ak-glow-primary': s[400],
173
+ // Dark parity — same token set, dark mappings (fills brighten, deeps
174
+ // carry containers). See the light builder for why parity matters.
175
+ '--color-ak-primary-50': s[950], '--color-ak-primary-100': s[900],
176
+ '--color-ak-primary-200': s[800], '--color-ak-primary-300': s[700],
177
+ '--color-ak-primary-400': s[600], '--color-ak-primary-500': s[500],
178
+ '--color-ak-primary-600': s[400], '--color-ak-primary-700': s[300],
179
+ '--color-ak-primary-800': s[200], '--color-ak-primary-900': s[100],
180
+ '--color-ak-primary-950': s[50],
181
+ '--color-ak-accent': ink ? s[500] : s[600],
182
+ '--color-ak-on-accent': ink ? '#1c1917' : '#ffffff',
183
+ '--color-ak-accent-soft': s[900],
184
+ '--color-ak-primary-selection': ink ? s[500] : s[600],
185
+ '--color-ak-on-primary-selection': ink ? '#1c1917' : '#ffffff',
186
+ '--color-ak-primary-container-emphasis': s[800],
187
+ '--color-ak-text-link': s[400],
188
+ '--color-ak-hero-accent': s[500],
189
+ '--color-ak-gradient-start': s[400],
190
+ '--color-ak-gradient-mid': s[500],
191
+ '--color-ak-gradient-end': s[700],
192
+ '--gradient-ak-primary': `linear-gradient(135deg, ${s[400]} 0%, ${s[500]} 52%, ${s[700]} 100%)`,
193
+ '--gradient-ak-primary-hover': `linear-gradient(135deg, ${s[300]} 0%, ${s[400]} 48%, ${s[600]} 100%)`,
194
+ '--color-ak-on-primary': ink ? '#1c1917' : '#ffffff',
195
+ '--color-ak-primary-subtle': s[900],
196
+ '--color-ak-primary-subtle-hover': s[800],
197
+ '--color-ak-primary-container': s[900],
198
+ '--color-ak-on-primary-container': s[100],
199
+ '--color-ak-primary-outline': s[700],
200
+ // 300: the 400 step fails 4.5:1 on the 900 tint
201
+ '--color-ak-selection-ink': s[300],
202
+ })
203
+
204
+ const acc = (id, name, scale, ink) => ({
205
+ id,
206
+ name,
207
+ description: '',
208
+ variables: lightAccent(scale, ink),
209
+ darkVariables: darkAccent(scale, ink),
210
+ swatch: scale[600],
211
+ })
212
+
213
+ export const ACCENT_THEMES = [
214
+ { id: 'default', name: 'Default', description: 'The active palette’s own accent.', variables: {}, darkVariables: {}, swatch: '#a3a3a3' },
215
+ acc('blue', 'Blue', { 50: '#eff6ff', 100: '#dbeafe', 200: '#bfdbfe', 300: '#93c5fd', 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8', 800: '#1e40af', 900: '#1e3a8a', 950: '#172554' }),
216
+ acc('indigo', 'Indigo', { 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 300: '#a5b4fc', 400: '#818cf8', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca', 800: '#3730a3', 900: '#312e81', 950: '#1e1b4b' }),
217
+ acc('violet', 'Violet', { 50: '#f5f3ff', 100: '#ede9fe', 200: '#ddd6fe', 300: '#c4b5fd', 400: '#a78bfa', 500: '#8b5cf6', 600: '#7c3aed', 700: '#6d28d9', 800: '#5b21b6', 900: '#4c1d95', 950: '#2e1065' }),
218
+ acc('purple', 'Purple', { 50: '#faf5ff', 100: '#f3e8ff', 200: '#e9d5ff', 300: '#d8b4fe', 400: '#c084fc', 500: '#a855f7', 600: '#9333ea', 700: '#7e22ce', 800: '#6b21a8', 900: '#581c87', 950: '#3b0764' }),
219
+ acc('pink', 'Pink', { 50: '#fdf2f8', 100: '#fce7f3', 200: '#fbcfe8', 300: '#f9a8d4', 400: '#f472b6', 500: '#ec4899', 600: '#db2777', 700: '#be185d', 800: '#9d174d', 900: '#831843', 950: '#500724' }),
220
+ acc('rose', 'Rose', { 50: '#fff1f2', 100: '#ffe4e6', 200: '#fecdd3', 300: '#fda4af', 400: '#fb7185', 500: '#f43f5e', 600: '#e11d48', 700: '#be123c', 800: '#9f1239', 900: '#881337', 950: '#4c0519' }),
221
+ acc('red', 'Red', { 50: '#fef2f2', 100: '#fee2e2', 200: '#fecaca', 300: '#fca5a5', 400: '#f87171', 500: '#ef4444', 600: '#dc2626', 700: '#b91c1c', 800: '#991b1b', 900: '#7f1d1d', 950: '#450a0a' }),
222
+ acc('orange', 'Orange', { 50: '#fff7ed', 100: '#ffedd5', 200: '#fed7aa', 300: '#fdba74', 400: '#fb923c', 500: '#f97316', 600: '#CA4C0A', 700: '#c2410c', 800: '#9a3412', 900: '#7c2d12', 950: '#431407' }),
223
+ acc('amber', 'Amber', { 50: '#fffbeb', 100: '#fef3c7', 200: '#fde68a', 300: '#fcd34d', 400: '#fbbf24', 500: '#f59e0b', 600: '#B16105', 700: '#b45309', 800: '#92400e', 900: '#78350f', 950: '#451a03' }),
224
+ acc('emerald', 'Emerald', { 50: '#ecfdf5', 100: '#d1fae5', 200: '#a7f3d0', 300: '#6ee7b9', 400: '#34d399', 500: '#10b981', 600: '#05875F', 700: '#047857', 800: '#065f46', 900: '#064e3b', 950: '#022c22' }),
225
+ acc('teal', 'Teal', { 50: '#f0fdfa', 100: '#ccfbf1', 200: '#99f6e4', 300: '#5eead4', 400: '#2dd4bf', 500: '#14b8a6', 600: '#0C8479', 700: '#0f766e', 800: '#115e59', 900: '#134e4a', 950: '#042f2e' }),
226
+ acc('cyan', 'Cyan', { 50: '#ecfeff', 100: '#cffafe', 200: '#a5f3fc', 300: '#67e8f9', 400: '#22d3ee', 500: '#06b6d4', 600: '#07819E', 700: '#0e7490', 800: '#155e75', 900: '#164e63', 950: '#083344' }),
227
+ ]
228
+
229
+ export function accentThemeToVariables(theme, opts) {
230
+ const mode = (opts && opts.mode) || 'light'
231
+ return { ...(mode === 'dark' ? theme.darkVariables : theme.variables) }
232
+ }
233
+
234
+ // ── Radius (the corrected knob) ──────────────────────────────────────
235
+
236
+ /**
237
+ * Emit the FULL derived radius set for a knob value. Container-level
238
+ * overrides of --ak-radius alone do nothing: CSS substitutes the var()
239
+ * chains where they are DEFINED (:root), so descendants inherit resolved
240
+ * values. Spreading this record re-declares the whole chain at the
241
+ * container. Multipliers mirror the SDK theme exactly.
242
+ */
243
+ export function radiusToVariables(value) {
244
+ const rem = typeof value === 'number' ? value : parseFloat(value)
245
+ // PILL MODE (Extra Round): controls go full-pill via CSS's self-clamping
246
+ // 9999px (border-radius larger than the box clamps to a perfect pill),
247
+ // while CONTAINER-tier tokens cap at tasteful roundness so cards and
248
+ // panels never become blobs.
249
+ if (rem >= 62) {
250
+ // Extra Round, tamed (Fred, 2026-08-24): the old full-pill 9999px turned
251
+ // md-radius tiles into circles and textareas into capsules. Generous,
252
+ // BOUNDED roundness instead — clearly rounder than every other stop,
253
+ // never a blob.
254
+ return {
255
+ '--ak-radius': '1.25rem',
256
+ '--radius-ak-xs': '0.5rem',
257
+ '--radius-ak-sm': '0.75rem',
258
+ '--radius-ak-md': '1rem',
259
+ '--radius-ak-lg': '1.5rem',
260
+ '--radius-ak-xl': '1.75rem',
261
+ '--radius-ak-2xl': '2.25rem',
262
+ '--radius-ak-3xl': '2.75rem',
263
+ '--radius-ak-input': '1.25rem',
264
+ '--radius-ak-control-sm': '0.875rem',
265
+ }
266
+ }
267
+ const step = (m) => `${+(rem * m).toFixed(4)}rem`
268
+ return {
269
+ '--ak-radius': `${rem}rem`,
270
+ '--radius-ak-xs': step(0.25),
271
+ '--radius-ak-sm': step(0.5),
272
+ '--radius-ak-md': step(1),
273
+ '--radius-ak-lg': step(1.5),
274
+ '--radius-ak-xl': step(2),
275
+ '--radius-ak-2xl': step(3),
276
+ '--radius-ak-3xl': step(4),
277
+ '--radius-ak-input': step(1.25),
278
+ '--radius-ak-control-sm': `${Math.min(rem, 0.625)}rem`,
279
+ }
280
+ }
281
+
282
+ // ── Type scales ────────────────────────────────────────────────────
283
+
284
+ export const DEFAULT_TYPE_SCALE_ID = 'default'
285
+
286
+ /**
287
+ * TYPE_SCALES — true modular scales, named by their musical interval ratio.
288
+ * The base and everything below it are PINNED (16px body floor, UI-legible
289
+ * smalls); the ratio drives every step ABOVE the base via fixed exponents,
290
+ * so the dropdown is strictly monotonic: each option renders every heading
291
+ * step larger than the option before it. Unitless leadings adapt free.
292
+ */
293
+ // Default small sizes — they keep these PROPORTIONS to the base and lift
294
+ // with it (multiplying by the same gentle base lift keeps every token
295
+ // monotonic across the dropdown; dividing by the ratio made smalls shrink
296
+ // as scales grew).
297
+ const SMALL_DEFAULTS = {
298
+ '--text-ak-2xs': 0.6875,
299
+ '--text-ak-xs': 0.75,
300
+ '--text-ak-xs_5': 0.8125,
301
+ '--text-ak-sm': 0.875,
302
+ '--text-ak-sm_5': 0.9375,
303
+ }
304
+
305
+ // Exponent of the ratio applied per token above the base (anchored at 16px).
306
+ const STEP_EXPONENTS = {
307
+ '--text-ak-lg': 1, '--text-ak-xl': 1.5, '--text-ak-xl_5': 1.75,
308
+ '--text-ak-2xl': 2, '--text-ak-2xl_5': 2.25, '--text-ak-3xl': 2.75,
309
+ '--text-ak-4xl': 3.25, '--text-ak-4xl_5': 3.5, '--text-ak-5xl': 3.75,
310
+ '--text-ak-6xl': 4.25, '--text-ak-6xl_5': 4.5, '--text-ak-7xl': 4.75,
311
+ '--text-ak-7xl_5': 5, '--text-ak-8xl': 5.25, '--text-ak-9xl': 5.75,
312
+ '--text-ak-10xl': 6,
313
+ }
314
+
315
+ function modularRamp(ratio) {
316
+ // The base itself gets a GENTLE ratio lift (exp 0.25): 16px floor at
317
+ // Default, ~18px at Golden — so body copy, default buttons and links all
318
+ // breathe with the scale instead of only the display steps.
319
+ const lift = Math.pow(ratio, 0.25)
320
+ const out = { '--text-ak-base': `${+lift.toFixed(4)}rem` }
321
+ for (const token of Object.keys(SMALL_DEFAULTS)) {
322
+ out[token] = `${+(SMALL_DEFAULTS[token] * lift).toFixed(4)}rem`
323
+ }
324
+ for (const token of Object.keys(STEP_EXPONENTS)) {
325
+ out[token] = `${+(Math.pow(ratio, STEP_EXPONENTS[token])).toFixed(4)}rem`
326
+ }
327
+ // Fluid steps pin to the scale's static sizes: a chosen scale should move
328
+ // EVERY size on the page (fluid-typed components included), trading the
329
+ // viewport ramp for scale fidelity while the dial is active.
330
+ for (const step of ['xs', 'sm', 'base', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl', '8xl', '9xl']) {
331
+ out[`--text-ak-fl-${step}`] = `var(--text-ak-${step})`
332
+ }
333
+ return out
334
+ }
335
+
336
+ export const TYPE_SCALES = [
337
+ {
338
+ id: 'default', name: 'Default', description: 'The authored SDK ramp.',
339
+ variables: {},
340
+ },
341
+ {
342
+ id: 'major-third', name: 'Major Third', description: '1.250 — compact, businesslike hierarchy.',
343
+ variables: modularRamp(1.25),
344
+ },
345
+ {
346
+ id: 'perfect-fourth', name: 'Perfect Fourth', description: '1.333 — the classic editorial workhorse.',
347
+ variables: modularRamp(1.333),
348
+ },
349
+ {
350
+ id: 'augmented-fourth', name: 'Augmented Fourth', description: '1.414 — assertive, modern contrast.',
351
+ variables: modularRamp(1.414),
352
+ },
353
+ {
354
+ id: 'perfect-fifth', name: 'Perfect Fifth', description: '1.500 — bold, display-forward hierarchy.',
355
+ variables: modularRamp(1.5),
356
+ },
357
+ {
358
+ id: 'golden', name: 'Golden Ratio', description: '1.618 — maximum drama for heroes and landings.',
359
+ variables: modularRamp(1.618),
360
+ },
361
+ ]
362
+
363
+ export function typeScaleToVariables(scale) {
364
+ return { ...scale.variables }
365
+ }