@hewnui/themes 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +33 -2
  3. package/dist/authoring/css-vars.d.ts +10 -0
  4. package/dist/authoring/css-vars.js +83 -0
  5. package/dist/authoring/derive.d.ts +9 -0
  6. package/dist/authoring/derive.js +79 -0
  7. package/dist/authoring/export-css.d.ts +8 -0
  8. package/dist/authoring/export-css.js +60 -0
  9. package/dist/authoring/export-design-md.d.ts +5 -0
  10. package/dist/authoring/export-design-md.js +113 -0
  11. package/dist/authoring/export-dtcg.d.ts +6 -0
  12. package/dist/authoring/export-dtcg.js +85 -0
  13. package/dist/authoring/export-figma.d.ts +27 -0
  14. package/dist/authoring/export-figma.js +76 -0
  15. package/dist/authoring/export-formats.d.ts +27 -0
  16. package/dist/authoring/export-formats.js +62 -0
  17. package/dist/authoring/export-skill.d.ts +5 -0
  18. package/dist/authoring/export-skill.js +75 -0
  19. package/dist/authoring/export-sticker.d.ts +6 -0
  20. package/dist/authoring/export-sticker.js +54 -0
  21. package/dist/authoring/export-style-dictionary.d.ts +3 -0
  22. package/dist/authoring/export-style-dictionary.js +52 -0
  23. package/dist/authoring/figma-kit.d.ts +16 -0
  24. package/dist/authoring/figma-kit.js +41 -0
  25. package/dist/authoring/index.d.ts +21 -0
  26. package/dist/authoring/index.js +18 -0
  27. package/dist/authoring/oklch.d.ts +11 -0
  28. package/dist/authoring/oklch.js +116 -0
  29. package/dist/authoring/override-map.d.ts +9 -0
  30. package/dist/authoring/override-map.js +150 -0
  31. package/dist/authoring/pack-def.d.ts +7 -0
  32. package/dist/authoring/pack-def.js +3 -0
  33. package/dist/authoring/pack-meta.d.ts +10 -0
  34. package/dist/authoring/pack-meta.js +58 -0
  35. package/dist/authoring/packs-dual.d.ts +129 -0
  36. package/dist/authoring/packs-dual.js +130 -0
  37. package/dist/authoring/packs-single.d.ts +87 -0
  38. package/dist/authoring/packs-single.js +88 -0
  39. package/dist/authoring/packs.d.ts +8 -0
  40. package/dist/authoring/packs.js +26 -0
  41. package/dist/authoring/pro-apply.d.ts +8 -0
  42. package/dist/authoring/pro-apply.js +107 -0
  43. package/dist/authoring/propose.d.ts +21 -0
  44. package/dist/authoring/propose.js +162 -0
  45. package/dist/authoring/ramps.d.ts +13 -0
  46. package/dist/authoring/ramps.js +40 -0
  47. package/dist/authoring/status-ramps.d.ts +13 -0
  48. package/dist/authoring/status-ramps.js +76 -0
  49. package/dist/authoring/token-entries.d.ts +10 -0
  50. package/dist/authoring/token-entries.js +52 -0
  51. package/dist/authoring/types.d.ts +110 -0
  52. package/dist/authoring/types.js +11 -0
  53. package/exports/clay/DESIGN.md +138 -0
  54. package/exports/clay/SKILL.md +94 -0
  55. package/exports/clay/theme.css +37 -0
  56. package/exports/clay/tokens.dtcg.json +200 -0
  57. package/exports/dune/DESIGN.md +138 -0
  58. package/exports/dune/SKILL.md +94 -0
  59. package/exports/dune/theme.css +37 -0
  60. package/exports/dune/tokens.dtcg.json +200 -0
  61. package/exports/ember/DESIGN.md +138 -0
  62. package/exports/ember/SKILL.md +94 -0
  63. package/exports/ember/theme.css +37 -0
  64. package/exports/ember/tokens.dtcg.json +200 -0
  65. package/exports/forest/DESIGN.md +138 -0
  66. package/exports/forest/SKILL.md +94 -0
  67. package/exports/forest/theme.css +67 -0
  68. package/exports/forest/tokens.dtcg.json +348 -0
  69. package/exports/graphite/DESIGN.md +138 -0
  70. package/exports/graphite/SKILL.md +94 -0
  71. package/exports/graphite/theme.css +67 -0
  72. package/exports/graphite/tokens.dtcg.json +348 -0
  73. package/exports/index.json +45 -0
  74. package/exports/tide/DESIGN.md +138 -0
  75. package/exports/tide/SKILL.md +94 -0
  76. package/exports/tide/theme.css +67 -0
  77. package/exports/tide/tokens.dtcg.json +348 -0
  78. package/figma/README.md +16 -0
  79. package/figma/hewn.tokens.json +1308 -0
  80. package/figma/sticker-sheet.svg +93 -0
  81. package/package.json +77 -3
  82. package/src/authoring/css-vars.ts +92 -0
  83. package/src/authoring/derive.ts +106 -0
  84. package/src/authoring/export-css.ts +86 -0
  85. package/src/authoring/export-design-md.ts +134 -0
  86. package/src/authoring/export-dtcg.ts +116 -0
  87. package/src/authoring/export-figma.ts +109 -0
  88. package/src/authoring/export-formats.ts +101 -0
  89. package/src/authoring/export-skill.ts +93 -0
  90. package/src/authoring/export-sticker.ts +60 -0
  91. package/src/authoring/export-style-dictionary.ts +67 -0
  92. package/src/authoring/figma-kit.ts +60 -0
  93. package/src/authoring/index.ts +61 -0
  94. package/src/authoring/oklch.ts +137 -0
  95. package/src/authoring/override-map.ts +191 -0
  96. package/src/authoring/pack-def.ts +11 -0
  97. package/src/authoring/pack-meta.ts +66 -0
  98. package/src/authoring/packs-dual.ts +131 -0
  99. package/src/authoring/packs-single.ts +89 -0
  100. package/src/authoring/packs.ts +43 -0
  101. package/src/authoring/pro-apply.ts +156 -0
  102. package/src/authoring/propose.ts +181 -0
  103. package/src/authoring/ramps.ts +62 -0
  104. package/src/authoring/status-ramps.ts +98 -0
  105. package/src/authoring/token-entries.ts +59 -0
  106. package/src/authoring/types.ts +133 -0
  107. package/src/glass-buttons.css +46 -0
  108. package/src/glass.css +266 -0
  109. package/src/motion.css +60 -0
  110. package/src/packs/clay.css +45 -0
  111. package/src/packs/dune.css +45 -0
  112. package/src/packs/ember.css +45 -0
  113. package/src/packs/forest.css +80 -0
  114. package/src/packs/graphite.css +79 -0
  115. package/src/packs/tide.css +79 -0
  116. package/src/radius.css +25 -0
  117. package/src/tokens.css +104 -0
@@ -0,0 +1,156 @@
1
+ import { contrastForeground, parseColor } from "./oklch";
2
+ import { statusColors } from "./status-ramps";
3
+ import type {
4
+ GlassOverrides,
5
+ ModeColorTokens,
6
+ MotionCharacter,
7
+ NeutralOverrides,
8
+ Oklch,
9
+ OverrideMap,
10
+ SemanticOverrides,
11
+ ShapeOverrides,
12
+ StructuralTokens,
13
+ ThemeMode,
14
+ } from "./types";
15
+
16
+ const DENSITY: Record<NonNullable<ShapeOverrides["density"]>, number> = {
17
+ compact: 0.85,
18
+ default: 1,
19
+ comfortable: 1.15,
20
+ };
21
+
22
+ const MOTION: Record<
23
+ Exclude<MotionCharacter, "balanced">,
24
+ Pick<StructuralTokens, "motionDuration" | "motionStiffness" | "motionDamping">
25
+ > = {
26
+ snappy: {
27
+ motionDuration: "180ms",
28
+ motionStiffness: 480,
29
+ motionDamping: 32,
30
+ },
31
+ soft: {
32
+ motionDuration: "340ms",
33
+ motionStiffness: 220,
34
+ motionDamping: 26,
35
+ },
36
+ };
37
+
38
+ function clampL(color: Oklch, delta: number): Oklch {
39
+ return { ...color, l: Math.min(0.99, Math.max(0.05, color.l + delta)) };
40
+ }
41
+
42
+ function scaleC(color: Oklch, scale: number): Oklch {
43
+ return { ...color, c: Math.max(0, color.c * scale) };
44
+ }
45
+
46
+ function biasNeutral(color: Oklch, n: NeutralOverrides): Oklch {
47
+ let next = color;
48
+ if (n.surfaceShift !== undefined) next = clampL(next, n.surfaceShift);
49
+ if (n.chromaScale !== undefined) next = scaleC(next, n.chromaScale);
50
+ return next;
51
+ }
52
+
53
+ export function applyNeutrals(
54
+ colors: ModeColorTokens,
55
+ neutrals: NeutralOverrides | undefined,
56
+ mode: ThemeMode,
57
+ adjustments: string[],
58
+ ): ModeColorTokens {
59
+ if (!neutrals) return colors;
60
+ if (
61
+ neutrals.surfaceShift === undefined &&
62
+ neutrals.chromaScale === undefined
63
+ ) {
64
+ return colors;
65
+ }
66
+
67
+ const next = {
68
+ ...colors,
69
+ background: biasNeutral(colors.background, neutrals),
70
+ surface: biasNeutral(colors.surface, neutrals),
71
+ surfaceRaised: biasNeutral(colors.surfaceRaised, neutrals),
72
+ muted: biasNeutral(colors.muted, neutrals),
73
+ border: biasNeutral(colors.border, neutrals),
74
+ };
75
+
76
+ const fg = contrastForeground(next.background, colors.foreground);
77
+ if (fg.adjusted) {
78
+ next.foreground = fg.color;
79
+ adjustments.push(
80
+ `${mode} foreground adjusted after neutral bias to meet WCAG AA`,
81
+ );
82
+ } else {
83
+ next.foreground = colors.foreground;
84
+ }
85
+
86
+ const mutedFg = contrastForeground(next.muted, colors.mutedForeground, 3);
87
+ next.mutedForeground = mutedFg.color;
88
+
89
+ return next;
90
+ }
91
+
92
+ export function applySemantic(
93
+ colors: ModeColorTokens,
94
+ semantic: SemanticOverrides | undefined,
95
+ mode: ThemeMode,
96
+ adjustments: string[],
97
+ ): ModeColorTokens | { error: string } {
98
+ if (!semantic?.dangerSeed) return colors;
99
+ const seed = parseColor(semantic.dangerSeed);
100
+ if (!seed) return { error: `invalid danger seed: "${semantic.dangerSeed}"` };
101
+
102
+ const danger: Oklch =
103
+ mode === "dark"
104
+ ? { ...seed, l: Math.min(0.78, Math.max(0.58, seed.l + 0.08)) }
105
+ : { ...seed, l: Math.min(0.62, Math.max(0.48, seed.l)) };
106
+
107
+ if (danger.l !== seed.l) {
108
+ adjustments.push(
109
+ `${mode} danger lightness clamped to ${danger.l.toFixed(2)}`,
110
+ );
111
+ }
112
+
113
+ const status = statusColors(mode, danger, {
114
+ success: colors.success.h,
115
+ warning: colors.warning.h,
116
+ info: colors.info.h,
117
+ });
118
+ return {
119
+ ...colors,
120
+ danger,
121
+ dangerForeground: status.dangerForeground,
122
+ dangerSubtle: status.dangerSubtle,
123
+ dangerBorder: status.dangerBorder,
124
+ };
125
+ }
126
+
127
+ export function applyGlass(
128
+ colors: ModeColorTokens,
129
+ glass: GlassOverrides | undefined,
130
+ ): ModeColorTokens {
131
+ if (!glass?.tintStrength || glass.tintStrength === 1) return colors;
132
+ const base = colors.glassTint.alpha ?? 1;
133
+ return {
134
+ ...colors,
135
+ glassTint: {
136
+ ...colors.glassTint,
137
+ alpha: Math.min(0.55, Math.max(0.08, base * glass.tintStrength)),
138
+ },
139
+ };
140
+ }
141
+
142
+ export function applyShape(
143
+ structural: StructuralTokens,
144
+ shape: ShapeOverrides | undefined,
145
+ ): StructuralTokens {
146
+ if (!shape?.density || shape.density === "default") return structural;
147
+ return { ...structural, density: DENSITY[shape.density] };
148
+ }
149
+
150
+ export function applyMotion(
151
+ structural: StructuralTokens,
152
+ motion: OverrideMap["motion"],
153
+ ): StructuralTokens {
154
+ if (!motion || motion === "balanced") return structural;
155
+ return { ...structural, ...MOTION[motion] };
156
+ }
@@ -0,0 +1,181 @@
1
+ import { formatHex, parseColor } from "./oklch";
2
+ import { normalizeOverrideMap } from "./override-map";
3
+ import {
4
+ BASE_PACK_IDS,
5
+ SCHEMA_VERSION,
6
+ type BasePackId,
7
+ type OverrideMap,
8
+ type RadiusPreset,
9
+ } from "./types";
10
+
11
+ const HEX_FIND = /#([0-9a-f]{3}|[0-9a-f]{6})\b/gi;
12
+ const OKLCH_FIND =
13
+ /oklch\(\s*[0-9.]+\s+[0-9.]+\s+[0-9.]+(?:\s*\/\s*[0-9.]+%?)?\s*\)/gi;
14
+
15
+ export type ThemeProposal = {
16
+ basePackId: BasePackId;
17
+ accentSeed: string;
18
+ radiusPreset: RadiusPreset;
19
+ notes: string[];
20
+ };
21
+
22
+ const PACK_BY_HUE: { pack: BasePackId; min: number; max: number }[] = [
23
+ { pack: "clay", min: 10, max: 45 },
24
+ { pack: "dune", min: 45, max: 85 },
25
+ { pack: "forest", min: 110, max: 165 },
26
+ { pack: "tide", min: 175, max: 230 },
27
+ { pack: "graphite", min: 230, max: 280 },
28
+ { pack: "ember", min: 0, max: 25 },
29
+ ];
30
+
31
+ const PACK_KEYWORDS: { pack: BasePackId; words: string[] }[] = [
32
+ { pack: "forest", words: ["forest", "green", "nature", "leaf"] },
33
+ { pack: "tide", words: ["tide", "ocean", "sea", "blue", "cyan"] },
34
+ { pack: "graphite", words: ["graphite", "slate", "mono", "neutral", "gray", "grey"] },
35
+ { pack: "dune", words: ["dune", "sand", "desert", "beige", "warm"] },
36
+ { pack: "clay", words: ["clay", "terracotta", "earthy", "pottery"] },
37
+ { pack: "ember", words: ["ember", "fire", "night", "dark", "crimson"] },
38
+ ];
39
+
40
+ /** Pull hex / oklch color strings from CSS or Tailwind snippets. */
41
+ export function extractColorsFromCss(source: string): string[] {
42
+ const found: string[] = [];
43
+ for (const match of source.matchAll(HEX_FIND)) {
44
+ const normalized = normalizeHex(match[0]);
45
+ if (normalized) found.push(normalized);
46
+ }
47
+ for (const match of source.matchAll(OKLCH_FIND)) {
48
+ const parsed = parseColor(match[0]);
49
+ if (parsed) found.push(formatHex(parsed));
50
+ }
51
+ return [...new Set(found)];
52
+ }
53
+
54
+ export function pickAccentFromColors(colors: string[]): string | null {
55
+ let best: { hex: string; score: number } | null = null;
56
+ for (const hex of colors) {
57
+ const color = parseColor(hex);
58
+ if (!color) continue;
59
+ if (color.l < 0.2 || color.l > 0.9) continue;
60
+ const score = color.c * (1 - Math.abs(color.l - 0.55));
61
+ if (!best || score > best.score) best = { hex, score };
62
+ }
63
+ return best?.hex ?? colors[0] ?? null;
64
+ }
65
+
66
+ function normalizeHex(raw: string): string | null {
67
+ const match = raw.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
68
+ if (!match?.[1]) return null;
69
+ const hex = match[1];
70
+ const full =
71
+ hex.length === 3
72
+ ? hex
73
+ .split("")
74
+ .map((ch) => ch + ch)
75
+ .join("")
76
+ : hex;
77
+ return `#${full.toLowerCase()}`;
78
+ }
79
+
80
+ export function suggestBasePack(accentSeed: string, hint = ""): BasePackId {
81
+ const lower = hint.toLowerCase();
82
+ for (const entry of PACK_KEYWORDS) {
83
+ if (entry.words.some((w) => lower.includes(w))) return entry.pack;
84
+ }
85
+ const color = parseColor(accentSeed);
86
+ if (!color || color.c < 0.02) return "graphite";
87
+ const h = color.h;
88
+ for (const band of PACK_BY_HUE) {
89
+ if (h >= band.min && h < band.max) return band.pack;
90
+ }
91
+ return "forest";
92
+ }
93
+
94
+ export function suggestRadius(hint = ""): RadiusPreset {
95
+ const lower = hint.toLowerCase();
96
+ if (/\b(sharp|square|brutal)\b/.test(lower)) return "sharp";
97
+ if (/\b(pill|full.?round|capsule)\b/.test(lower)) return "pill";
98
+ if (/\b(rounded|soft.?round|friendly)\b/.test(lower)) return "rounded";
99
+ return "soft";
100
+ }
101
+
102
+ /** Deterministic proposal from colors + optional free-text hint (no LLM). */
103
+ export function proposeFromPalette(
104
+ colors: string[],
105
+ hint = "",
106
+ ): ThemeProposal | null {
107
+ const accent = pickAccentFromColors(colors);
108
+ if (!accent) return null;
109
+ const basePackId = suggestBasePack(accent, hint);
110
+ const radiusPreset = suggestRadius(hint);
111
+ return {
112
+ basePackId,
113
+ accentSeed: accent,
114
+ radiusPreset,
115
+ notes: [
116
+ `Accent from palette (${colors.length} color${colors.length === 1 ? "" : "s"})`,
117
+ `Base pack suggested: ${basePackId}`,
118
+ ],
119
+ };
120
+ }
121
+
122
+ /** Offline prompt migration: keywords + embedded colors; never throws. */
123
+ export function proposeFromPrompt(prompt: string): ThemeProposal {
124
+ const colors = extractColorsFromCss(prompt);
125
+ const fromPalette = proposeFromPalette(colors, prompt);
126
+ if (fromPalette) return fromPalette;
127
+ const basePackId = suggestBasePack("#2f6b4f", prompt);
128
+ return {
129
+ basePackId,
130
+ accentSeed: defaultAccentFor(basePackId),
131
+ radiusPreset: suggestRadius(prompt),
132
+ notes: ["No colors in prompt — used keyword defaults"],
133
+ };
134
+ }
135
+
136
+ export function proposalToOverrideMap(
137
+ proposal: ThemeProposal,
138
+ packId = "studio-draft",
139
+ ): OverrideMap {
140
+ return {
141
+ schemaVersion: SCHEMA_VERSION,
142
+ basePackId: proposal.basePackId,
143
+ packId,
144
+ accentSeed: proposal.accentSeed,
145
+ radiusPreset: proposal.radiusPreset,
146
+ };
147
+ }
148
+
149
+ /** Validate raw LLM/model JSON into a ThemeProposal via override normalize. */
150
+ export function parseAiProposal(raw: unknown): ThemeProposal | { error: string } {
151
+ const normalized = normalizeOverrideMap({
152
+ schemaVersion: SCHEMA_VERSION,
153
+ ...(typeof raw === "object" && raw !== null ? raw : {}),
154
+ });
155
+ if (!normalized.ok) {
156
+ return { error: normalized.errors.join("; ") };
157
+ }
158
+ const { basePackId, accentSeed, radiusPreset } = normalized.map;
159
+ if (!accentSeed) return { error: "accentSeed required" };
160
+ if (!BASE_PACK_IDS.includes(basePackId)) {
161
+ return { error: "invalid basePackId" };
162
+ }
163
+ return {
164
+ basePackId,
165
+ accentSeed,
166
+ radiusPreset: radiusPreset ?? "soft",
167
+ notes: ["Validated AI proposal"],
168
+ };
169
+ }
170
+
171
+ function defaultAccentFor(pack: BasePackId): string {
172
+ const map: Record<BasePackId, string> = {
173
+ forest: "#2f6b4f",
174
+ tide: "#2f6f8f",
175
+ graphite: "#4a5568",
176
+ dune: "#b08968",
177
+ clay: "#a65d45",
178
+ ember: "#c45c3e",
179
+ };
180
+ return map[pack];
181
+ }
@@ -0,0 +1,62 @@
1
+ import { contrastForeground, parseColor } from "./oklch";
2
+ import type { Oklch, ThemeMode } from "./types";
3
+
4
+ export type AccentSet = {
5
+ accent: Oklch;
6
+ accentForeground: Oklch;
7
+ ring: Oklch;
8
+ glassTint: Oklch;
9
+ };
10
+
11
+ export function accentSetFromSeed(
12
+ seedInput: string,
13
+ mode: ThemeMode,
14
+ ): { set: AccentSet; adjustments: string[] } | { error: string } {
15
+ const seed = parseColor(seedInput);
16
+ if (!seed) {
17
+ return { error: `invalid accent seed: "${seedInput}"` };
18
+ }
19
+
20
+ const adjustments: string[] = [];
21
+ const accent: Oklch =
22
+ mode === "dark"
23
+ ? { ...seed, l: Math.min(0.82, Math.max(0.62, seed.l + 0.12)) }
24
+ : { ...seed, l: Math.min(0.62, Math.max(0.42, seed.l)) };
25
+
26
+ if (accent.l !== seed.l) {
27
+ adjustments.push(
28
+ `${mode} accent lightness clamped to ${accent.l.toFixed(2)} for surface contrast`,
29
+ );
30
+ }
31
+
32
+ // Dark packs use light accents; prefer light ink so AA holds without
33
+ // brightening the accent beyond the pack character.
34
+ const preferFg: Oklch = {
35
+ l: 0.98,
36
+ c: Math.min(0.02, accent.c),
37
+ h: accent.h,
38
+ };
39
+ const fg = contrastForeground(accent, preferFg);
40
+ if (fg.adjusted) {
41
+ adjustments.push(
42
+ `${mode} accent-foreground adjusted to meet WCAG AA against accent`,
43
+ );
44
+ }
45
+
46
+ const ring = { ...accent, c: Math.min(0.14, accent.c + 0.01) };
47
+ const glassTint: Oklch = {
48
+ ...accent,
49
+ l: mode === "dark" ? Math.min(0.7, accent.l) : Math.min(0.82, accent.l + 0.15),
50
+ alpha: mode === "dark" ? 0.24 : 0.28,
51
+ };
52
+
53
+ return {
54
+ set: {
55
+ accent,
56
+ accentForeground: fg.color,
57
+ ring,
58
+ glassTint,
59
+ },
60
+ adjustments,
61
+ };
62
+ }
@@ -0,0 +1,98 @@
1
+ import type { BasePackId, Oklch, StatusModeColors, ThemeMode } from "./types";
2
+
3
+ /** Pack-character hues for semantic status families (not accent clones). */
4
+ export const PACK_STATUS_HUES: Record<
5
+ BasePackId,
6
+ { success: number; warning: number; info: number }
7
+ > = {
8
+ forest: { success: 148, warning: 78, info: 220 },
9
+ tide: { success: 165, warning: 75, info: 230 },
10
+ graphite: { success: 150, warning: 75, info: 250 },
11
+ dune: { success: 140, warning: 70, info: 230 },
12
+ clay: { success: 145, warning: 55, info: 230 },
13
+ ember: { success: 145, warning: 70, info: 230 },
14
+ };
15
+
16
+ function o(l: number, c: number, h: number, alpha?: number): Oklch {
17
+ return alpha === undefined ? { l, c, h } : { l, c, h, alpha };
18
+ }
19
+
20
+ function family(
21
+ mode: ThemeMode,
22
+ hue: number,
23
+ kind: "success" | "warning" | "info" | "danger",
24
+ dangerSeed?: Oklch,
25
+ ): { base: Oklch; fg: Oklch; subtle: Oklch; border: Oklch } {
26
+ const dark = mode === "dark";
27
+
28
+ if (kind === "danger" && dangerSeed) {
29
+ const base = dangerSeed;
30
+ return {
31
+ base,
32
+ fg: o(0.985, 0.01, base.h),
33
+ subtle: dark ? o(0.32, 0.05, base.h) : o(0.94, 0.035, base.h),
34
+ border: dark ? o(0.72, 0.1, base.h, 0.4) : o(0.56, 0.12, base.h, 0.4),
35
+ };
36
+ }
37
+
38
+ if (kind === "warning") {
39
+ const base = dark ? o(0.78, 0.12, hue) : o(0.75, 0.14, hue);
40
+ const fg = dark ? o(0.22, 0.04, hue) : o(0.25, 0.05, hue);
41
+ return {
42
+ base,
43
+ fg,
44
+ subtle: dark ? o(0.34, 0.04, hue) : o(0.94, 0.04, hue),
45
+ border: dark ? o(0.78, 0.1, hue, 0.4) : o(0.72, 0.12, hue, 0.4),
46
+ };
47
+ }
48
+
49
+ if (kind === "info") {
50
+ const base = dark ? o(0.72, 0.1, hue) : o(0.52, 0.11, hue);
51
+ return {
52
+ base,
53
+ fg: o(0.985, 0.01, hue),
54
+ subtle: dark ? o(0.32, 0.035, hue) : o(0.94, 0.03, hue),
55
+ border: dark ? o(0.72, 0.08, hue, 0.4) : o(0.52, 0.09, hue, 0.4),
56
+ };
57
+ }
58
+
59
+ // success — tide dark needs a slightly deeper base for light-fg AA
60
+ const successL = dark ? (hue === 165 ? 0.71 : 0.72) : 0.55;
61
+ const base = dark ? o(successL, 0.14, hue) : o(0.55, 0.15, hue);
62
+ return {
63
+ base,
64
+ fg: o(0.985, 0.01, hue),
65
+ subtle: dark ? o(0.32, 0.045, hue) : o(0.94, 0.035, hue),
66
+ border: dark ? o(0.72, 0.1, hue, 0.4) : o(0.55, 0.12, hue, 0.4),
67
+ };
68
+ }
69
+
70
+ /** Derive danger/success/warning/info (+ fg/subtle/border) for a mode. */
71
+ export function statusColors(
72
+ mode: ThemeMode,
73
+ danger: Oklch,
74
+ hues: { success: number; warning: number; info: number },
75
+ ): StatusModeColors {
76
+ const dangerSet = family(mode, danger.h, "danger", danger);
77
+ const success = family(mode, hues.success, "success");
78
+ const warning = family(mode, hues.warning, "warning");
79
+ const info = family(mode, hues.info, "info");
80
+
81
+ return {
82
+ dangerForeground: dangerSet.fg,
83
+ dangerSubtle: dangerSet.subtle,
84
+ dangerBorder: dangerSet.border,
85
+ success: success.base,
86
+ successForeground: success.fg,
87
+ successSubtle: success.subtle,
88
+ successBorder: success.border,
89
+ warning: warning.base,
90
+ warningForeground: warning.fg,
91
+ warningSubtle: warning.subtle,
92
+ warningBorder: warning.border,
93
+ info: info.base,
94
+ infoForeground: info.fg,
95
+ infoSubtle: info.subtle,
96
+ infoBorder: info.border,
97
+ };
98
+ }
@@ -0,0 +1,59 @@
1
+ import { formatOklch } from "./oklch";
2
+ import type { ResolvedMode, ResolvedPack, ThemeMode } from "./types";
3
+
4
+ export type TokenEntry = { name: string; value: string; kind: "color" | "number" | "string" };
5
+
6
+ export function modeOrder(pack: ResolvedPack): ThemeMode[] {
7
+ const order: ThemeMode[] = ["light", "dark"];
8
+ return order.filter((mode) => pack.modes[mode]);
9
+ }
10
+
11
+ export function colorEntries(mode: ResolvedMode): TokenEntry[] {
12
+ return [
13
+ { name: "background", value: formatOklch(mode.background), kind: "color" },
14
+ { name: "foreground", value: formatOklch(mode.foreground), kind: "color" },
15
+ { name: "surface", value: formatOklch(mode.surface), kind: "color" },
16
+ { name: "surface-raised", value: formatOklch(mode.surfaceRaised), kind: "color" },
17
+ { name: "muted", value: formatOklch(mode.muted), kind: "color" },
18
+ { name: "muted-foreground", value: formatOklch(mode.mutedForeground), kind: "color" },
19
+ { name: "accent", value: formatOklch(mode.accent), kind: "color" },
20
+ { name: "accent-foreground", value: formatOklch(mode.accentForeground), kind: "color" },
21
+ { name: "border", value: formatOklch(mode.border), kind: "color" },
22
+ { name: "ring", value: formatOklch(mode.ring), kind: "color" },
23
+ { name: "danger", value: formatOklch(mode.danger), kind: "color" },
24
+ { name: "danger-foreground", value: formatOklch(mode.dangerForeground), kind: "color" },
25
+ { name: "danger-subtle", value: formatOklch(mode.dangerSubtle), kind: "color" },
26
+ { name: "danger-border", value: formatOklch(mode.dangerBorder), kind: "color" },
27
+ { name: "success", value: formatOklch(mode.success), kind: "color" },
28
+ { name: "success-foreground", value: formatOklch(mode.successForeground), kind: "color" },
29
+ { name: "success-subtle", value: formatOklch(mode.successSubtle), kind: "color" },
30
+ { name: "success-border", value: formatOklch(mode.successBorder), kind: "color" },
31
+ { name: "warning", value: formatOklch(mode.warning), kind: "color" },
32
+ { name: "warning-foreground", value: formatOklch(mode.warningForeground), kind: "color" },
33
+ { name: "warning-subtle", value: formatOklch(mode.warningSubtle), kind: "color" },
34
+ { name: "warning-border", value: formatOklch(mode.warningBorder), kind: "color" },
35
+ { name: "info", value: formatOklch(mode.info), kind: "color" },
36
+ { name: "info-foreground", value: formatOklch(mode.infoForeground), kind: "color" },
37
+ { name: "info-subtle", value: formatOklch(mode.infoSubtle), kind: "color" },
38
+ { name: "info-border", value: formatOklch(mode.infoBorder), kind: "color" },
39
+ { name: "glass-tint", value: formatOklch(mode.glassTint), kind: "color" },
40
+ ];
41
+ }
42
+
43
+ export function structuralEntries(mode: ResolvedMode): TokenEntry[] {
44
+ return [
45
+ { name: "radius-sm", value: mode.radiusSm, kind: "string" },
46
+ { name: "radius-md", value: mode.radiusMd, kind: "string" },
47
+ { name: "radius-lg", value: mode.radiusLg, kind: "string" },
48
+ { name: "radius-panel", value: mode.radiusPanel, kind: "string" },
49
+ { name: "density", value: String(mode.density), kind: "number" },
50
+ { name: "motion-duration", value: mode.motionDuration, kind: "string" },
51
+ { name: "motion-stiffness", value: String(mode.motionStiffness), kind: "number" },
52
+ { name: "motion-damping", value: String(mode.motionDamping), kind: "number" },
53
+ ];
54
+ }
55
+
56
+ export function primaryMode(pack: ResolvedPack): ResolvedMode {
57
+ const modes = modeOrder(pack);
58
+ return pack.modes[modes[0]!]!;
59
+ }
@@ -0,0 +1,133 @@
1
+ export const SCHEMA_VERSION = 2 as const;
2
+
3
+ /** Flip when monetization lands — Studio UI reads this stub gate. */
4
+ export const PRO_AUTHORING_UNLOCKED = false;
5
+
6
+ export const BASE_PACK_IDS = [
7
+ "forest",
8
+ "tide",
9
+ "graphite",
10
+ "dune",
11
+ "clay",
12
+ "ember",
13
+ ] as const;
14
+
15
+ export type BasePackId = (typeof BASE_PACK_IDS)[number];
16
+ export type ThemeMode = "light" | "dark";
17
+ export type RadiusPreset = "sharp" | "soft" | "rounded" | "pill";
18
+
19
+ export type Oklch = {
20
+ l: number;
21
+ c: number;
22
+ h: number;
23
+ alpha?: number;
24
+ };
25
+
26
+ /** Core semantic surfaces + accent + danger seed (curated per pack mode). */
27
+ export type CoreModeColors = {
28
+ background: Oklch;
29
+ foreground: Oklch;
30
+ surface: Oklch;
31
+ surfaceRaised: Oklch;
32
+ muted: Oklch;
33
+ mutedForeground: Oklch;
34
+ accent: Oklch;
35
+ accentForeground: Oklch;
36
+ border: Oklch;
37
+ ring: Oklch;
38
+ danger: Oklch;
39
+ glassTint: Oklch;
40
+ };
41
+
42
+ /** Status families — base + foreground / subtle / border variants. */
43
+ export type StatusModeColors = {
44
+ dangerForeground: Oklch;
45
+ dangerSubtle: Oklch;
46
+ dangerBorder: Oklch;
47
+ success: Oklch;
48
+ successForeground: Oklch;
49
+ successSubtle: Oklch;
50
+ successBorder: Oklch;
51
+ warning: Oklch;
52
+ warningForeground: Oklch;
53
+ warningSubtle: Oklch;
54
+ warningBorder: Oklch;
55
+ info: Oklch;
56
+ infoForeground: Oklch;
57
+ infoSubtle: Oklch;
58
+ infoBorder: Oklch;
59
+ };
60
+
61
+ /** Full semantic color tokens authored per mode. */
62
+ export type ModeColorTokens = CoreModeColors & StatusModeColors;
63
+
64
+ export type StructuralTokens = {
65
+ radiusSm: string;
66
+ radiusMd: string;
67
+ radiusLg: string;
68
+ radiusPanel: string;
69
+ density: number;
70
+ motionDuration: string;
71
+ motionStiffness: number;
72
+ motionDamping: number;
73
+ };
74
+
75
+ export type ResolvedMode = ModeColorTokens & StructuralTokens & {
76
+ mode: ThemeMode;
77
+ colorScheme: ThemeMode;
78
+ };
79
+
80
+ export type ResolvedPack = {
81
+ id: string;
82
+ basePackId: BasePackId;
83
+ supportedModes: readonly ThemeMode[];
84
+ modes: Partial<Record<ThemeMode, ResolvedMode>>;
85
+ adjustments: string[];
86
+ };
87
+
88
+ export type DensityPreset = "compact" | "default" | "comfortable";
89
+ export type MotionCharacter = "snappy" | "balanced" | "soft";
90
+
91
+ /** Pro — neutrals / surfaces (constrained bias, not free-form tokens). */
92
+ export type NeutralOverrides = {
93
+ /** Lightness shift −0.08…0.08 applied to surfaces. */
94
+ surfaceShift?: number;
95
+ /** Chroma multiplier 0.5…1.5 for tinted neutrals. */
96
+ chromaScale?: number;
97
+ };
98
+
99
+ /** Pro — semantic colors beyond accent. */
100
+ export type SemanticOverrides = {
101
+ /** Danger seed as `oklch(...)` or `#rrggbb`. */
102
+ dangerSeed?: string;
103
+ };
104
+
105
+ /** Pro — density (radius stays free via radiusPreset). */
106
+ export type ShapeOverrides = {
107
+ density?: DensityPreset;
108
+ };
109
+
110
+ /** Pro — glass tint strength. */
111
+ export type GlassOverrides = {
112
+ /** Alpha multiplier 0.5…1.5 on glassTint. */
113
+ tintStrength?: number;
114
+ };
115
+
116
+ /**
117
+ * Override map — free fields + optional Pro groups.
118
+ * schemaVersion migrations keep older maps valid.
119
+ */
120
+ export type OverrideMap = {
121
+ schemaVersion: typeof SCHEMA_VERSION | number;
122
+ basePackId: BasePackId;
123
+ /** Pack id for CSS `[data-theme="…"]` — kebab-case. */
124
+ packId?: string;
125
+ /** Accent seed as `oklch(...)` or `#rrggbb`. */
126
+ accentSeed?: string;
127
+ radiusPreset?: RadiusPreset;
128
+ neutrals?: NeutralOverrides;
129
+ semantic?: SemanticOverrides;
130
+ shape?: ShapeOverrides;
131
+ glass?: GlassOverrides;
132
+ motion?: MotionCharacter;
133
+ };