@transtyle/exporter-css-variables 0.1.0-alpha.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.
Files changed (2) hide show
  1. package/package.json +39 -0
  2. package/src/index.js +241 -0
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@transtyle/exporter-css-variables",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "Transtyle exporter emitting the resolved semantic catalog as plain CSS custom properties — the simplest possible backend and the plugin-API conformance fixture.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "transtyle": {
11
+ "kind": "exporter",
12
+ "name": "css-variables",
13
+ "irSpec": "v0-draft",
14
+ "pluginApi": "0",
15
+ "targets": {
16
+ "css-variables": [
17
+ "*"
18
+ ]
19
+ },
20
+ "modes": [
21
+ "color-scheme"
22
+ ],
23
+ "capabilities": [
24
+ "build"
25
+ ]
26
+ },
27
+ "files": [
28
+ "src"
29
+ ],
30
+ "publishConfig": { "access": "public" },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/transtyle/transtyle.git",
34
+ "directory": "packages/exporter-css-variables"
35
+ },
36
+ "homepage": "https://github.com/transtyle/transtyle#readme",
37
+ "bugs": "https://github.com/transtyle/transtyle/issues",
38
+ "license": "MIT"
39
+ }
package/src/index.js ADDED
@@ -0,0 +1,241 @@
1
+ /**
2
+ * @transtyle/exporter-css-variables — the simplest possible backend, kept
3
+ * deliberately boring: it dumps the resolved semantic catalog 1:1 as plain
4
+ * CSS custom properties. Spec: docs/specs/exporters/css-variables.md.
5
+ *
6
+ * Two jobs beyond being useful on its own:
7
+ * 1. Executable specification of the plugin API — an exporter is exactly
8
+ * this: `emit(normalized, ctx) -> { files, coverage }`, nothing more.
9
+ * 2. Conformance fixture for plugin testing (Phase 2 kit): its output is a
10
+ * total, framework-free projection of the IR, so any pipeline change
11
+ * that alters resolution shows up here first.
12
+ *
13
+ * Naming: strip the `semantic.` prefix, dots -> dashes. Color-role and
14
+ * content-hierarchy slots keep their `color.` segment (`--color-primary-solid`,
15
+ * `--color-text-base`); the elevation ladder and scrim drop it, since they
16
+ * read as surfaces, not role colors (`--elevation-1-surface`, `--scrim`).
17
+ * Everything else keeps its own top group: `--radius-md`, `--space-4`,
18
+ * `--type-size-md`, `--z-modal`. Composite `type.role.*` values (DTCG
19
+ * `typography`) expand to longhand sub-properties (`-size`/`-weight`/
20
+ * `-leading`/`-family`); `elevation.N.shadow` (DTCG `shadow`) collapses to one
21
+ * box-shadow-shaped value.
22
+ *
23
+ * Mode encoding: `:root` carries color slots for the light map (mode NAMES,
24
+ * never the default flag — ir.md#modes); the dark map goes under
25
+ * `[data-color-scheme="dark"]` (override via options.darkSelector). Non-color
26
+ * slots (radius/space/type/motion/...) are mode-invariant and emitted once —
27
+ * unless they vary by a *non-primary* mode dimension (T8, e.g. `density`),
28
+ * in which case they get their own selector block (see "Extra mode
29
+ * dimensions" below); this is the one exporter that expresses every
30
+ * configured dimension, not just `color-scheme`.
31
+ */
32
+
33
+ export default {
34
+ name: 'css-variables',
35
+
36
+ // Validated by core against the target's `options` at load time (audit A8).
37
+ optionsSchema: {
38
+ type: 'object',
39
+ additionalProperties: false,
40
+ properties: {
41
+ prefix: { type: 'string' },
42
+ darkSelector: { type: 'string' },
43
+ dimensionSelectors: { type: 'object', additionalProperties: { type: 'string' } },
44
+ },
45
+ },
46
+
47
+ emit(normalized, ctx) {
48
+ const prefix = ctx.targetConfig.options?.prefix ? `${ctx.targetConfig.options.prefix}-` : '';
49
+ const darkSelector = ctx.targetConfig.options?.darkSelector ?? '[data-color-scheme="dark"]';
50
+ const light = normalized.modes.light ?? normalized.modes[normalized.defaultMode];
51
+ const dark = normalized.modes.dark;
52
+
53
+ const coverage = [];
54
+ const colorLines = { light: [], dark: [] };
55
+ const invariantLines = [];
56
+
57
+ const slots = [...light.keys()].filter((k) => k.startsWith('semantic.')).sort((a, b) => varName(a, prefix).localeCompare(varName(b, prefix)));
58
+
59
+ for (const slot of slots) {
60
+ const entry = light.get(slot);
61
+ if (entry?.value === undefined) continue;
62
+ const isColor = slot.startsWith('semantic.color.');
63
+ const rendered = renderEntry(entry, ctx);
64
+ if (!rendered) continue;
65
+
66
+ if (isColor) {
67
+ for (const [suffix, value] of rendered) {
68
+ const name = varName(slot, prefix) + suffix;
69
+ colorLines.light.push(cssLine(name, value, entry));
70
+ coverage.push({ variable: name, slot, class: 'native', provenance: entry.provenance.kind });
71
+ }
72
+ const darkEntry = dark?.get(slot);
73
+ if (darkEntry?.value !== undefined) {
74
+ const renderedDark = renderEntry(darkEntry, ctx);
75
+ for (const [suffix, value] of renderedDark) {
76
+ colorLines.dark.push(cssLine(varName(slot, prefix) + suffix, value, darkEntry));
77
+ }
78
+ }
79
+ } else {
80
+ for (const [suffix, value] of rendered) {
81
+ const name = varName(slot, prefix) + suffix;
82
+ invariantLines.push(cssLine(name, value, entry));
83
+ coverage.push({ variable: name, slot, class: 'native', provenance: entry.provenance.kind });
84
+ }
85
+ }
86
+ }
87
+
88
+ // Extra mode dimensions (T8): every dimension beyond the primary
89
+ // (`color-scheme`, handled above) gets one selector block per non-default
90
+ // value, containing only the slots that actually differ from the
91
+ // all-defaults combo (`light`) — e.g. `density: compact` only touches
92
+ // `space.*`, so only those variables appear, not a full re-dump.
93
+ const dimNames = normalized.dimensionNames ?? [normalized.modeDimension];
94
+ const extraDimBlocks = [];
95
+ for (const dimName of dimNames) {
96
+ if (dimName === normalized.modeDimension) continue;
97
+ const dimDef = normalized.dimensions[dimName];
98
+ for (const value of dimDef.values) {
99
+ if (value === dimDef.default) continue;
100
+ const comboValues = Object.fromEntries(dimNames.map((d) => [d, normalized.dimensions[d].default]));
101
+ comboValues[dimName] = value;
102
+ const comboMap = normalized.modes[dimNames.map((d) => comboValues[d]).join('+')];
103
+ if (!comboMap) continue;
104
+ const lines = [];
105
+ for (const slot of [...comboMap.keys()].filter((k) => k.startsWith('semantic.'))) {
106
+ const comboEntry = comboMap.get(slot);
107
+ if (comboEntry?.value === undefined) continue;
108
+ const baseEntry = light.get(slot);
109
+ const rendered = renderEntry(comboEntry, ctx);
110
+ if (!rendered) continue;
111
+ const baseRendered = baseEntry ? renderEntry(baseEntry, ctx) : null;
112
+ if (baseRendered && JSON.stringify(baseRendered) === JSON.stringify(rendered)) continue; // unchanged under this dimension
113
+ for (const [suffix, val] of rendered) lines.push(cssLine(varName(slot, prefix) + suffix, val, comboEntry));
114
+ }
115
+ if (lines.length) {
116
+ const template = ctx.targetConfig.options?.dimensionSelectors?.[dimName] ?? `[data-${dimName}="{value}"]`;
117
+ extraDimBlocks.push('', `${template.replace('{value}', value)} {`, ...lines, '}');
118
+ }
119
+ }
120
+ }
121
+
122
+ const css = [
123
+ '/*',
124
+ ` * GENERATED by transtyle — do not edit; source: ${ctx.projectName} token files`,
125
+ ' * Target: css-variables (the resolved semantic catalog, 1:1) · rules standard@1',
126
+ ` * Modes: :root = light · ${darkSelector} = dark (mode names, never the default flag)`,
127
+ ' */',
128
+ '',
129
+ ':root {',
130
+ ...invariantLines,
131
+ ...colorLines.light,
132
+ '}',
133
+ ...(colorLines.dark.length ? ['', `${darkSelector} {`, ...colorLines.dark, '}'] : []),
134
+ ...extraDimBlocks,
135
+ '',
136
+ ].join('\n');
137
+
138
+ return {
139
+ files: [
140
+ { path: 'variables.transtyle.css', contents: css, kind: 'stylesheet' },
141
+ { path: 'usage.md', contents: renderUsage(ctx, coverage.length, darkSelector, dimNames.filter((d) => d !== normalized.modeDimension)), kind: 'doc' },
142
+ ],
143
+ coverage,
144
+ };
145
+ },
146
+ };
147
+
148
+ // ---------- naming ----------
149
+
150
+ function varName(path, prefix) {
151
+ let rest = path.replace(/^semantic\./, '');
152
+ if (rest.startsWith('color.elevation.') || rest === 'color.scrim') {
153
+ rest = rest.replace(/^color\./, '');
154
+ }
155
+ return '--' + prefix + rest.replace(/\./g, '-');
156
+ }
157
+
158
+ // ---------- value rendering: returns [[suffix, cssValueString], ...] ----------
159
+
160
+ function renderEntry(entry, ctx) {
161
+ const { type, value } = entry;
162
+ if (type === 'color') return [['', ctx.formatColor(value)]];
163
+ if (type === 'dimension' || type === 'duration' || type === 'cubicBezier') return [['', String(value)]];
164
+ if (type === 'number') return [['', String(value)]];
165
+ if (type === 'typography') {
166
+ // AL5: a composite member can be absent — a design system that authors no
167
+ // font family gets a type role with no `fontFamily`, and `String(undefined)`
168
+ // wrote `--type-role-body-md-family: undefined;` into the stylesheet. Emit
169
+ // the longhands that exist; a missing custom property is inert, a malformed
170
+ // one is not.
171
+ return [
172
+ ['-size', value.fontSize],
173
+ ['-weight', value.fontWeight === undefined ? undefined : String(value.fontWeight)],
174
+ ['-leading', value.lineHeight === undefined ? undefined : String(value.lineHeight)],
175
+ ['-family', Array.isArray(value.fontFamily) ? fontList(value.fontFamily) : value.fontFamily],
176
+ ].filter(([, v]) => v !== undefined);
177
+ }
178
+ if (type === 'shadow') {
179
+ return [['', `${value.offsetX} ${value.offsetY} ${value.blur} ${value.spread} ${ctx.formatColor(value.color)}`]];
180
+ }
181
+ if (Array.isArray(value)) return [['', fontList(value)]];
182
+ return [['', String(value)]];
183
+ }
184
+
185
+ const fontList = (value) => value.map((f) => (/[^a-z-]/.test(f) ? `"${f}"` : f)).join(', ');
186
+
187
+ const cssLine = (name, value, entry) =>
188
+ ` ${name}: ${value}; /* ${entry.provenance.kind !== 'authored' ? entry.provenance.kind + ' · ' : ''}${entry.type} */`;
189
+
190
+ // ---------- usage ----------
191
+
192
+ function renderUsage(ctx, count, darkSelector, extraDims) {
193
+ return `# Using these CSS variables
194
+
195
+ The complete resolved semantic catalog of **${ctx.projectName}** (${count} custom properties), framework-free. This is transtyle's simplest target — and the reference projection of the IR: every other exporter's output is some mapping of what you see here.
196
+
197
+ ## Install
198
+
199
+ \`\`\`html
200
+ <link rel="stylesheet" href="variables.transtyle.css">
201
+ \`\`\`
202
+
203
+ \`\`\`css
204
+ .my-button {
205
+ background: var(--color-primary-solid);
206
+ color: var(--color-primary-on-solid);
207
+ border-radius: var(--radius-md);
208
+ }
209
+ .my-button:hover { background: var(--color-primary-solid-hover); }
210
+ \`\`\`
211
+
212
+ ## Dark mode
213
+
214
+ \`:root\` carries the light values; the dark values live under \`${darkSelector}\`:
215
+
216
+ \`\`\`js
217
+ document.documentElement.setAttribute('data-color-scheme', 'dark');
218
+ \`\`\`
219
+
220
+ (Configure the selector via \`options.darkSelector\`, and prefix all variables via \`options.prefix\`.)
221
+ ${extraDims?.length ? `
222
+ ## Other mode dimensions (${extraDims.join(', ')})
223
+
224
+ This design system also declares ${extraDims.length === 1 ? 'a' : ''} mode dimension${extraDims.length === 1 ? '' : 's'} beyond \`color-scheme\`. Each non-default value that actually changes something gets its own selector block, containing only the variables that differ from the default — set the attribute to activate it:
225
+
226
+ \`\`\`js
227
+ document.documentElement.setAttribute('data-${extraDims[0]}', '<non-default-value>');
228
+ \`\`\`
229
+
230
+ Default selector is \`[data-<dimension>="<value>"]\`; override per dimension via \`options.dimensionSelectors\` (e.g. \`{ "${extraDims[0]}": ".${extraDims[0]}-{value}" }\`, where \`{value}\` is replaced with the mode value).
231
+ ` : ''}
232
+ ## Naming
233
+
234
+ Strip \`semantic.\`, dots become dashes: \`color.primary.solid\` → \`--color-primary-solid\`. The elevation ladder and \`scrim\` drop the \`color.\` segment (\`--elevation-1-surface\`, \`--scrim\`) since they're surfaces, not role colors. Composite typography roles (\`type.role.*\`) expand to \`-size\`/\`-weight\`/\`-leading\`/\`-family\`; elevation shadows collapse to one box-shadow-shaped value (\`--elevation-1-shadow\`).
235
+
236
+ ## Regenerating
237
+
238
+ Never edit this file — change the design system tokens and run \`transtyle build css-variables\`.
239
+ See \`report.json\` for provenance per variable (authored vs derived vs defaulted).
240
+ `;
241
+ }