@transtyle/exporter-bootstrap 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.
- package/package.json +43 -0
- package/src/components.js +326 -0
- package/src/descriptors.js +665 -0
- package/src/index.js +686 -0
- package/surface-inventory.json +12855 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @transtyle/exporter-bootstrap — emits a Bootstrap (>=5.3 <6) theme from the
|
|
3
|
+
* resolved IR. Spec: docs/specs/exporters/bootstrap.md.
|
|
4
|
+
*
|
|
5
|
+
* Two consumption paths (the Bootstrap community is split):
|
|
6
|
+
* - Sass path (full fidelity): `_variables.transtyle.scss` before Bootstrap,
|
|
7
|
+
* `_maps.transtyle.scss` after Bootstrap's own variables — our OKLCH-derived
|
|
8
|
+
* subtle/emphasis values REPLACE Bootstrap's sRGB tint/shade derivations.
|
|
9
|
+
* - CSS-variable path (lower fidelity, documented): `bootstrap-theme.css`
|
|
10
|
+
* loaded after bootstrap.css — rethemes the token tier only; values Sass
|
|
11
|
+
* baked into component rules (`.btn-primary` backgrounds, hovers) are out
|
|
12
|
+
* of reach (exercise finding F13).
|
|
13
|
+
*
|
|
14
|
+
* Exporter conventions, now engine-owned via the role grid
|
|
15
|
+
* (docs/architecture/ir.md#color-the-role-grid; docs/plan/catalog-revision.md T3):
|
|
16
|
+
* - $light/$dark pseudo-roles (F12): neutral.tint / neutral.text-strong.
|
|
17
|
+
* - border-subtle(role) = role.outline directly (was a private mix; F10 is
|
|
18
|
+
* now a first-class grid cell — no exporter-side formula left).
|
|
19
|
+
* - $light/$dark's own bg-subtle stay Bootstrap-private (no grid equivalent
|
|
20
|
+
* for "a tint of a tint"); $light's border-subtle = neutral.outline
|
|
21
|
+
* (same 0.70 ratio as every other role, so no private formula needed there
|
|
22
|
+
* either); $dark's border-subtle keeps its private 0.55 mix.
|
|
23
|
+
* - link ← primary; dark link ← ring[dark] (F13 asymmetry), hover = one
|
|
24
|
+
* lightness step (+0.05 in dark, primary.solid-hover in light).
|
|
25
|
+
* - shadows composed from scrim at fixed alpha ramps (F2).
|
|
26
|
+
* - type/space scales: consumed from the engine's always-present
|
|
27
|
+
* catalog-default scales (semantic.type.*, semantic.space.*) instead of a
|
|
28
|
+
* private exporter fallback table.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { droppedDimensions } from '@transtyle/ir';
|
|
32
|
+
import { componentVariables, componentCssBlocks, buttonVariantBlocks } from './components.js';
|
|
33
|
+
|
|
34
|
+
const S = 'semantic.color.';
|
|
35
|
+
|
|
36
|
+
/** Bootstrap's theme-color order; light/dark are exporter pseudo-roles (F12). */
|
|
37
|
+
const ROLES = ['primary', 'secondary', 'success', 'info', 'warning', 'danger'];
|
|
38
|
+
const SHADOWS = [
|
|
39
|
+
{ name: 'sm', geometry: '0 1px 2px', light: 0.06, dark: 0.3 },
|
|
40
|
+
{ name: '', geometry: '0 4px 12px', light: 0.1, dark: 0.4 },
|
|
41
|
+
{ name: 'lg', geometry: '0 12px 32px', light: 0.16, dark: 0.5 },
|
|
42
|
+
];
|
|
43
|
+
/** Bootstrap's $spacers keys ← the engine's linear space scale (0.25rem base). */
|
|
44
|
+
const SPACE_MAP = [
|
|
45
|
+
['0', '0'],
|
|
46
|
+
['1', '1'],
|
|
47
|
+
['2', '2'],
|
|
48
|
+
['3', '4'],
|
|
49
|
+
['4', '6'],
|
|
50
|
+
['5', '12'],
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
export default {
|
|
54
|
+
name: 'bootstrap',
|
|
55
|
+
|
|
56
|
+
emit(normalized, ctx) {
|
|
57
|
+
// Mode polarity: bind mode NAMES (ir.md#modes) — :root/[data-bs-theme=light]
|
|
58
|
+
// is always the light map, even for dark-native design systems.
|
|
59
|
+
const light = normalized.modes.light ?? normalized.modes[normalized.defaultMode];
|
|
60
|
+
const dark = normalized.modes.dark;
|
|
61
|
+
const r = resolve(light, dark, ctx);
|
|
62
|
+
// Mode dimensions this exporter doesn't express (T8, ir.md#modes) — a
|
|
63
|
+
// no-op unless the compile actually declares one, e.g. `density`.
|
|
64
|
+
r.coverage.push(...droppedDimensions(normalized.dimensionNames, ['color-scheme']));
|
|
65
|
+
|
|
66
|
+
// AL5: a semantic slot an exporter reads can legitimately be unauthored and
|
|
67
|
+
// underivable — `semantic.color.border` is aliased in every example, but a
|
|
68
|
+
// minimal design system has none, and `$border-color: undefined;` is not a
|
|
69
|
+
// stylesheet. Dropping the declaration is right: Bootstrap's own default
|
|
70
|
+
// then applies, which is exactly what "we have nothing to say about this"
|
|
71
|
+
// should mean. Each dropped line becomes a coverage row, so it is visible
|
|
72
|
+
// rather than merely absent. Guarded repo-wide by check:minimal-ds.
|
|
73
|
+
const dropUndefined = (contents, file) =>
|
|
74
|
+
contents
|
|
75
|
+
.split('\n')
|
|
76
|
+
.filter((l) => {
|
|
77
|
+
if (!/:\s*undefined\s*[;,]/.test(l)) return true;
|
|
78
|
+
const name = /^\s*(--?[\w-]+|\$[\w-]+)/.exec(l)?.[1] ?? l.trim();
|
|
79
|
+
const note = `not emitted in ${file}: this design system provides no value for it, so Bootstrap's own default stands`;
|
|
80
|
+
// Reconcile with the row the resolution pass already wrote (AL5 sweep):
|
|
81
|
+
// pushing a second row left `$border-color` reported as BOTH `native`
|
|
82
|
+
// and `dropped` in the same report. The declaration is the ground
|
|
83
|
+
// truth — if it isn't in the file, no earlier claim about it survives.
|
|
84
|
+
const existing = r.coverage.filter((c) => c.variable === name);
|
|
85
|
+
if (existing.length) {
|
|
86
|
+
for (const c of existing) {
|
|
87
|
+
c.class = 'dropped';
|
|
88
|
+
c.slot = '—';
|
|
89
|
+
c.note = note;
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
r.coverage.push({ variable: name, slot: '—', class: 'dropped', note });
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
})
|
|
96
|
+
.join('\n');
|
|
97
|
+
|
|
98
|
+
const files = [
|
|
99
|
+
{ path: '_variables.transtyle.scss', contents: dropUndefined(renderVariables(r, ctx), '_variables.transtyle.scss'), kind: 'stylesheet' },
|
|
100
|
+
{ path: '_maps.transtyle.scss', contents: dropUndefined(renderMaps(r, ctx), '_maps.transtyle.scss'), kind: 'stylesheet' },
|
|
101
|
+
{ path: 'bootstrap-theme.css', contents: dropUndefined(renderCss(r, ctx), 'bootstrap-theme.css'), kind: 'stylesheet' },
|
|
102
|
+
{ path: 'usage.md', contents: renderUsage(ctx, r.coverage), kind: 'doc' },
|
|
103
|
+
];
|
|
104
|
+
return { files, coverage: r.coverage };
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// ---------- resolution ----------
|
|
109
|
+
|
|
110
|
+
function resolve(light, dark, ctx) {
|
|
111
|
+
const coverage = [];
|
|
112
|
+
const hx = (c) => (c ? ctx.formatHex(c).text : undefined);
|
|
113
|
+
const val = (map, p) => map?.get(S + p)?.value;
|
|
114
|
+
const raw = (map, p) => map?.get(`semantic.${p}`)?.value;
|
|
115
|
+
const provKind = (map, p) => map?.get(S + p)?.provenance.kind;
|
|
116
|
+
const cls = (p, mappedCls = 'native') =>
|
|
117
|
+
mappedCls !== 'native' ? mappedCls : provKind(light, p) === 'derived' ? 'derived' : 'native';
|
|
118
|
+
const cov = (variable, slot, klass, note) =>
|
|
119
|
+
coverage.push({ variable, slot, class: klass, ...(note && { note }) });
|
|
120
|
+
|
|
121
|
+
const perMode = (map) => {
|
|
122
|
+
if (!map) return null;
|
|
123
|
+
const surface = val(map, 'elevation.1.surface');
|
|
124
|
+
const role = (name) => {
|
|
125
|
+
if (name === 'light') {
|
|
126
|
+
return {
|
|
127
|
+
base: val(map, 'neutral.tint'),
|
|
128
|
+
text: val(map, 'neutral.on-tint'),
|
|
129
|
+
bgSubtle: ctx.mix(val(map, 'neutral.tint'), surface, 0.6),
|
|
130
|
+
borderSubtle: val(map, 'neutral.outline'),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (name === 'dark') {
|
|
134
|
+
return {
|
|
135
|
+
base: val(map, 'neutral.text-strong'),
|
|
136
|
+
text: val(map, 'neutral.text-strong'),
|
|
137
|
+
bgSubtle: ctx.mix(val(map, 'neutral.text-strong'), surface, 0.85),
|
|
138
|
+
borderSubtle: ctx.mix(val(map, 'neutral.text-strong'), surface, 0.55),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
base: val(map, `${name}.solid`),
|
|
143
|
+
text: val(map, `${name}.on-tint`),
|
|
144
|
+
bgSubtle: val(map, `${name}.tint`),
|
|
145
|
+
borderSubtle: val(map, `${name}.outline`),
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
const ring = val(map, 'ring');
|
|
149
|
+
return {
|
|
150
|
+
roles: Object.fromEntries([...ROLES, 'light', 'dark'].map((n) => [n, role(n)])),
|
|
151
|
+
bodyBg: val(map, 'elevation.0.surface'),
|
|
152
|
+
bodyColor: val(map, 'text.base'),
|
|
153
|
+
emphasis: val(map, 'neutral.text-strong'),
|
|
154
|
+
secondaryColor: val(map, 'text.muted'),
|
|
155
|
+
secondaryBg: val(map, 'neutral.tint'),
|
|
156
|
+
tertiaryBg: val(map, 'elevation.1.surface'),
|
|
157
|
+
border: val(map, 'border'),
|
|
158
|
+
primary: val(map, 'primary.solid'),
|
|
159
|
+
primaryHover: val(map, 'primary.solid-hover'),
|
|
160
|
+
ring,
|
|
161
|
+
ringHover: ring && { ...ring, l: Math.min(1, ring.l + 0.05) },
|
|
162
|
+
scrim: val(map, 'scrim'),
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// theme-color coverage (once, from the light map)
|
|
167
|
+
for (const name of ROLES) {
|
|
168
|
+
cov(`$${name}`, `${S}${name}.solid`, cls(`${name}.solid`));
|
|
169
|
+
cov(`$theme-colors-text.${name}`, `${S}${name}.on-tint`, cls(`${name}.on-tint`));
|
|
170
|
+
cov(`$theme-colors-bg-subtle.${name}`, `${S}${name}.tint`, cls(`${name}.tint`));
|
|
171
|
+
cov(
|
|
172
|
+
`$theme-colors-border-subtle.${name}`,
|
|
173
|
+
`${S}${name}.outline`,
|
|
174
|
+
cls(`${name}.outline`),
|
|
175
|
+
'promoted from a private mix to a first-class grid cell (F10)',
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
cov(
|
|
179
|
+
'$light',
|
|
180
|
+
`${S}neutral.tint`,
|
|
181
|
+
cls('neutral.tint'),
|
|
182
|
+
'exporter convention: $light/$dark map from the neutral role (F12)',
|
|
183
|
+
);
|
|
184
|
+
cov('$dark', `${S}neutral.text-strong`, cls('neutral.text-strong'), 'exporter convention (F12)');
|
|
185
|
+
cov('$body-bg', `${S}elevation.0.surface`, cls('elevation.0.surface'));
|
|
186
|
+
cov('$body-color', `${S}text.base`, cls('text.base'));
|
|
187
|
+
cov('$body-emphasis-color', `${S}neutral.text-strong`, cls('neutral.text-strong'));
|
|
188
|
+
cov('$body-secondary-color', `${S}text.muted`, cls('text.muted'));
|
|
189
|
+
cov('$body-secondary-bg', `${S}neutral.tint`, cls('neutral.tint'));
|
|
190
|
+
cov('$body-tertiary-bg', `${S}elevation.1.surface`, cls('elevation.1.surface'));
|
|
191
|
+
cov('$border-color', `${S}border`, cls('border'));
|
|
192
|
+
cov(
|
|
193
|
+
'$link-color',
|
|
194
|
+
`${S}primary.solid`,
|
|
195
|
+
cls('primary.solid'),
|
|
196
|
+
'exporter convention: link ← primary.solid (matches Bootstrap default)',
|
|
197
|
+
);
|
|
198
|
+
cov(
|
|
199
|
+
'$link-hover-color',
|
|
200
|
+
`${S}primary.solid-hover`,
|
|
201
|
+
cls('primary.solid-hover'),
|
|
202
|
+
'replaces Bootstrap sRGB shade-color(20%)',
|
|
203
|
+
);
|
|
204
|
+
cov(
|
|
205
|
+
'$focus-ring-color',
|
|
206
|
+
`${S}ring`,
|
|
207
|
+
cls('ring'),
|
|
208
|
+
'ring ← primary (F3) at Bootstrap conventional alpha .25',
|
|
209
|
+
);
|
|
210
|
+
for (const s of SHADOWS)
|
|
211
|
+
cov(
|
|
212
|
+
`$box-shadow${s.name && '-' + s.name}`,
|
|
213
|
+
`${S}scrim`,
|
|
214
|
+
'derived',
|
|
215
|
+
'composed from scrim alpha ramp (F2)',
|
|
216
|
+
);
|
|
217
|
+
cov(
|
|
218
|
+
'$box-shadow-inset',
|
|
219
|
+
'—',
|
|
220
|
+
'unsupported',
|
|
221
|
+
'no IR inset-shadow concept; Bootstrap default kept',
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const radius = (k) => light.get(`semantic.radius.${k}`);
|
|
225
|
+
for (const k of ['md', 'sm', 'lg', 'xl']) {
|
|
226
|
+
const e = radius(k);
|
|
227
|
+
if (e)
|
|
228
|
+
cov(
|
|
229
|
+
`$border-radius${k === 'md' ? '' : '-' + k}`,
|
|
230
|
+
`semantic.radius.${k}`,
|
|
231
|
+
e.provenance.kind === 'derived' ? 'derived' : 'native',
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
cov(
|
|
235
|
+
'$border-radius-xxl',
|
|
236
|
+
'semantic.radius.xl',
|
|
237
|
+
'approximated',
|
|
238
|
+
'exporter convention: xl × 2 — no IR 2xl slot (F8 watch item)',
|
|
239
|
+
);
|
|
240
|
+
cov(
|
|
241
|
+
'$border-radius-pill',
|
|
242
|
+
'semantic.radius.full',
|
|
243
|
+
// AL5 sweep: was `derived`, which claimed the emitted value came from the
|
|
244
|
+
// slot. It does not — `50rem` is a hard-coded Bootstrap idiom emitted
|
|
245
|
+
// unconditionally, and the exporter never reads `semantic.radius.full` at
|
|
246
|
+
// all. The *meaning* maps exactly (both say "fully rounded"), the value is
|
|
247
|
+
// the target's own constant: that is what `approximated` is for. The old
|
|
248
|
+
// class also named a slot that need not exist — a design system with no
|
|
249
|
+
// radius scale has no `radius.full`, yet the pill still emits correctly.
|
|
250
|
+
'approximated',
|
|
251
|
+
"meaning maps exactly (fully rounded); the value is Bootstrap's own 50rem idiom, emitted unconditionally rather than read from the slot",
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const font = (k) => light.get(`semantic.font.${k}`);
|
|
255
|
+
if (font('sans')) cov('$font-family-sans-serif', 'semantic.font.sans', 'native');
|
|
256
|
+
if (font('mono')) cov('$font-family-monospace', 'semantic.font.mono', 'native');
|
|
257
|
+
|
|
258
|
+
const typeProv = light.get('semantic.type.size.md')?.provenance.kind;
|
|
259
|
+
cov(
|
|
260
|
+
'$font-size-base…$h1-font-size',
|
|
261
|
+
'semantic.type.*',
|
|
262
|
+
typeProv === 'authored' || typeProv === 'aliased' ? 'native' : 'derived',
|
|
263
|
+
typeProv === 'defaulted'
|
|
264
|
+
? 'defaulted modular scale (base 1rem, ratio 1.25) — no authored type tokens'
|
|
265
|
+
: undefined,
|
|
266
|
+
);
|
|
267
|
+
cov(
|
|
268
|
+
'$display-font-sizes',
|
|
269
|
+
'—',
|
|
270
|
+
'unsupported',
|
|
271
|
+
"the IR does have this concept — `semantic.type.role.display.{sm,md,lg}` — but the two ladders disagree: Bootstrap runs 6 rungs from 2.5rem to 5rem, the type-role scale 3 from 1.953rem to 3.052rem, so mapping either onto the other invents rungs or drops them. Same shape as AL2's size-ladder deferral; Bootstrap defaults kept",
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const spaceProv = light.get('semantic.space.4')?.provenance.kind;
|
|
275
|
+
cov(
|
|
276
|
+
'$spacer/$spacers',
|
|
277
|
+
'semantic.space.*',
|
|
278
|
+
spaceProv === 'authored' || spaceProv === 'aliased' ? 'native' : 'derived',
|
|
279
|
+
spaceProv === 'defaulted' ? 'defaulted linear scale (base 0.25rem)' : undefined,
|
|
280
|
+
);
|
|
281
|
+
cov(
|
|
282
|
+
'$grid-breakpoints/$container-max-widths',
|
|
283
|
+
'—',
|
|
284
|
+
'unsupported',
|
|
285
|
+
"`semantic.breakpoint.*` ships (6 rungs), but only `md` (768px) agrees: Bootstrap's sm/lg/xl/xxl are 576/992/1200/1400 against the catalog's 640/1024/1280/1536, and its `xs` is `0` — the unqueried mobile-first base, not a boundary at all, where the catalog's is 480px. Rebinding would move every responsive boundary in the framework: a behavioral change, not a theming one. `$container-max-widths` has no IR counterpart at all. Bootstrap defaults kept",
|
|
286
|
+
);
|
|
287
|
+
cov(
|
|
288
|
+
'motion ($transition-*)',
|
|
289
|
+
'—',
|
|
290
|
+
'dropped',
|
|
291
|
+
'the global $transition-base/-fade stay Bootstrap defaults; per-component transitions are driven — see the component-tier rows',
|
|
292
|
+
);
|
|
293
|
+
cov(
|
|
294
|
+
'$component-active-color',
|
|
295
|
+
`${S}primary.on-solid`,
|
|
296
|
+
cls('primary.on-solid'),
|
|
297
|
+
'review pickup (AL1.2 findings): Bootstrap hardcodes $white; $component-active-bg already chains from $primary',
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// Component tier (AL1.3): the full per-variable walk replaces the old
|
|
301
|
+
// blanket "reserved for v2" line. Sass lines + 657 coverage rows.
|
|
302
|
+
const component = componentVariables(light, ctx);
|
|
303
|
+
coverage.push(...component.coverage);
|
|
304
|
+
// CSS path (AL1.4): structural component vars + button variant state colors.
|
|
305
|
+
coverage.push(...componentCssBlocks(light, ctx).coverage);
|
|
306
|
+
cov(
|
|
307
|
+
'--bs-btn-* variant state colors (CSS path)',
|
|
308
|
+
`${S}<role>.{solid,solid-hover,solid-active,on-solid}`,
|
|
309
|
+
'derived',
|
|
310
|
+
"grid state cells replace Bootstrap's shade/tint derivation for stock-CSS users; .btn-light/.btn-dark keep defaults (pseudo-roles have no grid)",
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const typeScale = {
|
|
314
|
+
base: raw(light, 'type.size.md'),
|
|
315
|
+
leading: raw(light, 'type.leading.normal'),
|
|
316
|
+
h1: raw(light, 'type.size.4xl'),
|
|
317
|
+
h2: raw(light, 'type.size.3xl'),
|
|
318
|
+
h3: raw(light, 'type.size.2xl'),
|
|
319
|
+
h4: raw(light, 'type.size.xl'),
|
|
320
|
+
h5: raw(light, 'type.size.lg'),
|
|
321
|
+
h6: raw(light, 'type.size.md'),
|
|
322
|
+
};
|
|
323
|
+
const spaceScale = SPACE_MAP.map(([bsKey, catKey]) => {
|
|
324
|
+
const v = raw(light, `space.${catKey}`);
|
|
325
|
+
return [bsKey, parseFloat(v) === 0 ? '0' : v, `space.${catKey}`];
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
light: perMode(light),
|
|
330
|
+
dark: perMode(dark),
|
|
331
|
+
radiusEntries: Object.fromEntries(['md', 'sm', 'lg', 'xl', 'full'].map((k) => [k, radius(k)])),
|
|
332
|
+
fontSans: font('sans'),
|
|
333
|
+
fontMono: font('mono'),
|
|
334
|
+
typeScale,
|
|
335
|
+
spaceScale,
|
|
336
|
+
hx,
|
|
337
|
+
coverage,
|
|
338
|
+
componentLines: component.lines,
|
|
339
|
+
componentActiveColor: hx(val(light, 'primary.on-solid')),
|
|
340
|
+
// AL1.4: grid-cell reader for the CSS path's button variant blocks.
|
|
341
|
+
gridCell: (map) => (roleName, cellName) => hx(val(map, `${roleName}.${cellName}`)),
|
|
342
|
+
lightMap: light,
|
|
343
|
+
darkMap: dark,
|
|
344
|
+
ctx,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---------- shared formatting ----------
|
|
349
|
+
|
|
350
|
+
const fontList = (value) => value.map((f) => (/[^a-z-]/.test(f) ? `"${f}"` : f)).join(', ');
|
|
351
|
+
const rgbTriplet = (hex) => {
|
|
352
|
+
const n = parseInt(hex.slice(1), 16);
|
|
353
|
+
return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`;
|
|
354
|
+
};
|
|
355
|
+
const scssHeader = (ctx) =>
|
|
356
|
+
[
|
|
357
|
+
'// GENERATED by transtyle — do not edit; source: ' + ctx.projectName + ' token files',
|
|
358
|
+
'// Target: bootstrap (>=5.3 <6), Sass path · rules standard@1',
|
|
359
|
+
'//',
|
|
360
|
+
'// Import order (Sass path):',
|
|
361
|
+
'// @import "variables.transtyle"; // this file — BEFORE bootstrap',
|
|
362
|
+
'// @import "maps.transtyle"; // AFTER bootstrap variables, BEFORE the rest',
|
|
363
|
+
'// (or with bootstrap.scss directly — see usage.md)',
|
|
364
|
+
'',
|
|
365
|
+
].join('\n');
|
|
366
|
+
|
|
367
|
+
const xxl = (radiusMd) => {
|
|
368
|
+
// exporter convention: xl × 2 (F8 watch item), from the same dimension unit
|
|
369
|
+
const m = /^([\d.]+)([a-z%]+)$/.exec(String(radiusMd));
|
|
370
|
+
return m ? `${parseFloat(m[1]) * 4}${m[2]}` : '2rem';
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// ---------- _variables.transtyle.scss ----------
|
|
374
|
+
|
|
375
|
+
function renderVariables(r, ctx) {
|
|
376
|
+
const { hx } = r;
|
|
377
|
+
const L = r.light,
|
|
378
|
+
D = r.dark;
|
|
379
|
+
const lines = [scssHeader(ctx)];
|
|
380
|
+
lines.push('// ---------------------------------------------------------------- theme colors');
|
|
381
|
+
for (const [name, sassVar] of [
|
|
382
|
+
...ROLES.map((n) => [n, n]),
|
|
383
|
+
['light', 'light'],
|
|
384
|
+
['dark', 'dark'],
|
|
385
|
+
]) {
|
|
386
|
+
lines.push(`$${sassVar}: ${hx(L.roles[name].base)};`);
|
|
387
|
+
}
|
|
388
|
+
lines.push('');
|
|
389
|
+
lines.push('// -------------------------------------------------------------- body / surfaces');
|
|
390
|
+
lines.push(`$body-bg: ${hx(L.bodyBg)}; // elevation.0.surface`);
|
|
391
|
+
lines.push(`$body-color: ${hx(L.bodyColor)}; // text.base`);
|
|
392
|
+
lines.push(`$body-emphasis-color: ${hx(L.emphasis)}; // neutral.text-strong (F20)`);
|
|
393
|
+
lines.push(`$body-secondary-color: ${hx(L.secondaryColor)}; // text.muted`);
|
|
394
|
+
lines.push(`$body-tertiary-bg: ${hx(L.tertiaryBg)}; // elevation.1.surface (F11)`);
|
|
395
|
+
lines.push(`$body-secondary-bg: ${hx(L.secondaryBg)}; // neutral.tint (F11)`);
|
|
396
|
+
if (D) {
|
|
397
|
+
lines.push('');
|
|
398
|
+
lines.push('// dark mode (data-bs-theme="dark") — Bootstrap 5.3 *-dark variables');
|
|
399
|
+
lines.push(`$body-bg-dark: ${hx(D.bodyBg)};`);
|
|
400
|
+
lines.push(`$body-color-dark: ${hx(D.bodyColor)};`);
|
|
401
|
+
lines.push(`$body-emphasis-color-dark: ${hx(D.emphasis)};`);
|
|
402
|
+
lines.push(`$body-secondary-color-dark: ${hx(D.secondaryColor)};`);
|
|
403
|
+
lines.push(`$body-tertiary-bg-dark: ${hx(D.tertiaryBg)};`);
|
|
404
|
+
lines.push(`$body-secondary-bg-dark: ${hx(D.secondaryBg)};`);
|
|
405
|
+
lines.push(`$border-color-dark: ${hx(D.border)};`);
|
|
406
|
+
}
|
|
407
|
+
lines.push('');
|
|
408
|
+
lines.push('// -------------------------------------------------------------- links / focus');
|
|
409
|
+
lines.push('$link-color: $primary; // exporter convention: link ← primary.solid');
|
|
410
|
+
lines.push(
|
|
411
|
+
`$link-hover-color: ${hx(L.primaryHover)}; // primary.solid-hover — replaces Bootstrap's sRGB shade-color(20%)`,
|
|
412
|
+
);
|
|
413
|
+
lines.push(
|
|
414
|
+
'// $link-color-dark intentionally NOT emitted: Bootstrap derives it from $primary (F13)',
|
|
415
|
+
);
|
|
416
|
+
lines.push('$focus-ring-color: rgba($primary, .25); // ring ← primary (F3)');
|
|
417
|
+
lines.push(
|
|
418
|
+
`$component-active-color: ${r.componentActiveColor}; // primary.on-solid — Bootstrap hardcodes $white (AL1.2 review pickup); -bg already chains from $primary`,
|
|
419
|
+
);
|
|
420
|
+
lines.push('');
|
|
421
|
+
lines.push('// ---------------------------------------------------------------- typography');
|
|
422
|
+
if (r.fontSans)
|
|
423
|
+
lines.push(`$font-family-sans-serif: ${fontList(r.fontSans.value)}; // font.sans`);
|
|
424
|
+
if (r.fontMono)
|
|
425
|
+
lines.push(`$font-family-monospace: ${fontList(r.fontMono.value)}; // font.mono`);
|
|
426
|
+
const ts = r.typeScale;
|
|
427
|
+
lines.push(`$font-size-base: ${ts.base}; // type.size.md`);
|
|
428
|
+
lines.push(`$line-height-base: ${ts.leading}; // type.leading.normal`);
|
|
429
|
+
lines.push(`$h1-font-size: ${ts.h1}; // type.size.4xl`);
|
|
430
|
+
lines.push(`$h2-font-size: ${ts.h2}; // type.size.3xl`);
|
|
431
|
+
lines.push(`$h3-font-size: ${ts.h3}; // type.size.2xl`);
|
|
432
|
+
lines.push(`$h4-font-size: ${ts.h4}; // type.size.xl`);
|
|
433
|
+
lines.push(`$h5-font-size: ${ts.h5}; // type.size.lg`);
|
|
434
|
+
lines.push(`$h6-font-size: ${ts.h6}; // type.size.md`);
|
|
435
|
+
lines.push('');
|
|
436
|
+
lines.push('// ------------------------------------------------------------------- spacing');
|
|
437
|
+
lines.push(`$spacer: ${r.spaceScale[3][1]}; // space.4`);
|
|
438
|
+
lines.push('$spacers: (');
|
|
439
|
+
lines.push(
|
|
440
|
+
r.spaceScale
|
|
441
|
+
.map(([k, v, slot]) => ` ${k}: ${v}${k === '5' ? '' : ','} // ${slot}`)
|
|
442
|
+
.join('\n'),
|
|
443
|
+
);
|
|
444
|
+
lines.push(');');
|
|
445
|
+
lines.push('');
|
|
446
|
+
lines.push('// ------------------------------------------------------------- radius / borders');
|
|
447
|
+
const rad = r.radiusEntries;
|
|
448
|
+
if (rad.md) lines.push(`$border-radius: ${rad.md.value}; // radius.md`);
|
|
449
|
+
if (rad.sm)
|
|
450
|
+
lines.push(`$border-radius-sm: ${rad.sm.value}; // radius.sm · derived (F8: md × 0.5)`);
|
|
451
|
+
if (rad.lg)
|
|
452
|
+
lines.push(`$border-radius-lg: ${rad.lg.value}; // radius.lg · derived (F8: md × 1.5)`);
|
|
453
|
+
if (rad.xl)
|
|
454
|
+
lines.push(`$border-radius-xl: ${rad.xl.value}; // radius.xl · derived (F8: md × 2)`);
|
|
455
|
+
if (rad.md)
|
|
456
|
+
lines.push(
|
|
457
|
+
`$border-radius-xxl: ${xxl(rad.md.value)}; // exporter convention: xl × 2 (F8 watch item)`,
|
|
458
|
+
);
|
|
459
|
+
lines.push("$border-radius-pill: 50rem; // radius.full, in Bootstrap's own idiom");
|
|
460
|
+
lines.push(`$border-color: ${hx(L.border)}; // border`);
|
|
461
|
+
lines.push('');
|
|
462
|
+
lines.push('// ------------------------------------------------------------------- shadows');
|
|
463
|
+
lines.push('// Composed from scrim at fixed alpha ramps (F2)');
|
|
464
|
+
for (const s of SHADOWS) {
|
|
465
|
+
lines.push(
|
|
466
|
+
`$box-shadow${s.name ? '-' + s.name : ''}: ${s.geometry} rgba(${hx(L.scrim)}, .${String(s.light * 100).padStart(2, '0')});`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
lines.push(
|
|
470
|
+
'// shadow.xl: dropped — Bootstrap has no -xl slot; $box-shadow-inset kept at Bootstrap default',
|
|
471
|
+
);
|
|
472
|
+
lines.push('');
|
|
473
|
+
lines.push(...r.componentLines);
|
|
474
|
+
lines.push('');
|
|
475
|
+
return lines.join('\n');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ---------- _maps.transtyle.scss ----------
|
|
479
|
+
|
|
480
|
+
function renderMaps(r, ctx) {
|
|
481
|
+
const { hx } = r;
|
|
482
|
+
const mapBlock = (name, pick, mode) => {
|
|
483
|
+
const entries = [...ROLES, 'light', 'dark'].map((role) => {
|
|
484
|
+
const v = hx(pick(r[mode].roles[role]));
|
|
485
|
+
return ` "${role}": ${v}`;
|
|
486
|
+
});
|
|
487
|
+
return `$${name}: (\n${entries.join(',\n')}\n);`;
|
|
488
|
+
};
|
|
489
|
+
const lines = [
|
|
490
|
+
'// GENERATED by transtyle — do not edit; source: ' + ctx.projectName + ' token files',
|
|
491
|
+
'// Target: bootstrap (>=5.3 <6), Sass path · rules standard@1',
|
|
492
|
+
'//',
|
|
493
|
+
"// These maps REPLACE Bootstrap's own tint-color()/shade-color() derivations",
|
|
494
|
+
'// with OKLCH-derived values (perceptually consistent across roles) — the',
|
|
495
|
+
'// mechanism that makes role.on-tint → -text-emphasis NATIVE (F9).',
|
|
496
|
+
'',
|
|
497
|
+
'// <role>.on-tint (on-brand walk, F1/F19)',
|
|
498
|
+
mapBlock('theme-colors-text', (x) => x.text, 'light'),
|
|
499
|
+
'',
|
|
500
|
+
'// <role>.tint (mix toward surface — cartesian OKLab, F21)',
|
|
501
|
+
mapBlock('theme-colors-bg-subtle', (x) => x.bgSubtle, 'light'),
|
|
502
|
+
'',
|
|
503
|
+
'// <role>.outline (F10, now a first-class grid cell)',
|
|
504
|
+
mapBlock('theme-colors-border-subtle', (x) => x.borderSubtle, 'light'),
|
|
505
|
+
];
|
|
506
|
+
if (r.dark) {
|
|
507
|
+
lines.push(
|
|
508
|
+
'',
|
|
509
|
+
'// ------------------------------------------------- dark mode (data-bs-theme="dark")',
|
|
510
|
+
'',
|
|
511
|
+
);
|
|
512
|
+
lines.push(mapBlock('theme-colors-text-dark', (x) => x.text, 'dark'));
|
|
513
|
+
lines.push('');
|
|
514
|
+
lines.push(mapBlock('theme-colors-bg-subtle-dark', (x) => x.bgSubtle, 'dark'));
|
|
515
|
+
lines.push('');
|
|
516
|
+
lines.push(mapBlock('theme-colors-border-subtle-dark', (x) => x.borderSubtle, 'dark'));
|
|
517
|
+
}
|
|
518
|
+
lines.push('');
|
|
519
|
+
return lines.join('\n');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ---------- bootstrap-theme.css (CSS-variable path) ----------
|
|
523
|
+
|
|
524
|
+
function renderCss(r, ctx) {
|
|
525
|
+
const { hx } = r;
|
|
526
|
+
const modeBlock = (M, isDark) => {
|
|
527
|
+
const lines = [];
|
|
528
|
+
if (!isDark) {
|
|
529
|
+
for (const name of [...ROLES, 'light', 'dark']) {
|
|
530
|
+
const h = hx(M.roles[name].base);
|
|
531
|
+
lines.push(` --bs-${name}: ${h}; --bs-${name}-rgb: ${rgbTriplet(h)};`);
|
|
532
|
+
}
|
|
533
|
+
lines.push('');
|
|
534
|
+
}
|
|
535
|
+
for (const name of [...ROLES, 'light', 'dark']) {
|
|
536
|
+
const x = M.roles[name];
|
|
537
|
+
lines.push(
|
|
538
|
+
` --bs-${name}-text-emphasis: ${hx(x.text)}; --bs-${name}-bg-subtle: ${hx(x.bgSubtle)}; --bs-${name}-border-subtle: ${hx(x.borderSubtle)};`,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
lines.push('');
|
|
542
|
+
const bg = hx(M.bodyBg),
|
|
543
|
+
color = hx(M.bodyColor),
|
|
544
|
+
emph = hx(M.emphasis);
|
|
545
|
+
lines.push(` --bs-body-bg: ${bg}; --bs-body-bg-rgb: ${rgbTriplet(bg)};`);
|
|
546
|
+
lines.push(` --bs-body-color: ${color}; --bs-body-color-rgb: ${rgbTriplet(color)};`);
|
|
547
|
+
lines.push(` --bs-emphasis-color: ${emph}; --bs-emphasis-color-rgb: ${rgbTriplet(emph)};`);
|
|
548
|
+
lines.push(` --bs-secondary-color: ${hx(M.secondaryColor)}; /* text.muted */`);
|
|
549
|
+
lines.push(` --bs-secondary-bg: ${hx(M.secondaryBg)}; /* neutral.tint */`);
|
|
550
|
+
lines.push(` --bs-tertiary-bg: ${hx(M.tertiaryBg)}; /* elevation.1.surface */`);
|
|
551
|
+
lines.push(` --bs-border-color: ${hx(M.border)}; /* border */`);
|
|
552
|
+
lines.push('');
|
|
553
|
+
// Link asymmetry (F13): light links ← primary; dark links ← ring[dark]
|
|
554
|
+
// because CDN users have Bootstrap's stock literals baked in.
|
|
555
|
+
const link = isDark ? hx(M.ring) : hx(M.primary);
|
|
556
|
+
const linkHover = isDark ? hx(M.ringHover) : hx(M.primaryHover);
|
|
557
|
+
lines.push(` --bs-link-color: ${link}; --bs-link-color-rgb: ${rgbTriplet(link)};`);
|
|
558
|
+
lines.push(
|
|
559
|
+
` --bs-link-hover-color: ${linkHover}; --bs-link-hover-color-rgb: ${rgbTriplet(linkHover)};`,
|
|
560
|
+
);
|
|
561
|
+
lines.push(
|
|
562
|
+
` --bs-focus-ring-color: rgba(${rgbTriplet(isDark ? hx(M.ring) : hx(M.primary))}, 0.25);`,
|
|
563
|
+
);
|
|
564
|
+
lines.push('');
|
|
565
|
+
for (const s of SHADOWS) {
|
|
566
|
+
const alpha = isDark ? s.dark : s.light;
|
|
567
|
+
lines.push(
|
|
568
|
+
` --bs-box-shadow${s.name ? '-' + s.name : ''}: ${s.geometry} rgba(${rgbTriplet(hx(M.scrim))}, ${alpha});`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
return lines;
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const lines = [
|
|
575
|
+
'/*',
|
|
576
|
+
` * GENERATED by transtyle — do not edit; source: ${ctx.projectName} token files`,
|
|
577
|
+
' * Target: bootstrap (>=5.3 <6), CSS-variable path · rules standard@1',
|
|
578
|
+
' *',
|
|
579
|
+
' * LOWER-FIDELITY PATH, NARROWED BY AL1.4: this layer rethemes the token',
|
|
580
|
+
' * tier PLUS the per-component --bs-* variables Bootstrap 5.3 exposes',
|
|
581
|
+
' * (selector-scoped blocks below), including button variant state colors',
|
|
582
|
+
" * from the role grid — which replaces the part of F13's gap that 5.3's",
|
|
583
|
+
' * component CSS vars made reachable. Still out of reach: values with no',
|
|
584
|
+
' * runtime variable (Sass-only expressions, responsive re-sets, and the',
|
|
585
|
+
' * state colors of components without per-variant CSS vars).',
|
|
586
|
+
' * Use the Sass path for full fidelity. Load AFTER bootstrap.css.',
|
|
587
|
+
' */',
|
|
588
|
+
'',
|
|
589
|
+
':root,',
|
|
590
|
+
'[data-bs-theme="light"] {',
|
|
591
|
+
...modeBlock(r.light, false),
|
|
592
|
+
'',
|
|
593
|
+
];
|
|
594
|
+
const rad = r.radiusEntries;
|
|
595
|
+
lines.push(' /* mode-invariant */');
|
|
596
|
+
if (rad.md) lines.push(` --bs-border-radius: ${rad.md.value};`);
|
|
597
|
+
if (rad.sm) lines.push(` --bs-border-radius-sm: ${rad.sm.value};`);
|
|
598
|
+
if (rad.lg) lines.push(` --bs-border-radius-lg: ${rad.lg.value};`);
|
|
599
|
+
if (rad.xl) lines.push(` --bs-border-radius-xl: ${rad.xl.value};`);
|
|
600
|
+
if (rad.md) lines.push(` --bs-border-radius-xxl: ${xxl(rad.md.value)};`);
|
|
601
|
+
lines.push(' --bs-border-radius-pill: 50rem;');
|
|
602
|
+
if (r.fontSans) lines.push(` --bs-font-sans-serif: ${fontList(r.fontSans.value)};`);
|
|
603
|
+
if (r.fontMono) lines.push(` --bs-font-monospace: ${fontList(r.fontMono.value)};`);
|
|
604
|
+
lines.push('}');
|
|
605
|
+
if (r.dark) {
|
|
606
|
+
lines.push('', '[data-bs-theme="dark"] {', ...modeBlock(r.dark, true), '}');
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ---- component tier, CSS path (AL1.4) ----
|
|
610
|
+
lines.push('', '/* component structure (mode-invariant) — component tier, AL1.4 */');
|
|
611
|
+
lines.push(...componentCssBlocks(r.lightMap, r.ctx).lines);
|
|
612
|
+
lines.push("/* button variant state colors from the role grid — replaces Bootstrap's");
|
|
613
|
+
lines.push(' * shade/tint derivation on this path (the AL1.2 "knobs dropped, results');
|
|
614
|
+
lines.push(' * driven" call). .btn-light/.btn-dark keep Bootstrap defaults (pseudo-roles');
|
|
615
|
+
lines.push(' * have no grid state cells). */');
|
|
616
|
+
lines.push(...buttonVariantBlocks(ROLES, r.gridCell(r.lightMap), rgbTriplet));
|
|
617
|
+
if (r.darkMap) {
|
|
618
|
+
lines.push(
|
|
619
|
+
'',
|
|
620
|
+
...buttonVariantBlocks(ROLES, r.gridCell(r.darkMap), rgbTriplet, {
|
|
621
|
+
darkPrefix: '[data-bs-theme="dark"] ',
|
|
622
|
+
}),
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
lines.push('');
|
|
626
|
+
return lines.join('\n');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ---------- usage ----------
|
|
630
|
+
|
|
631
|
+
function renderUsage(ctx, coverage) {
|
|
632
|
+
const counts = {};
|
|
633
|
+
for (const c of coverage) counts[c.class] = (counts[c.class] ?? 0) + 1;
|
|
634
|
+
const summary = Object.entries(counts)
|
|
635
|
+
.map(([k, v]) => `${v} ${k}`)
|
|
636
|
+
.join(' · ');
|
|
637
|
+
return `# Using this Bootstrap theme
|
|
638
|
+
|
|
639
|
+
Generated from the **${ctx.projectName}** design system by transtyle (Bootstrap >=5.3 <6). Coverage: ${summary}.
|
|
640
|
+
|
|
641
|
+
## Sass path (recommended — full fidelity)
|
|
642
|
+
|
|
643
|
+
Your build imports our variables **before** Bootstrap and our maps **after** Bootstrap's variables:
|
|
644
|
+
|
|
645
|
+
\`\`\`scss
|
|
646
|
+
@import "variables.transtyle"; // theme values, BEFORE bootstrap
|
|
647
|
+
@import "bootstrap/scss/functions";
|
|
648
|
+
@import "bootstrap/scss/variables";
|
|
649
|
+
@import "bootstrap/scss/variables-dark";
|
|
650
|
+
@import "maps.transtyle"; // replaces tint/shade derivations
|
|
651
|
+
@import "bootstrap/scss/maps";
|
|
652
|
+
@import "bootstrap/scss/mixins";
|
|
653
|
+
@import "bootstrap/scss/root";
|
|
654
|
+
@import "bootstrap/scss/bootstrap"; // or the parts you use
|
|
655
|
+
\`\`\`
|
|
656
|
+
|
|
657
|
+
Every component (buttons, alerts, badges…) is compiled from your theme values.
|
|
658
|
+
|
|
659
|
+
## CSS-variable path (no Sass build)
|
|
660
|
+
|
|
661
|
+
Load \`bootstrap-theme.css\` **after** \`bootstrap.css\` (CDN or otherwise). This
|
|
662
|
+
rethemes the token tier **plus** the per-component \`--bs-*\` variables Bootstrap
|
|
663
|
+
5.3 exposes: selector-scoped structure (button/badge/toast paddings, radii,
|
|
664
|
+
transitions) and button variant state colors (\`.btn-primary\` backgrounds and
|
|
665
|
+
hovers now come from the role grid — both modes). Still out of reach, honestly:
|
|
666
|
+
values with no runtime variable — Sass-only expressions, responsive re-sets
|
|
667
|
+
(\`--bs-modal-margin\` above \`sm\`), and state colors of components without
|
|
668
|
+
per-variant CSS vars. The remaining gap is documented, not a bug.
|
|
669
|
+
|
|
670
|
+
## Component tokens
|
|
671
|
+
|
|
672
|
+
Authored \`component.*\` tokens (e.g. \`component.button.radius\`) reach both
|
|
673
|
+
paths; unauthored ones resolve from their semantic defaults with provenance in
|
|
674
|
+
\`report.json\` — one row per inventoried Bootstrap variable (657), including
|
|
675
|
+
what is deliberately not driven and why.
|
|
676
|
+
|
|
677
|
+
## Dark mode
|
|
678
|
+
|
|
679
|
+
Both paths follow Bootstrap's own mechanism: \`data-bs-theme="dark"\` on \`<html>\`.
|
|
680
|
+
|
|
681
|
+
## Regenerating
|
|
682
|
+
|
|
683
|
+
Never edit these files — change the design system tokens and run \`transtyle build bootstrap\`.
|
|
684
|
+
See \`report.json\` for the full coverage/provenance breakdown.
|
|
685
|
+
`;
|
|
686
|
+
}
|