meno-core 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/chunks/{chunk-NUP7H7D3.js → chunk-7ZLF4NE5.js} +2 -2
  2. package/dist/chunks/{chunk-QWTQZHG3.js → chunk-FQBIC2OB.js} +1 -1
  3. package/dist/chunks/chunk-FQBIC2OB.js.map +7 -0
  4. package/dist/chunks/{chunk-2IIQK7T3.js → chunk-J4IPTP5X.js} +393 -3
  5. package/dist/chunks/{chunk-2IIQK7T3.js.map → chunk-J4IPTP5X.js.map} +3 -3
  6. package/dist/lib/client/index.js +2 -2
  7. package/dist/lib/server/index.js +804 -218
  8. package/dist/lib/server/index.js.map +4 -4
  9. package/dist/lib/shared/index.js +6 -2
  10. package/dist/lib/shared/index.js.map +2 -2
  11. package/lib/server/index.ts +19 -0
  12. package/lib/server/services/ColorService.ts +38 -7
  13. package/lib/server/services/VariableService.ts +34 -5
  14. package/lib/server/ssr/htmlGenerator.ts +18 -2
  15. package/lib/server/ssr/ssrRenderer.test.ts +25 -0
  16. package/lib/server/ssr/ssrRenderer.ts +12 -2
  17. package/lib/server/themeCssCodec.test.ts +154 -0
  18. package/lib/server/themeCssCodec.ts +488 -0
  19. package/lib/server/themeCssStore.test.ts +139 -0
  20. package/lib/server/themeCssStore.ts +210 -0
  21. package/lib/shared/cssGeneration.test.ts +10 -0
  22. package/lib/shared/cssGeneration.ts +10 -8
  23. package/lib/shared/index.ts +6 -0
  24. package/lib/shared/interactiveStyles.test.ts +3 -1
  25. package/lib/shared/tailwindThemeScale.ts +256 -0
  26. package/lib/shared/types/colors.ts +6 -2
  27. package/lib/shared/types/variables.ts +14 -7
  28. package/lib/shared/utilityClassConfig.ts +116 -0
  29. package/lib/shared/utilityClassMapper.test.ts +148 -0
  30. package/lib/shared/utilityClassMapper.ts +33 -0
  31. package/lib/shared/utilityClassNames.ts +146 -0
  32. package/package.json +1 -1
  33. package/dist/chunks/chunk-QWTQZHG3.js.map +0 -7
  34. /package/dist/chunks/{chunk-NUP7H7D3.js.map → chunk-7ZLF4NE5.js.map} +0 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * theme.css store — the read/modify/write layer over a project's `src/styles/theme.css`,
3
+ * which is the single source of truth for design tokens (colors + variables). It replaces
4
+ * the old `colors.json` / `variables.json` pair for astro-format projects.
5
+ *
6
+ * Colors and variables share ONE file, so {@link saveColors} / {@link saveVariables} each
7
+ * re-read the current file and replace only their half (preserving the other half AND any
8
+ * `raw` passthrough), then re-serialize. All writes are funnelled through a single in-process
9
+ * queue so a colors save and a variables save can't clobber each other.
10
+ *
11
+ * On first access for a project that still carries legacy JSON, {@link migrateIfNeeded}
12
+ * regenerates theme.css from those JSON files (authoritative) and deletes them — a one-way,
13
+ * idempotent migration. Legacy JSON-format projects (no `src/pages`) are not handled here;
14
+ * the services keep their JSON path for those.
15
+ */
16
+
17
+ import { existsSync, readFileSync } from 'node:fs';
18
+ import { rm } from 'node:fs/promises';
19
+ import { join } from 'node:path';
20
+ import { getProjectRoot } from './projectContext';
21
+ import { writeFile, readTextFile, fileExists, ensureDir } from './runtime';
22
+ import { configService } from './services/configService';
23
+ import { parseThemeCss, serializeThemeCss, type ScaleConfig, type ThemeModel } from './themeCssCodec';
24
+ import type { ThemeConfig } from '../shared/types/colors';
25
+ import type { CSSVariable } from '../shared/types/variables';
26
+ import { createLogger } from '../shared/logger';
27
+
28
+ const log = createLogger('themeCssStore');
29
+
30
+ /** Absolute path to a project's theme stylesheet. */
31
+ export function themeCssPath(root: string = getProjectRoot()): string {
32
+ return join(root, 'src', 'styles', 'theme.css');
33
+ }
34
+
35
+ /**
36
+ * True when a project keeps its tokens in `theme.css` (astro-format). Detected by an
37
+ * existing theme.css, an `src/pages` layout, or an explicit `"format": "astro"`. Legacy
38
+ * JSON-format projects return false and keep reading colors.json / variables.json.
39
+ */
40
+ export function isThemeCssProject(root: string = getProjectRoot()): boolean {
41
+ if (existsSync(themeCssPath(root))) return true;
42
+ if (existsSync(join(root, 'src', 'pages'))) return true;
43
+ try {
44
+ const cfg = JSON.parse(readFileSync(join(root, 'project.config.json'), 'utf8')) as { format?: string };
45
+ if (cfg.format === 'astro') return true;
46
+ } catch {
47
+ /* no/invalid config → not astro */
48
+ }
49
+ return false;
50
+ }
51
+
52
+ // All writes (and the migration) run serialized so concurrent color/variable saves
53
+ // read-modify-write the shared file without races.
54
+ let writeChain: Promise<unknown> = Promise.resolve();
55
+ function enqueue<T>(fn: () => Promise<T>): Promise<T> {
56
+ const run = writeChain.then(fn, fn);
57
+ writeChain = run.then(
58
+ () => undefined,
59
+ () => undefined,
60
+ );
61
+ return run;
62
+ }
63
+
64
+ async function readJsonSafe<T>(path: string): Promise<T | null> {
65
+ try {
66
+ return JSON.parse(await readTextFile(path)) as T;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function scaleConfig(): ScaleConfig {
73
+ return { breakpoints: configService.getBreakpoints(), responsiveScales: configService.getResponsiveScales() };
74
+ }
75
+
76
+ /** Empty-but-valid model for a project with no theme.css yet. */
77
+ function emptyModel(): ThemeModel {
78
+ return { themes: { default: 'default', themes: { default: { colors: {} } } }, variables: [], raw: [] };
79
+ }
80
+
81
+ /** Parse the on-disk theme.css (or an empty model if absent). Assumes config is loaded. */
82
+ async function readModel(root: string): Promise<ThemeModel> {
83
+ const path = themeCssPath(root);
84
+ if (!(await fileExists(path))) return emptyModel();
85
+ return parseThemeCss(await readTextFile(path), configService.getBreakpoints());
86
+ }
87
+
88
+ /** Serialize + persist a model (creating `src/styles` as needed). Assumes config is loaded. */
89
+ async function writeModel(root: string, model: ThemeModel): Promise<void> {
90
+ const css = serializeThemeCss(model, scaleConfig());
91
+ await ensureDir(join(root, 'src', 'styles'));
92
+ await writeFile(themeCssPath(root), css);
93
+ }
94
+
95
+ /** True when a theme has at least one color (i.e. theme.css already holds real tokens). */
96
+ function hasColors(themes: ThemeConfig): boolean {
97
+ return Object.values(themes.themes ?? {}).some((t) => Object.keys(t.colors ?? {}).length > 0);
98
+ }
99
+
100
+ /** Union of two theme configs — `current` (theme.css) wins on conflict; incoming adds keys. */
101
+ function mergeThemes(current: ThemeConfig, incoming: ThemeConfig | null): ThemeConfig {
102
+ if (!incoming?.themes) return current;
103
+ if (!hasColors(current)) return incoming; // empty theme.css → adopt the JSON wholesale
104
+ const out: ThemeConfig = { default: current.default, palette: current.palette, themes: { ...current.themes } };
105
+ for (const [name, theme] of Object.entries(incoming.themes)) {
106
+ const existing = out.themes[name];
107
+ out.themes[name] = existing ? { ...existing, colors: { ...theme.colors, ...existing.colors } } : theme;
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /** Union of two variable lists by `cssVar` — `current` wins on conflict; incoming adds new. */
113
+ function mergeVariables(current: CSSVariable[], incoming: CSSVariable[] | undefined): CSSVariable[] {
114
+ if (!incoming?.length) return current;
115
+ const seen = new Set(current.map((v) => v.cssVar));
116
+ return [...current, ...incoming.filter((v) => !seen.has(v.cssVar))];
117
+ }
118
+
119
+ /** Marker the serializer always stamps — distinguishes an authored theme.css from a derived one. */
120
+ const AUTHORED_MARKER = 'Meno design tokens';
121
+
122
+ /**
123
+ * Fold a project's `colors.json` + `variables.json` into theme.css, then delete the JSON.
124
+ * Serves two cases:
125
+ * - Legacy migration — theme.css is absent or in the OLD derived format (the previous
126
+ * converter's DO-NOT-EDIT output). The JSON is authoritative: theme.css is rebuilt from it
127
+ * (we must NOT merge the derived file, whose unsectioned `:root` + duplicate `[theme]` blocks
128
+ * would parse into a bogus extra theme).
129
+ * - Overlay — theme.css is already authored (new format) and an external tool (e.g. the
130
+ * style-guide applier) dropped JSON additions. A UNION merge (theme.css wins on key conflict,
131
+ * JSON adds new keys) folds those in without clobbering anything.
132
+ * Foreign CSS (`raw`, e.g. @font-face) is preserved either way. No-op when no JSON is present, so
133
+ * it's safe to run on every load/save. Must be called inside {@link enqueue}.
134
+ */
135
+ async function migrateIfNeeded(root: string): Promise<void> {
136
+ const colorsJson = join(root, 'colors.json');
137
+ const variablesJson = join(root, 'variables.json');
138
+ const hasColorsJson = existsSync(colorsJson);
139
+ const hasVarsJson = existsSync(variablesJson);
140
+ if (!hasColorsJson && !hasVarsJson) return;
141
+ try {
142
+ const jsonColors = hasColorsJson ? await readJsonSafe<ThemeConfig>(colorsJson) : null;
143
+ const jsonVars = hasVarsJson ? await readJsonSafe<{ variables: CSSVariable[] }>(variablesJson) : null;
144
+
145
+ let current = emptyModel();
146
+ let authored = false;
147
+ if (await fileExists(themeCssPath(root))) {
148
+ const text = await readTextFile(themeCssPath(root));
149
+ authored = text.includes(AUTHORED_MARKER);
150
+ current = parseThemeCss(text, configService.getBreakpoints());
151
+ }
152
+ // Authored → overlay-merge (theme.css wins). Derived/absent → JSON authoritative (base empty),
153
+ // but keep any foreign CSS the derived file happened to carry.
154
+ const base = authored ? current : emptyModel();
155
+ const merged: ThemeModel = {
156
+ themes: mergeThemes(base.themes, jsonColors?.themes ? jsonColors : null),
157
+ variables: mergeVariables(base.variables, jsonVars?.variables),
158
+ raw: current.raw,
159
+ };
160
+ await writeModel(root, merged);
161
+ await rm(colorsJson, { force: true });
162
+ await rm(variablesJson, { force: true });
163
+ log.info('Folded colors.json / variables.json into src/styles/theme.css');
164
+ } catch (err) {
165
+ // Leave the JSON in place so a later attempt can retry; surface but don't throw.
166
+ log.warn('theme.css migration failed; keeping JSON files:', err);
167
+ }
168
+ }
169
+
170
+ /** Load the full token model (runs the one-time JSON→theme.css migration first). */
171
+ export async function loadThemeModel(root: string = getProjectRoot()): Promise<ThemeModel> {
172
+ await configService.load();
173
+ return enqueue(async () => {
174
+ await migrateIfNeeded(root);
175
+ return readModel(root);
176
+ });
177
+ }
178
+
179
+ /** Persist colors (themes) into theme.css, preserving the variables + raw already there. */
180
+ export async function saveColors(themes: ThemeConfig, root: string = getProjectRoot()): Promise<void> {
181
+ await configService.load();
182
+ return enqueue(async () => {
183
+ await migrateIfNeeded(root);
184
+ const current = await readModel(root);
185
+ await writeModel(root, { themes, variables: current.variables, raw: current.raw });
186
+ });
187
+ }
188
+
189
+ /** Persist variables into theme.css, preserving the colors + raw already there. */
190
+ export async function saveVariables(variables: CSSVariable[], root: string = getProjectRoot()): Promise<void> {
191
+ await configService.load();
192
+ return enqueue(async () => {
193
+ await migrateIfNeeded(root);
194
+ const current = await readModel(root);
195
+ await writeModel(root, { themes: current.themes, variables, raw: current.raw });
196
+ });
197
+ }
198
+
199
+ /**
200
+ * Re-serialize theme.css from its own current contents — used after a `project.config.json`
201
+ * scale-knob change to regenerate the AUTO `@media` region from the new config.
202
+ */
203
+ export async function regenerateThemeCss(root: string = getProjectRoot()): Promise<void> {
204
+ await configService.load();
205
+ return enqueue(async () => {
206
+ await migrateIfNeeded(root);
207
+ if (!(await fileExists(themeCssPath(root)))) return;
208
+ await writeModel(root, await readModel(root));
209
+ });
210
+ }
@@ -694,6 +694,16 @@ describe('styleObjectToCSS', () => {
694
694
  expect(styleObjectToCSS({ color: 'rgb(0,0,0)' })).toBe('color: rgb(0,0,0)');
695
695
  });
696
696
 
697
+ test('named CSS colors and keywords pass through — never wrapped in var()', () => {
698
+ // Regression: a literal `red` was being emitted as `var(--red)` (a token
699
+ // that does not exist) instead of the real CSS color.
700
+ expect(styleObjectToCSS({ color: 'red' })).toBe('color: red');
701
+ expect(styleObjectToCSS({ backgroundColor: 'transparent' })).toBe('background-color: transparent');
702
+ expect(styleObjectToCSS({ color: 'currentColor' })).toBe('color: currentColor');
703
+ expect(styleObjectToCSS({ borderColor: 'rebeccapurple' })).toBe('border-color: rebeccapurple');
704
+ expect(styleObjectToCSS({ color: 'inherit' })).toBe('color: inherit');
705
+ });
706
+
697
707
  test('skips _mapping (binding) values — bound props yield empty/partial output', () => {
698
708
  expect(styleObjectToCSS({ color: { _mapping: { prop: 'x' } } as never })).toBe('');
699
709
  expect(styleObjectToCSS({ color: { _mapping: { prop: 'x' } } as never, padding: '4px' })).toBe('padding: 4px');
@@ -11,6 +11,7 @@ import {
11
11
  breakpointClassPrefixes,
12
12
  resolveRootProperty,
13
13
  camelToKebab,
14
+ isBareColorTokenName,
14
15
  } from './utilityClassNames';
15
16
  import { getStyleValue, getDynamicStyle, isDynamicClass } from './styleValueRegistry';
16
17
  import type { BreakpointConfig } from './breakpoints';
@@ -213,6 +214,9 @@ export function generateRuleForClass(className: string, knownTokens?: ReadonlySe
213
214
  }
214
215
 
215
216
  // Multi-property roots (Tailwind axis shorthands)
217
+ if (root === 'size') {
218
+ return `width: ${value}; height: ${value};`;
219
+ }
216
220
  if (root === 'px') {
217
221
  return `padding-left: ${value}; padding-right: ${value};`;
218
222
  }
@@ -726,15 +730,13 @@ export function styleObjectToCSS(style: StyleObject): string {
726
730
  // Convert camelCase to kebab-case
727
731
  const cssProperty = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
728
732
 
729
- // Handle CSS variable references for color properties
733
+ // Handle CSS variable references for color properties. Only a bare Meno
734
+ // color *token* (e.g. `primary`) is wrapped in `var(--token)`. Named CSS
735
+ // colors (`red`), hex, color functions and Tailwind palette names are real
736
+ // values and pass through untouched — mirrors the utility-class engine so
737
+ // the interactive path and the static path resolve colors identically.
730
738
  let cssValue = String(value);
731
- if (
732
- (prop === 'backgroundColor' || prop === 'color' || prop === 'borderColor') &&
733
- !cssValue.startsWith('#') &&
734
- !cssValue.startsWith('rgb') &&
735
- !cssValue.startsWith('var(') &&
736
- !cssValue.includes('(')
737
- ) {
739
+ if ((prop === 'backgroundColor' || prop === 'color' || prop === 'borderColor') && isBareColorTokenName(cssValue)) {
738
740
  cssValue = `var(--${cssValue})`;
739
741
  }
740
742
 
@@ -30,6 +30,12 @@ export { splitVariantPrefix, normalizeBreakpointVariant } from './utilityClassNa
30
30
  // Utility-class catalog pieces — the seed for the Styles panel's class-bar autocomplete (§13 Phase 5).
31
31
  // Selective (not `export *`) to keep the barrel collision-free.
32
32
  export { propertyMap, staticUtilityReverse, keywordValues, presetClassReverse } from './utilityClassConfig';
33
+ // Tailwind theme-scale tokens + the seed-variable generator (`text-lg` → `var(--text-lg, …)`).
34
+ export {
35
+ THEME_SCALE_ENTRIES,
36
+ defaultTailwindThemeVariables,
37
+ type ThemeScaleEntry,
38
+ } from './tailwindThemeScale';
33
39
  export * from './elementClassName';
34
40
  export * from './styleValueRegistry';
35
41
 
@@ -33,8 +33,10 @@ describe('generateInteractiveCSS', () => {
33
33
  const css = generateInteractiveCSS(elementClass, interactiveStyles);
34
34
 
35
35
  expect(css).toContain(`.${elementClass}:hover`);
36
+ // `primary` is a bare Meno token → wrapped in var(); `white` is a real
37
+ // named CSS color → passes through untouched (not var(--white)).
36
38
  expect(css).toContain('background-color: var(--primary)');
37
- expect(css).toContain('color: var(--white)');
39
+ expect(css).toContain('color: white');
38
40
  });
39
41
 
40
42
  test('should generate active styles for page element', () => {
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Tailwind named-scale → CSS-variable theme table.
3
+ *
4
+ * Tailwind's *named* value scales (`text-lg`, `font-semibold`, `rounded-md`, `shadow-lg`,
5
+ * `max-w-md`, `leading-tight`, `tracking-wide`, `font-sans`) have no single numeric mapping — each
6
+ * name is a theme token. Meno is token-based, so the class resolves to a plain variable reference
7
+ * with **no baked default** — it renders the project's variable when defined, and is inert when not
8
+ * (Meno deliberately does NOT smuggle Tailwind's opinionated defaults into the render):
9
+ *
10
+ * `text-lg` → `font-size: var(--text-lg)`
11
+ *
12
+ * Define `--text-lg` (in `variables.json`) → `text-lg` uses it everywhere; leave it undefined →
13
+ * the declaration has no effect (use an arbitrary value like `text-[18px]` for a one-off). See the
14
+ * styling docs for the authoring guidance.
15
+ *
16
+ * This table is the single source of truth for:
17
+ * - the parser (`parseUtilityClass`): class → `var(--token)`
18
+ * - the inverse mapper (`propertyValueToClass`): `var(--token)` → compact class
19
+ * - `defaultTailwindThemeVariables()`: an OPT-IN starter set (Tailwind values) a user can choose
20
+ * to create — it is never auto-applied and never a render fallback.
21
+ */
22
+
23
+ import type { CSSVariable, VariableGroup, VariableType } from './types/variables';
24
+
25
+ /** One Tailwind theme token: the class it backs, its CSS var, default value and UI grouping. */
26
+ export interface ThemeScaleEntry {
27
+ /** Full utility class, e.g. `text-lg`, or the bare default (`rounded`, `shadow`). */
28
+ className: string;
29
+ /** The class root (`text`, `font`, `rounded`, `shadow`, `max-w`, `leading`, `tracking`). */
30
+ root: string;
31
+ /** CSS custom property, e.g. `--text-lg`. */
32
+ cssVar: string;
33
+ /** kebab-case CSS property the class targets. */
34
+ property: string;
35
+ /** Tailwind's default value — the starter value for the OPT-IN seed set, NOT a render fallback. */
36
+ value: string;
37
+ /** Variable picker group + scaling type for the seeded variable. */
38
+ group: VariableGroup;
39
+ /** Human label for the seeded variable, e.g. `Text LG`. */
40
+ label: string;
41
+ }
42
+
43
+ type ScaleSpec = {
44
+ root: string;
45
+ property: string;
46
+ group: VariableGroup;
47
+ /** Prefix for the css var; class is `${root}-${name}` (or bare `${root}` when name is ''). */
48
+ varPrefix: string;
49
+ /** Human label prefix for the seeded variable. */
50
+ labelPrefix: string;
51
+ /** [classSuffix, cssValue] pairs. '' suffix = the bare class (`rounded`, `shadow`). */
52
+ values: [string, string][];
53
+ };
54
+
55
+ const SANS = 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif';
56
+ const SERIF = 'ui-serif, Georgia, Cambria, serif';
57
+ const MONO = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace';
58
+
59
+ const SPECS: ScaleSpec[] = [
60
+ {
61
+ root: 'text',
62
+ property: 'font-size',
63
+ group: 'font-size',
64
+ varPrefix: 'text',
65
+ labelPrefix: 'Text',
66
+ values: [
67
+ ['xs', '0.75rem'],
68
+ ['sm', '0.875rem'],
69
+ ['base', '1rem'],
70
+ ['lg', '1.125rem'],
71
+ ['xl', '1.25rem'],
72
+ ['2xl', '1.5rem'],
73
+ ['3xl', '1.875rem'],
74
+ ['4xl', '2.25rem'],
75
+ ['5xl', '3rem'],
76
+ ['6xl', '3.75rem'],
77
+ ['7xl', '4.5rem'],
78
+ ['8xl', '6rem'],
79
+ ['9xl', '8rem'],
80
+ ],
81
+ },
82
+ {
83
+ // Only the weights Meno's static utilities don't already cover (normal/bold stay static).
84
+ root: 'font',
85
+ property: 'font-weight',
86
+ group: 'font-weight',
87
+ varPrefix: 'font-weight',
88
+ labelPrefix: 'Font Weight',
89
+ values: [
90
+ ['thin', '100'],
91
+ ['extralight', '200'],
92
+ ['light', '300'],
93
+ ['medium', '500'],
94
+ ['semibold', '600'],
95
+ ['extrabold', '800'],
96
+ ['black', '900'],
97
+ ],
98
+ },
99
+ {
100
+ root: 'font',
101
+ property: 'font-family',
102
+ group: 'font-family',
103
+ varPrefix: 'font',
104
+ labelPrefix: 'Font',
105
+ values: [
106
+ ['sans', SANS],
107
+ ['serif', SERIF],
108
+ ['mono', MONO],
109
+ ],
110
+ },
111
+ {
112
+ root: 'rounded',
113
+ property: 'border-radius',
114
+ group: 'border-radius',
115
+ varPrefix: 'radius',
116
+ labelPrefix: 'Radius',
117
+ values: [
118
+ ['', '0.25rem'],
119
+ ['sm', '0.125rem'],
120
+ ['md', '0.375rem'],
121
+ ['lg', '0.5rem'],
122
+ ['xl', '0.75rem'],
123
+ ['2xl', '1rem'],
124
+ ['3xl', '1.5rem'],
125
+ ],
126
+ },
127
+ {
128
+ root: 'shadow',
129
+ property: 'box-shadow',
130
+ group: 'shadow',
131
+ varPrefix: 'shadow',
132
+ labelPrefix: 'Shadow',
133
+ values: [
134
+ ['', '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)'],
135
+ ['sm', '0 1px 2px 0 rgb(0 0 0 / 0.05)'],
136
+ ['md', '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)'],
137
+ ['lg', '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)'],
138
+ ['xl', '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)'],
139
+ ['2xl', '0 25px 50px -12px rgb(0 0 0 / 0.25)'],
140
+ ],
141
+ },
142
+ {
143
+ root: 'max-w',
144
+ property: 'max-width',
145
+ group: 'size',
146
+ varPrefix: 'container',
147
+ labelPrefix: 'Container',
148
+ values: [
149
+ ['xs', '20rem'],
150
+ ['sm', '24rem'],
151
+ ['md', '28rem'],
152
+ ['lg', '32rem'],
153
+ ['xl', '36rem'],
154
+ ['2xl', '42rem'],
155
+ ['3xl', '48rem'],
156
+ ['4xl', '56rem'],
157
+ ['5xl', '64rem'],
158
+ ['6xl', '72rem'],
159
+ ['7xl', '80rem'],
160
+ ['prose', '65ch'],
161
+ ],
162
+ },
163
+ {
164
+ root: 'leading',
165
+ property: 'line-height',
166
+ group: 'line-height',
167
+ varPrefix: 'leading',
168
+ labelPrefix: 'Leading',
169
+ values: [
170
+ ['none', '1'],
171
+ ['tight', '1.25'],
172
+ ['snug', '1.375'],
173
+ ['relaxed', '1.625'],
174
+ ['loose', '2'],
175
+ ],
176
+ },
177
+ {
178
+ root: 'tracking',
179
+ property: 'letter-spacing',
180
+ group: 'letter-spacing',
181
+ varPrefix: 'tracking',
182
+ labelPrefix: 'Tracking',
183
+ values: [
184
+ ['tighter', '-0.05em'],
185
+ ['tight', '-0.025em'],
186
+ ['wide', '0.025em'],
187
+ ['wider', '0.05em'],
188
+ ['widest', '0.1em'],
189
+ ],
190
+ },
191
+ ];
192
+
193
+ /** Size codes read better uppercased (`LG`, `2XL`); word names get title case (`Semibold`). */
194
+ function labelSuffix(suffix: string): string {
195
+ if (/^(xs|sm|md|lg|xl|\dxl)$/.test(suffix)) return suffix.toUpperCase();
196
+ return suffix.charAt(0).toUpperCase() + suffix.slice(1);
197
+ }
198
+
199
+ /** All theme entries, flattened. */
200
+ export const THEME_SCALE_ENTRIES: ThemeScaleEntry[] = SPECS.flatMap((spec) =>
201
+ spec.values.map(([suffix, value]) => {
202
+ const className = suffix ? `${spec.root}-${suffix}` : spec.root;
203
+ const cssVar = suffix ? `--${spec.varPrefix}-${suffix}` : `--${spec.varPrefix}`;
204
+ const label = suffix ? `${spec.labelPrefix} ${labelSuffix(suffix)}` : spec.labelPrefix;
205
+ return { className, root: spec.root, cssVar, property: spec.property, value, group: spec.group, label };
206
+ }),
207
+ );
208
+
209
+ /** className → entry (parser lookup). e.g. `text-lg`, `rounded`, `font-sans`. */
210
+ export const THEME_SCALE_BY_CLASS: ReadonlyMap<string, ThemeScaleEntry> = new Map(
211
+ THEME_SCALE_ENTRIES.map((e) => [e.className, e]),
212
+ );
213
+
214
+ /** cssVar → entry (inverse-mapper lookup). e.g. `--text-lg` → `text-lg`. */
215
+ export const THEME_SCALE_BY_CSSVAR: ReadonlyMap<string, ThemeScaleEntry> = new Map(
216
+ THEME_SCALE_ENTRIES.map((e) => [e.cssVar, e]),
217
+ );
218
+
219
+ /**
220
+ * The CSS value a themed class resolves to: a plain `var(--token)` reference with no fallback.
221
+ * Renders the project variable when defined, inert otherwise (token-based by design — no baked
222
+ * Tailwind default in the render path).
223
+ */
224
+ export function themeScaleValue(entry: ThemeScaleEntry): string {
225
+ return `var(${entry.cssVar})`;
226
+ }
227
+
228
+ /** Map a variable group to its responsive-scaling type (mirrors getDefaultScalingType). */
229
+ function scalingTypeFor(group: VariableGroup): VariableType {
230
+ switch (group) {
231
+ case 'font-size':
232
+ return 'fontSize';
233
+ case 'border-radius':
234
+ return 'borderRadius';
235
+ case 'size':
236
+ return 'size';
237
+ default:
238
+ return 'none';
239
+ }
240
+ }
241
+
242
+ /**
243
+ * The full set of Tailwind theme tokens as Meno `CSSVariable`s, ready to merge into a project's
244
+ * `variables.json`. Seeding makes the tokens visible/editable in the variable picker (filed under
245
+ * their group); rendering does NOT require seeding — the class carries the Tailwind default as the
246
+ * `var()` fallback. Idempotent merge is the caller's job (skip a `cssVar` that already exists).
247
+ */
248
+ export function defaultTailwindThemeVariables(): CSSVariable[] {
249
+ return THEME_SCALE_ENTRIES.map((e) => ({
250
+ name: e.label,
251
+ cssVar: e.cssVar,
252
+ value: e.value,
253
+ type: scalingTypeFor(e.group),
254
+ group: e.group,
255
+ }));
256
+ }
@@ -12,10 +12,14 @@ export interface ColorVariables {
12
12
  }
13
13
 
14
14
  /**
15
- * Theme configuration with color set and metadata
15
+ * Theme configuration with color set and metadata.
16
+ *
17
+ * `label` is an editor-only display string. The theme.css codec does not persist
18
+ * it (a theme is identified by its name — the `[theme="<name>"]` selector), so it
19
+ * is optional; consumers fall back to the (prettified) theme name.
16
20
  */
17
21
  export interface Theme {
18
- label: string;
22
+ label?: string;
19
23
  colors: Record<string, string>;
20
24
  }
21
25
 
@@ -146,22 +146,29 @@ export function getGroupForProperty(prop: string): VariableGroup | null {
146
146
  }
147
147
 
148
148
  /**
149
- * A single CSS custom property definition
149
+ * A single CSS custom property definition.
150
+ *
151
+ * Source of truth is `src/styles/theme.css` (the `theme.css` codec), so the CSS
152
+ * variable name (`cssVar`) IS a token's identity — `name`/`prop_name` are
153
+ * editor-only labels that the codec no longer persists, and `group`/`type` are
154
+ * DERIVED on parse (from the var's comment-section header, falling back to a
155
+ * value heuristic), not stored. They stay on the in-memory object because the
156
+ * pickers and the responsive-scaling generator still consult them.
150
157
  */
151
158
  export interface CSSVariable {
152
- /** Display name (e.g., "H1 Font Size") */
153
- name: string;
154
- /** Optional prop name alias for organizational purposes */
159
+ /** Optional display label. Not persisted by the theme.css codec — defaults to `cssVar`. */
160
+ name?: string;
161
+ /** Optional prop name alias for organizational purposes (legacy; not persisted). */
155
162
  prop_name?: string;
156
- /** CSS custom property name (e.g., "--h1-fs") */
163
+ /** CSS custom property name (e.g., "--h1-fs") — the token's persistent identity. */
157
164
  cssVar: string;
158
165
  /** Base value (e.g., "48px") */
159
166
  value: string;
160
- /** Category for responsive scaling */
167
+ /** Category for responsive scaling (derived from {@link group} via getDefaultScalingType). */
161
168
  type: VariableType;
162
169
  /** Optional per-variable breakpoint scale overrides */
163
170
  scales?: Record<string, string>;
164
- /** Optional UI filtering group for the variable picker */
171
+ /** UI filtering group for the variable picker (derived from the theme.css comment section). */
165
172
  group?: VariableGroup;
166
173
  }
167
174