@transtyle/ir 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 +22 -0
  2. package/src/index.js +276 -0
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@transtyle/ir",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "Transtyle IR: semantic catalog, provenance constants, token-tree helpers. Zero dependencies.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "publishConfig": { "access": "public" },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/transtyle/transtyle.git",
17
+ "directory": "packages/ir"
18
+ },
19
+ "homepage": "https://github.com/transtyle/transtyle#readme",
20
+ "bugs": "https://github.com/transtyle/transtyle/issues",
21
+ "license": "MIT"
22
+ }
package/src/index.js ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * @transtyle/ir — IR constants and token-tree helpers.
3
+ * Walking-skeleton implementation of docs/architecture/ir.md (IR spec v0 draft).
4
+ */
5
+
6
+ export const IR_SPEC = 'v0-draft';
7
+
8
+ /**
9
+ * Semantic color roles. Each carries the full role grid (docs/architecture/ir.md
10
+ * #color-the-role-grid, proposal 0001): prominence (solid/tint/outline/text) x
11
+ * interaction state (rest/hover/active/selected) + on-colors.
12
+ */
13
+ export const COLOR_ROLES = [
14
+ 'primary',
15
+ 'secondary',
16
+ 'accent',
17
+ 'success',
18
+ 'warning',
19
+ 'danger',
20
+ 'info',
21
+ 'neutral',
22
+ ];
23
+
24
+ /** Valid values for `$extensions.transtyle.role.archetype` (docs/architecture/ir.md §archetypes). */
25
+ export const ROLE_ARCHETYPES = ['brand', 'status', 'neutral'];
26
+
27
+ /** Grid cell suffixes appended to `semantic.color.<role>.`, in derivation order. */
28
+ export const GRID_CELLS = [
29
+ 'solid',
30
+ 'solid-hover',
31
+ 'solid-active',
32
+ 'solid-selected',
33
+ 'tint',
34
+ 'tint-hover',
35
+ 'tint-active',
36
+ 'tint-selected',
37
+ 'outline',
38
+ 'outline-hover',
39
+ 'on-solid',
40
+ 'on-tint',
41
+ 'text',
42
+ 'text-hover',
43
+ 'text-active',
44
+ 'text-strong',
45
+ ];
46
+
47
+ /** Content hierarchy rungs under `semantic.color.text.<rung>` (docs/architecture/ir.md). */
48
+ export const TEXT_RUNGS = ['strong', 'base', 'muted', 'subtle', 'disabled', 'inverse'];
49
+
50
+ /** Elevation ladder: surfaces at levels 0-5, shadows at levels 1-4 (F2: scrim stays separate). */
51
+ export const ELEVATION_LEVELS = [0, 1, 2, 3, 4, 5];
52
+ export const SHADOW_LEVELS = [1, 2, 3, 4];
53
+
54
+ /** z-index ladder — key order is the contract; values are catalog defaults unless authored. */
55
+ export const Z_LADDER = [
56
+ 'hide',
57
+ 'base',
58
+ 'dropdown',
59
+ 'sticky',
60
+ 'banner',
61
+ 'overlay',
62
+ 'modal',
63
+ 'popover',
64
+ 'toast',
65
+ 'tooltip',
66
+ ];
67
+
68
+ /** Provenance kinds. */
69
+ export const PROVENANCE = {
70
+ AUTHORED: 'authored',
71
+ ALIASED: 'aliased',
72
+ DERIVED: 'derived',
73
+ DEFAULTED: 'defaulted',
74
+ };
75
+
76
+ /** Coverage classes (docs/specs/validation-and-coverage.md). */
77
+ export const COVERAGE = ['native', 'derived', 'approximated', 'dropped', 'unsupported'];
78
+
79
+ /**
80
+ * One `dropped` coverage entry per configured mode dimension an exporter
81
+ * doesn't express (docs/architecture/ir.md#modes: "Exporters declare which
82
+ * mode dimensions they can express; inexpressible dimensions surface in the
83
+ * coverage report"). No-op (empty array) when the compile only has the
84
+ * dimensions the exporter already expresses — e.g. a color-scheme-only
85
+ * compile never gets a spurious "density dropped" line.
86
+ */
87
+ export function droppedDimensions(dimensionNames, expressed) {
88
+ return (dimensionNames ?? [])
89
+ .filter((d) => !expressed.includes(d))
90
+ .map((d) => ({
91
+ variable: `(mode:${d})`,
92
+ slot: '—',
93
+ class: 'dropped',
94
+ note: `${d} mode dimension not expressed by this target`,
95
+ }));
96
+ }
97
+
98
+ /**
99
+ * Component tier (docs/plan/component-tier.md C2; docs/specs/component-layer.md;
100
+ * generalized by AL2 — docs/proposals/0003-component-catalog-generalization.md).
101
+ * Per component, per token: `defaultFrom` is a bare `semantic.*` path the token
102
+ * aliases when unauthored, or `component:<path>` to default from another
103
+ * component slot. Either way an empty `component.*` tier still compiles,
104
+ * exactly like every other resolve-or-fill slot in the catalog.
105
+ *
106
+ * `control` is the shared **interactive-control** geometry — the one component
107
+ * grouping both reference component-heavy targets converge on *architecturally*,
108
+ * not just nominally: Bootstrap chains `$btn-padding-*` from the shared
109
+ * `$input-btn-padding-*` root, and PrimeNG's Button reads the same `formField`
110
+ * object its inputs do. Buttons then layer on top (authoring `control.*` moves
111
+ * both; authoring `button.*` moves only buttons) — the two-level model both
112
+ * upstreams already implement. Order matters: entries may only `component:`
113
+ * -default from an EARLIER entry in this object.
114
+ *
115
+ * `defaultFrom` is optional. A slot without one exists **only when authored** —
116
+ * appropriate when the meaning is real and shared but no semantic rung expresses
117
+ * it, so inventing a default would mean picking one target's number. That is the
118
+ * case for `tooltip.max-width` below.
119
+ *
120
+ * Deliberately NOT here (evidence passes, see the proposals): the sm/lg size
121
+ * ladder (both targets have one, but they disagree on which rungs — the
122
+ * disagreement is the finding), nav/list/table item padding (correspondence is
123
+ * nominal, not architectural), and — after the 0004 geometry probe — component
124
+ * sizing generally: of ten geometry concepts Bootstrap tokenizes, six are
125
+ * one-sided or false friends and two more disagree architecturally. Exporters
126
+ * derive all of those privately today.
127
+ */
128
+ export const COMPONENT_CATALOG = {
129
+ control: {
130
+ radius: { type: 'dimension', defaultFrom: 'radius.control' },
131
+ 'padding-x': { type: 'dimension', defaultFrom: 'space.4' },
132
+ 'padding-y': { type: 'dimension', defaultFrom: 'space.2' },
133
+ },
134
+ button: {
135
+ radius: { type: 'dimension', defaultFrom: 'component:control.radius' },
136
+ 'padding-x': { type: 'dimension', defaultFrom: 'component:control.padding-x' },
137
+ 'padding-y': { type: 'dimension', defaultFrom: 'component:control.padding-y' },
138
+ },
139
+ /**
140
+ * The overlay measure (proposal 0004). Promoted on the strongest evidence the
141
+ * two-target bar has seen: Bootstrap (`$tooltip-max-width: 200px`) and PrimeNG
142
+ * (`tooltip.root.maxWidth: 12.5rem` — *the same 200px*) independently constrain
143
+ * the same element the same way at the same measure, and PrimeNG carries only
144
+ * two `maxWidth` slots in its entire 2759-slot surface. Two libraries agreeing
145
+ * that this specific element is the one needing a width ceiling, rather than
146
+ * two libraries incidentally having numbers.
147
+ *
148
+ * The decision underneath is typographic — a tooltip is a short line of text
149
+ * and ~200px is roughly a readable measure — which is a design-system opinion,
150
+ * exactly what belongs in a design-system vocabulary.
151
+ *
152
+ * No `defaultFrom`: the IR has no "readable measure" rung, and synthesizing one
153
+ * from either target's number would be inventing catalog vocabulary on one
154
+ * upstream's authority. Unauthored, both exporters keep their own default.
155
+ */
156
+ tooltip: {
157
+ 'max-width': { type: 'dimension' },
158
+ },
159
+ };
160
+
161
+ /** Reserved mode dimension names (docs/architecture/ir.md §reserved-mode-dimensions) — names only, every dimension stays optional. */
162
+ export const RESERVED_MODE_DIMENSIONS = [
163
+ 'color-scheme',
164
+ 'density',
165
+ 'contrast',
166
+ 'motion',
167
+ 'platform',
168
+ ];
169
+
170
+ /**
171
+ * Combine one value per mode dimension into the compound key used to address
172
+ * a compiled mode combo (docs/plan/catalog-revision.md T8) — `["color-scheme",
173
+ * "density"], {"color-scheme":"dark","density":"compact"}` -> `"dark+compact"`.
174
+ * Order is `dimNames`, not object insertion, so both directions of the
175
+ * key<->values mapping are deterministic across the compile.
176
+ */
177
+ export function comboKey(dimNames, values) {
178
+ return dimNames.map((d) => values[d]).join('+');
179
+ }
180
+
181
+ /**
182
+ * The full cross-product of every configured mode dimension's values, in
183
+ * dimension-declaration order. `dimEntries` is `Object.entries(config.modes)`.
184
+ * Returns `[{ key, values: {dimName: value} }, ...]`.
185
+ */
186
+ export function expandModeMatrix(dimEntries) {
187
+ const dimNames = dimEntries.map(([name]) => name);
188
+ let combos = [{}];
189
+ for (const [name, def] of dimEntries) {
190
+ const next = [];
191
+ for (const combo of combos) for (const v of def.values) next.push({ ...combo, [name]: v });
192
+ combos = next;
193
+ }
194
+ return combos.map((values) => ({ key: comboKey(dimNames, values), values }));
195
+ }
196
+
197
+ const ALIAS_RE = /^\{([^}]+)\}$/;
198
+
199
+ /** If `value` is a DTCG alias like "{option.color.blue.600}", return the path; else null. */
200
+ export function aliasTarget(value) {
201
+ if (typeof value !== 'string') return null;
202
+ const m = ALIAS_RE.exec(value.trim());
203
+ return m ? m[1] : null;
204
+ }
205
+
206
+ /**
207
+ * Collect tokens from a merged DTCG(-superset) tree.
208
+ * Returns Map<path, { type, value, modeValues: {dimension: {modeName: value}} }>.
209
+ * Handles group-level $type inheritance and the transtyle.modes extension.
210
+ */
211
+ export function collectTokens(tree) {
212
+ const out = new Map();
213
+ const walk = (node, path, inheritedType) => {
214
+ if (node === null || typeof node !== 'object' || Array.isArray(node)) return;
215
+ const type = node.$type ?? inheritedType;
216
+ if ('$value' in node) {
217
+ const modeValues = node.$extensions?.['transtyle.modes'] ?? {};
218
+ out.set(path.join('.'), { type, value: node.$value, modeValues });
219
+ return;
220
+ }
221
+ for (const [key, child] of Object.entries(node)) {
222
+ if (key.startsWith('$')) continue;
223
+ walk(child, [...path, key], type);
224
+ }
225
+ };
226
+ walk(tree, [], undefined);
227
+ return out;
228
+ }
229
+
230
+ /**
231
+ * Custom color roles opting into the full grid via `$extensions.transtyle.role`
232
+ * on a `semantic.color.<name>` group (docs/architecture/ir.md §archetypes,
233
+ * plan task T7) — e.g. `{ "solid": {...}, "$extensions": { "transtyle.role":
234
+ * { "archetype": "status" } } }`. Built-in roles are excluded (they don't need
235
+ * the extension). Returns Map<roleName, archetype>.
236
+ */
237
+ export function collectRoleArchetypes(tree, diagnostics) {
238
+ const out = new Map();
239
+ const colorRoot = tree?.semantic?.color;
240
+ if (!colorRoot || typeof colorRoot !== 'object') return out;
241
+ for (const [name, node] of Object.entries(colorRoot)) {
242
+ if (name.startsWith('$') || COLOR_ROLES.includes(name)) continue;
243
+ const archetype = node?.$extensions?.['transtyle.role']?.archetype;
244
+ if (!archetype) continue;
245
+ if (!ROLE_ARCHETYPES.includes(archetype)) {
246
+ diagnostics?.warn(
247
+ 'TST1111',
248
+ `semantic.color.${name}: unknown role archetype "${archetype}" (expected one of ${ROLE_ARCHETYPES.join(', ')}) — role still joins the grid`,
249
+ );
250
+ }
251
+ out.set(name, archetype);
252
+ }
253
+ return out;
254
+ }
255
+
256
+ /** Deep-merge token trees (later wins; conflicts reported via onConflict(path)). */
257
+ export function mergeTrees(trees, onConflict = () => {}) {
258
+ const merged = {};
259
+ const mergeInto = (dst, src, path) => {
260
+ for (const [key, val] of Object.entries(src)) {
261
+ if (val !== null && typeof val === 'object' && !Array.isArray(val) && !('$value' in val)) {
262
+ if (!(key in dst)) dst[key] = {};
263
+ else if ('$value' in dst[key]) {
264
+ onConflict([...path, key].join('.'));
265
+ dst[key] = {};
266
+ }
267
+ mergeInto(dst[key], val, [...path, key]);
268
+ } else {
269
+ if (key in dst && !key.startsWith('$')) onConflict([...path, key].join('.'));
270
+ dst[key] = val;
271
+ }
272
+ }
273
+ };
274
+ for (const t of trees) mergeInto(merged, t, []);
275
+ return merged;
276
+ }