@sentropic/design-system-tokens 0.10.0 → 0.10.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.
package/dist/component.js CHANGED
@@ -26,7 +26,10 @@ const FALLBACK = {
26
26
  focus: { strategy: "outline", width: "2px", offset: "2px", color: "var(--st-semantic-border-interactive)", inset: "0" },
27
27
  // Field style fallback = boxed outline (base Sent Tech). underlineColor /
28
28
  // underlineWidth are inert for outline; they only drive filled-underline.
29
- field: { style: "outline", fillBg: "", underlineColor: "", underlineWidth: "1px" }
29
+ // radiusTop "" = inherit the theme's shape radius (resolved in fieldOf).
30
+ // v1.4.0: selectAppearance "auto" (native arrow, base unchanged), no chevron,
31
+ // and the prior 2rem right arrow gap.
32
+ field: { style: "outline", fillBg: "", underlineColor: "", underlineWidth: "1px", radiusTop: "", selectAppearance: "auto", selectChevron: "none", selectPaddingRight: "2rem" }
30
33
  };
31
34
  function densityOf(f, size) {
32
35
  const base = FALLBACK.density[size];
@@ -40,6 +43,305 @@ function densityOf(f, size) {
40
43
  fontSize: themed.fontSize ?? base.fontSize
41
44
  };
42
45
  }
46
+ /**
47
+ * Button density (F9): the shared control density for `size`, overlaid with any
48
+ * BUTTON-specific override (`buttonDensity`). Adds an optional `paddingInlineEnd`
49
+ * leaf so a button can be asymmetric (Carbon's large trailing gutter) without
50
+ * touching the shared density the 100%-fidelity fields read. When the theme omits
51
+ * `buttonDensity` (base/DSFR), this is identical to `densityOf` → no change, and
52
+ * `paddingInlineEnd` mirrors `paddingInline` (symmetric).
53
+ */
54
+ function buttonDensityOf(f, size) {
55
+ const base = densityOf(f, size);
56
+ const override = f.buttonDensity?.[size] ?? {};
57
+ const paddingInline = override.paddingInline ?? base.paddingInline;
58
+ return {
59
+ controlHeight: override.controlHeight ?? base.controlHeight,
60
+ paddingBlock: override.paddingBlock ?? base.paddingBlock,
61
+ paddingInline,
62
+ gap: override.gap ?? base.gap,
63
+ minWidth: override.minWidth ?? base.minWidth,
64
+ fontSize: override.fontSize ?? base.fontSize,
65
+ // Trailing inline padding: an explicit asymmetric value (Carbon) or = paddingInline.
66
+ paddingInlineEnd: override.paddingInlineEnd ?? paddingInline
67
+ };
68
+ }
69
+ /**
70
+ * Tabs ACTIVE-tab resolution (F7/F8). Resolves the per-theme selected-tab
71
+ * primitive into a flat, CSS-ready set the Tabs component consumes verbatim.
72
+ * Every leaf DEFAULTS to the prior base render (12px/4px padding, inherited
73
+ * font-size, control weight/line-height, transparent active bg, primary text,
74
+ * BOTTOM indicator) so the base Sent Tech tab is byte-identical; DSFR / Carbon
75
+ * override the real selected-tab metrics. `indicatorSide` resolves into two
76
+ * border-width channels so a theme can put its accent on the top edge (DSFR,
77
+ * which then has NO bottom border) or the bottom edge (base / Carbon).
78
+ */
79
+ function tabsOf(f, controlTypography, indicatorWidth, indicatorColor, activeTextDefault) {
80
+ const t = f.tabs ?? {};
81
+ const indicatorSide = t.indicatorSide ?? "bottom";
82
+ const indicatorMode = t.indicatorMode ?? "border";
83
+ // The indicator lives on ONE edge; the opposite edge collapses to 0 so the
84
+ // active tab matches the reference (DSFR active = border-bottom 0 / top accent).
85
+ const onTop = indicatorSide === "top";
86
+ // "shadow" mode draws the accent as an inset box-shadow so BOTH border sides
87
+ // stay 0 (DSFR, whose real accent is a background-image filet, not a border).
88
+ // "border" mode keeps the real per-side border (base / Carbon) and no shadow.
89
+ const isShadow = indicatorMode === "shadow";
90
+ const shadowOffset = onTop ? indicatorWidth : `-${indicatorWidth}`;
91
+ return {
92
+ activeText: t.activeText || activeTextDefault,
93
+ activeBackground: t.activeBackground || "transparent",
94
+ // G2: resting-tab fill. Default "transparent" (base/Carbon unchanged); DSFR
95
+ // sets a light grey-blue fill so the white active tab reads as raised.
96
+ inactiveBackground: t.inactiveBackground || "transparent",
97
+ activeWeight: t.activeWeight ?? controlTypography.weight,
98
+ paddingBlock: t.paddingBlock ?? "0.75rem",
99
+ paddingInline: t.paddingInline ?? "0.25rem",
100
+ fontSize: t.fontSize ?? "inherit",
101
+ lineHeight: t.lineHeight ?? controlTypography.lineHeight,
102
+ indicatorSide,
103
+ activeBorderTopWidth: isShadow ? "0" : onTop ? indicatorWidth : "0",
104
+ activeBorderBottomWidth: isShadow ? "0" : onTop ? "0" : indicatorWidth,
105
+ activeShadow: isShadow ? `inset 0 ${shadowOffset} 0 0 ${indicatorColor}` : "none"
106
+ };
107
+ }
108
+ /**
109
+ * Button SECONDARY-variant resolution (G1). Resolves the per-theme secondary
110
+ * button surface into a flat, CSS-ready set the Button component consumes
111
+ * verbatim (background / border colour / hover background). Every leaf DEFAULTS
112
+ * to the prior base render (filled `action.secondary`, `border.subtle` stroke,
113
+ * `action.secondaryHover` hover) so the base Sent Tech secondary button is
114
+ * byte-identical; DSFR overrides them to render its OUTLINED secondary button
115
+ * (transparent fill + Bleu France border + light fill on hover).
116
+ */
117
+ function buttonSecondaryOf(semantic, f) {
118
+ const b = f.buttonSecondary ?? {};
119
+ return {
120
+ background: b.background || semantic.action.secondary,
121
+ border: b.border || semantic.border.subtle,
122
+ hoverBackground: b.hoverBackground || semantic.action.secondaryHover || semantic.action.secondary
123
+ };
124
+ }
125
+ /**
126
+ * Pagination resolution (F10). Resolves the per-theme pagination primitive into
127
+ * a flat, CSS-ready set the Pagination component consumes verbatim. Every leaf
128
+ * DEFAULTS to the prior base render (1px subtle stroke, radius.md, 0 block /
129
+ * 12px inline padding, 36px min size, filled action.primary active page,
130
+ * inherited font metrics) so the base Sent Tech pagination is byte-identical.
131
+ * DSFR / Carbon override the real active-page metrics.
132
+ */
133
+ function paginationOf(semantic, f, thin, radiusMd) {
134
+ const p = f.pagination ?? {};
135
+ const border = p.border || semantic.border.subtle;
136
+ const borderWidth = p.borderWidth ?? thin;
137
+ return {
138
+ background: p.background || semantic.surface.default,
139
+ border,
140
+ borderWidth,
141
+ text: p.text || semantic.text.primary,
142
+ radius: p.radius ?? radiusMd,
143
+ activeBackground: p.activeBackground || semantic.action.primary,
144
+ activeText: p.activeText || semantic.action.primaryText,
145
+ // The active page can drop its border (DSFR/Carbon active page has none);
146
+ // default = the resting page border so the base render is unchanged.
147
+ activeBorder: p.activeBorder || border,
148
+ activeBorderWidth: p.activeBorderWidth ?? borderWidth,
149
+ activeWeight: p.activeWeight ?? "inherit",
150
+ disabledText: p.disabledText || semantic.text.muted,
151
+ paddingBlock: p.paddingBlock ?? "0",
152
+ paddingInline: p.paddingInline ?? "0.75rem",
153
+ minSize: p.minSize ?? "2.25rem",
154
+ fontSize: p.fontSize ?? "inherit",
155
+ lineHeight: p.lineHeight ?? "normal",
156
+ letterSpacing: p.letterSpacing ?? "normal"
157
+ };
158
+ }
159
+ /**
160
+ * Breadcrumb resolution (F10). Resolves the per-theme breadcrumb primitive into
161
+ * a flat set the Breadcrumb component consumes verbatim. Every leaf DEFAULTS to
162
+ * the prior base render (link = text.link, no explicit font metrics → inherited
163
+ * 16px / normal, current 600 weight on the current page). DSFR / Carbon pin the
164
+ * real breadcrumb link colour + typography.
165
+ */
166
+ function breadcrumbOf(semantic, f) {
167
+ const b = f.breadcrumb ?? {};
168
+ return {
169
+ text: b.text || semantic.text.secondary,
170
+ linkText: b.linkText || semantic.text.link,
171
+ currentText: b.currentText || semantic.text.primary,
172
+ separator: b.separator || semantic.text.muted,
173
+ fontSize: b.fontSize ?? "inherit",
174
+ lineHeight: b.lineHeight ?? "normal",
175
+ letterSpacing: b.letterSpacing ?? "normal",
176
+ currentWeight: b.currentWeight ?? "600"
177
+ };
178
+ }
179
+ /**
180
+ * Alert resolution (P-B). Resolves the per-theme alert primitive into a flat,
181
+ * CSS-ready set the Alert component consumes verbatim. Every leaf DEFAULTS to the
182
+ * prior base render (surface.raised fill, 1px subtle box on top/right/bottom, a
183
+ * 4px left accent edge, 16px padding all sides, inherited font / `normal`
184
+ * line-height) so the base Sent Tech alert is byte-identical. DSFR drops the box
185
+ * + fill (accent becomes a `::before` filet → left border 0); Carbon paints a
186
+ * dark banner with a 3px coloured left bar (a real border).
187
+ */
188
+ function alertOf(semantic, f, thin, borderStyle) {
189
+ const a = f.alert ?? {};
190
+ // The base box border = 1px subtle on top/right/bottom (the left edge is the
191
+ // accent, sized by accentWidth and coloured per severity by the component).
192
+ const box = `${thin} ${borderStyle} ${semantic.border.subtle}`;
193
+ return {
194
+ background: a.background || semantic.surface.raised,
195
+ text: a.text || semantic.text.primary,
196
+ borderTop: a.borderTop || box,
197
+ borderRight: a.borderRight || box,
198
+ borderBottom: a.borderBottom || box,
199
+ accentWidth: a.accentWidth ?? "0.25rem", // 4px (current)
200
+ filetWidth: a.filetWidth ?? "0", // no filet (base/Carbon use a real left border)
201
+ paddingTop: a.paddingTop ?? "1rem",
202
+ paddingRight: a.paddingRight ?? "1rem",
203
+ paddingBottom: a.paddingBottom ?? "1rem",
204
+ paddingLeft: a.paddingLeft ?? "1rem",
205
+ fontSize: a.fontSize ?? "inherit",
206
+ lineHeight: a.lineHeight ?? "normal",
207
+ letterSpacing: a.letterSpacing ?? "normal",
208
+ accentInfo: a.accentInfo || semantic.feedback.info,
209
+ accentSuccess: a.accentSuccess || semantic.feedback.success,
210
+ accentWarning: a.accentWarning || semantic.feedback.warning,
211
+ accentError: a.accentError || semantic.feedback.error
212
+ };
213
+ }
214
+ /**
215
+ * Accordion resolution (P-B). Resolves the per-theme accordion-trigger primitive
216
+ * into a flat set the Accordion component consumes verbatim. Every leaf DEFAULTS
217
+ * to the prior base render (14px block / 8px inline padding, inherited font-size,
218
+ * weight 600, `normal` line-height, primary text) so the base Sent Tech accordion
219
+ * is byte-identical. DSFR / Carbon pin the real header metrics.
220
+ */
221
+ function accordionOf(semantic, f) {
222
+ const a = f.accordion ?? {};
223
+ return {
224
+ text: a.text || semantic.text.primary,
225
+ paddingBlock: a.paddingBlock ?? "0.875rem", // 14px (current)
226
+ paddingInline: a.paddingInline ?? "0.5rem", // 8px (current)
227
+ fontSize: a.fontSize ?? "inherit",
228
+ fontWeight: a.fontWeight ?? "600",
229
+ lineHeight: a.lineHeight ?? "normal"
230
+ };
231
+ }
232
+ /**
233
+ * Tag resolution (P-C). Resolves the per-theme tag primitive into a flat,
234
+ * CSS-ready set the Tag component consumes verbatim. Every leaf DEFAULTS to the
235
+ * prior base render (pill radius 999px, 4px/10px padding, 12px font, weight 600,
236
+ * line-height 1, no transform, no min-height, NEUTRAL tone = surface.subtle fill
237
+ * + text.secondary text) so the base Sent Tech tag is byte-identical. DSFR /
238
+ * Carbon override the real `.fr-tag` / `.bx--tag` metrics + neutral colours.
239
+ */
240
+ function tagOf(semantic, f) {
241
+ const t = f.tag ?? {};
242
+ return {
243
+ radius: t.radius ?? "999px",
244
+ paddingBlock: t.paddingBlock ?? "0.25rem", // 4px (current md)
245
+ paddingInline: t.paddingInline ?? "0.625rem", // 10px (current md)
246
+ fontSize: t.fontSize ?? "0.75rem", // 12px (current md)
247
+ fontWeight: t.fontWeight ?? "600",
248
+ lineHeight: t.lineHeight ?? "1",
249
+ letterSpacing: t.letterSpacing ?? "normal",
250
+ textTransform: t.textTransform ?? "none",
251
+ minHeight: t.minHeight ?? "0",
252
+ neutralBackground: t.neutralBackground || semantic.surface.subtle,
253
+ neutralText: t.neutralText || semantic.text.secondary
254
+ };
255
+ }
256
+ /**
257
+ * Badge resolution (P-C). Resolves the per-theme badge primitive into a flat,
258
+ * CSS-ready set the Badge component consumes verbatim. Every leaf DEFAULTS to
259
+ * the prior base render (pill radius 999px, 4px/8px padding, 12px font, weight
260
+ * 650, line-height 1, no transform, no min-height; tone colours stay the per-tone
261
+ * feedback mix) so the base Sent Tech badge is byte-identical. DSFR overrides the
262
+ * real `.fr-badge` metrics + recolours the INFO tone (the bench-rendered one) to
263
+ * the measured grey badge.
264
+ */
265
+ function badgeOf(semantic, f) {
266
+ const b = f.badge ?? {};
267
+ return {
268
+ radius: b.radius ?? "999px",
269
+ paddingBlock: b.paddingBlock ?? "0.25rem", // 4px (current)
270
+ paddingInline: b.paddingInline ?? "0.5rem", // 8px (current)
271
+ fontSize: b.fontSize ?? "0.75rem", // 12px (current)
272
+ fontWeight: b.fontWeight ?? "650",
273
+ lineHeight: b.lineHeight ?? "1",
274
+ letterSpacing: b.letterSpacing ?? "normal",
275
+ textTransform: b.textTransform ?? "none",
276
+ minHeight: b.minHeight ?? "0",
277
+ // Default INFO fill reproduces the current `color-mix(... feedback.info 14%,
278
+ // white)`; a theme can replace it with a flat measured colour.
279
+ infoBackground: b.infoBackground || `color-mix(in srgb, ${semantic.feedback.info} 14%, white)`,
280
+ infoText: b.infoText || semantic.feedback.info
281
+ };
282
+ }
283
+ /**
284
+ * Choice (Checkbox/Radio) LABEL resolution (P-D). Resolves the per-theme label
285
+ * typography into a flat, CSS-ready set the `.st-choice__label` consumes
286
+ * verbatim. Every leaf DEFAULTS to the prior base render (15px font, `normal`
287
+ * line-height + letter-spacing, primary text) so the base Sent Tech
288
+ * checkbox/radio is byte-identical. `radioLineHeight` defaults to the checkbox
289
+ * line-height (single value) so a theme that does not split them stays
290
+ * consistent; Carbon splits 18px (checkbox) / 20px (radio).
291
+ */
292
+ function choiceOf(semantic, f) {
293
+ const c = f.choice ?? {};
294
+ const labelLineHeight = c.labelLineHeight ?? "normal";
295
+ return {
296
+ labelFontSize: c.labelFontSize ?? "0.9375rem", // 15px (current)
297
+ labelLineHeight,
298
+ radioLineHeight: c.radioLineHeight ?? labelLineHeight,
299
+ labelLetterSpacing: c.labelLetterSpacing ?? "normal",
300
+ labelColor: c.labelColor || semantic.text.primary
301
+ };
302
+ }
303
+ /**
304
+ * Search FIELD resolution (P-D). Resolves the per-theme search-box padding +
305
+ * input typography into a flat set the `.st-search` consumes verbatim. Every
306
+ * leaf DEFAULTS to the prior base render (0 padding on the wrapper, inherited
307
+ * 16px / `normal` typography) so the base Sent Tech search is byte-identical.
308
+ * The field box (fill / borders / radius) is the shared field anatomy, unchanged.
309
+ */
310
+ function searchOf(f) {
311
+ const s = f.search ?? {};
312
+ return {
313
+ paddingBlock: s.paddingBlock ?? "0",
314
+ paddingInline: s.paddingInline ?? "0",
315
+ fontSize: s.fontSize ?? "1rem", // 16px (current inherited)
316
+ lineHeight: s.lineHeight ?? "normal",
317
+ letterSpacing: s.letterSpacing ?? "normal"
318
+ };
319
+ }
320
+ /**
321
+ * Toggle / Switch resolution (P-D). Resolves the per-theme track geometry +
322
+ * colours + label typography into a flat set the Toggle/Switch components
323
+ * consume verbatim. Every leaf DEFAULTS to the prior base render (pill radius
324
+ * 999px, 2px inner padding, 36×20 md track, 16px md thumb, border.strong resting
325
+ * track, action.primary checked track, surface.default thumb, inherited `normal`
326
+ * typography, primary text) so the base Sent Tech toggle is byte-identical.
327
+ */
328
+ function toggleOf(semantic, f) {
329
+ const t = f.toggle ?? {};
330
+ return {
331
+ trackRadius: t.trackRadius ?? "999px",
332
+ trackPadding: t.trackPadding ?? "0.125rem", // 2px (current)
333
+ trackWidth: t.trackWidth ?? "2.25rem", // 36px (current md)
334
+ trackHeight: t.trackHeight ?? "1.25rem", // 20px (current md)
335
+ thumbSize: t.thumbSize ?? "1rem", // 16px (current md)
336
+ trackColor: t.trackColor || semantic.border.strong,
337
+ trackCheckedColor: t.trackCheckedColor || semantic.action.primary,
338
+ thumbColor: t.thumbColor || semantic.surface.default,
339
+ fontSize: t.fontSize ?? "inherit",
340
+ lineHeight: t.lineHeight ?? "normal",
341
+ letterSpacing: t.letterSpacing ?? "normal",
342
+ textColor: t.textColor || semantic.text.primary
343
+ };
344
+ }
43
345
  function typographyOf(f, role) {
44
346
  // Widen to TypographyAnatomy so the optional textDecorationHover leaf is
45
347
  // readable across all roles (only `link` carries it in the FALLBACK literal).
@@ -102,25 +404,79 @@ function focusOf(f) {
102
404
  * borders all = `<borderWidth.thin> solid <border.subtle>`. This reproduces
103
405
  * the existing boxed input EXACTLY → no Sent Tech regression.
104
406
  * - `filled-underline` (DSFR / Carbon): `fillBg` = the theme's field fill tone,
105
- * top/right/left = `none`, only `borderBottom` = `<underlineWidth> solid
106
- * <underlineColor>`. This is what makes DSFR/Carbon inputs faithful (filled +
107
- * bottom rule only) instead of a boxed encadré.
407
+ * top/right/left = `none`, the bottom rule is the only stroke. HOW the bottom
408
+ * rule is drawn depends on the theme's real technique:
409
+ * · DSFR (`underlineAsShadow: true`) draws it as a `box-shadow inset` (its
410
+ * real CSS), so `borderBottom` is `none` and the rule adds no box height.
411
+ * · Carbon (default) genuinely uses a real `border-bottom: 1px solid` — so
412
+ * we keep the geometric `borderBottom` and leave `underline` = `none` to
413
+ * stay pixel-identical to the official `.bx--text-input`.
414
+ *
415
+ * v1.3.0 (additive): `radiusTop` rounds only the field's TOP corners (defaults
416
+ * to the theme's `shapeRadius` so a boxed field stays uniform — no regression);
417
+ * `underline` carries the filled-underline bottom rule as an inset box-shadow
418
+ * when `underlineAsShadow` is set; `focusShadow` composes it with the focus ring.
108
419
  */
109
- function fieldOf(semantic, f, bw, borderStyle) {
420
+ function fieldOf(semantic, f, bw, borderStyle, shapeRadius, focusBoxShadow) {
110
421
  const themed = f.field ?? {};
111
422
  const style = (themed.style ?? FALLBACK.field.style);
112
423
  const thin = bw.thin ?? "1px";
424
+ // v1.4.0 (F5/F9) — native <select> rendering. These are independent of the
425
+ // outline/filled-underline branch, so resolve them once and spread into each
426
+ // returned FieldAnatomy. selectAppearance "auto" keeps the base native arrow
427
+ // (and its UA-forced `line-height: normal`); "none" lets the anatomy
428
+ // line-height take effect, the chevron then drawn by selectChevron.
429
+ const selectAppearance = themed.selectAppearance ?? FALLBACK.field.selectAppearance;
430
+ const selectChevron = themed.selectChevron ?? FALLBACK.field.selectChevron;
431
+ const selectPaddingRight = themed.selectPaddingRight ?? FALLBACK.field.selectPaddingRight;
432
+ const selectLeaves = { selectAppearance, selectChevron, selectPaddingRight };
433
+ // Top corners inherit the theme's shape radius unless the theme rounds them
434
+ // explicitly (DSFR field = 4px top). Bottom corners always keep shapeRadius.
435
+ const radiusTop = themed.radiusTop || shapeRadius;
436
+ // Compose the field focus box-shadow so the resting underline is never lost
437
+ // incoherently: an outline-strategy theme (focusBoxShadow === "none") keeps
438
+ // the underline; an inset/ring theme stacks its ring + the underline.
439
+ const composeFocus = (underline) => {
440
+ const ring = focusBoxShadow && focusBoxShadow !== "none" ? focusBoxShadow : "";
441
+ if (underline === "none")
442
+ return ring || "none";
443
+ if (!ring)
444
+ return underline; // outline theme: keep the underline at focus
445
+ return `${ring}, ${underline}`; // inset/ring theme: ring + underline
446
+ };
113
447
  if (style === "filled-underline") {
114
448
  const fillBg = themed.fillBg || semantic.surface.subtle;
115
449
  const underlineColor = themed.underlineColor || semantic.border.strong;
116
450
  const underlineWidth = themed.underlineWidth || thin;
451
+ // DSFR draws the rule as an inset box-shadow (its real technique, cf. rule
452
+ // `underline-hardcoded-border`); Carbon keeps a real geometric border-bottom
453
+ // (its real technique) so it stays pixel-identical to `.bx--text-input`.
454
+ if (themed.underlineMode === "shadow") {
455
+ const underline = `inset 0 -${underlineWidth} 0 0 ${underlineColor}`;
456
+ return {
457
+ style,
458
+ fillBg,
459
+ borderTop: "none",
460
+ borderRight: "none",
461
+ borderBottom: "none",
462
+ borderLeft: "none",
463
+ radiusTop,
464
+ underline,
465
+ focusShadow: composeFocus(underline),
466
+ ...selectLeaves
467
+ };
468
+ }
117
469
  return {
118
470
  style,
119
471
  fillBg,
120
472
  borderTop: "none",
121
473
  borderRight: "none",
122
474
  borderBottom: `${underlineWidth} ${borderStyle} ${underlineColor}`,
123
- borderLeft: "none"
475
+ borderLeft: "none",
476
+ radiusTop,
477
+ underline: "none",
478
+ focusShadow: composeFocus("none"),
479
+ ...selectLeaves
124
480
  };
125
481
  }
126
482
  // outline (default): boxed, 4 equal borders — identical to the prior look.
@@ -132,7 +488,11 @@ function fieldOf(semantic, f, bw, borderStyle) {
132
488
  borderTop: border,
133
489
  borderRight: border,
134
490
  borderBottom: border,
135
- borderLeft: border
491
+ borderLeft: border,
492
+ radiusTop,
493
+ underline: "none",
494
+ focusShadow: composeFocus("none"),
495
+ ...selectLeaves
136
496
  };
137
497
  }
138
498
  /**
@@ -154,7 +514,10 @@ export function createComponent(semantic, foundation) {
154
514
  // foundation (radius/density/typography/focus) → the brand reaches anatomy.
155
515
  const buttonAnatomy = {
156
516
  shape: { radius: foundation.radius.md, borderWidth: bw.thin, borderStyle },
157
- density: { sm: densityOf(foundation, "sm"), md: densityOf(foundation, "md"), lg: densityOf(foundation, "lg") },
517
+ // F9: button-specific density (shared control density + optional button-only
518
+ // override) so Carbon's tall, asymmetric primary button doesn't regress the
519
+ // fields that share the control density. Base/DSFR get the shared density.
520
+ density: { sm: buttonDensityOf(foundation, "sm"), md: buttonDensityOf(foundation, "md"), lg: buttonDensityOf(foundation, "lg") },
158
521
  typography: typographyOf(foundation, "control"),
159
522
  focus,
160
523
  icon: { size: icon.md, gap: densityOf(foundation, "md").gap },
@@ -172,7 +535,9 @@ export function createComponent(semantic, foundation) {
172
535
  typography: typographyOf(foundation, "field"),
173
536
  focus,
174
537
  // Field style (v1.2.0): outline (boxed, base) vs filled-underline (DSFR/Carbon).
175
- field: fieldOf(semantic, foundation, bw, borderStyle),
538
+ // v1.3.0: radiusTop defaults to the field's own shape radius (radius.md);
539
+ // the underline is an inset box-shadow, composed with the focus ring.
540
+ field: fieldOf(semantic, foundation, bw, borderStyle, foundation.radius.md, focus.boxShadow),
176
541
  states: {
177
542
  hover: { border: semantic.border.strong },
178
543
  focus: { border: semantic.border.interactive },
@@ -193,30 +558,77 @@ export function createComponent(semantic, foundation) {
193
558
  disabled: { text: semantic.text.muted, decoration: "none", opacity: disabledOpacity }
194
559
  }
195
560
  };
561
+ // Card surface (additive): borderWidth defaults to the base `thin` stroke so
562
+ // Sent Tech is unchanged; DSFR/Carbon set it to 0 (their cards/tiles have no
563
+ // border). The fill defaults to surface.raised (base), Carbon overrides it to
564
+ // its $layer-01 tone via `card.background`.
565
+ const cardBorderWidth = foundation.card?.borderWidth ?? bw.thin;
566
+ const cardBackground = foundation.card?.background || semantic.surface.raised;
567
+ // F5 (additive): the card body typography. The base `.st-card` carries NO
568
+ // explicit font-size / line-height / letter-spacing, so the defaults here
569
+ // REPRODUCE that exact render (inherit / normal / normal). DSFR/Carbon pin
570
+ // their real tile body metrics so the card text matches the measured
571
+ // reference instead of `normal`. Family/weight stay on the field role (no
572
+ // visible change — the card already inherits the brand sans + 400).
573
+ const cardTypographyBase = typographyOf(foundation, "field");
574
+ const cardTypography = {
575
+ ...cardTypographyBase,
576
+ size: foundation.card?.fontSize ?? "inherit",
577
+ lineHeight: foundation.card?.lineHeight ?? "normal",
578
+ letterSpacing: foundation.card?.letterSpacing ?? "normal"
579
+ };
196
580
  const cardAnatomy = {
197
- shape: { radius: foundation.radius.lg, borderWidth: bw.thin, borderStyle },
198
- typography: typographyOf(foundation, "field"),
581
+ shape: { radius: foundation.radius.lg, borderWidth: cardBorderWidth, borderStyle },
582
+ typography: cardTypography,
199
583
  focus,
200
584
  states: {
201
585
  hover: { transform: "translateY(-1px)" }
202
586
  }
203
587
  };
588
+ const tabsControlTypography = typographyOf(foundation, "control");
204
589
  const tabsAnatomy = {
205
590
  shape: { radius: foundation.radius.none ?? "0", borderWidth: bw.thin, borderStyle },
206
591
  density: { md: densityOf(foundation, "md") },
207
- typography: typographyOf(foundation, "control"),
592
+ typography: tabsControlTypography,
208
593
  focus,
209
594
  states: {
210
595
  hover: { text: semantic.text.primary },
211
596
  disabled: { opacity: disabledOpacity }
212
597
  }
213
598
  };
599
+ // F7/F8 — active-tab metrics (additive, per-theme; base render unchanged).
600
+ // The indicator width is the tab's stroke width (thin); `tabsOf` resolves it
601
+ // onto the top edge (DSFR) or the bottom edge (base/Carbon), as a real border
602
+ // (base/Carbon) or an inset box-shadow accent (DSFR).
603
+ const tabsResolved = tabsOf(foundation, tabsControlTypography, bw.thin, semantic.action.primary, semantic.text.primary);
604
+ // G1 — secondary button surface (per theme; base render unchanged). DSFR
605
+ // overrides it to a transparent fill + Bleu France border + text; base/Carbon
606
+ // keep the filled neutral default.
607
+ const buttonSecondary = buttonSecondaryOf(semantic, foundation);
608
+ // F10 — Pagination / Breadcrumb anatomy (per theme; base render unchanged).
609
+ const paginationResolved = paginationOf(semantic, foundation, bw.thin, foundation.radius.md);
610
+ const breadcrumbResolved = breadcrumbOf(semantic, foundation);
611
+ // P-B — Alert / Accordion anatomy (per theme; base render unchanged).
612
+ const alertResolved = alertOf(semantic, foundation, bw.thin, borderStyle);
613
+ const accordionResolved = accordionOf(semantic, foundation);
614
+ // P-C — Tag / Badge anatomy (per theme; base render unchanged).
615
+ const tagResolved = tagOf(semantic, foundation);
616
+ const badgeResolved = badgeOf(semantic, foundation);
617
+ // P-D — Choice (Checkbox/Radio) / Search / Toggle anatomy (per theme; base
618
+ // render unchanged via the resolver defaults).
619
+ const choiceResolved = choiceOf(semantic, foundation);
620
+ const searchResolved = searchOf(foundation);
621
+ const toggleResolved = toggleOf(semantic, foundation);
214
622
  return {
215
623
  button: {
216
624
  radius: foundation.radius.md,
217
625
  primaryBackground: semantic.action.primary,
218
626
  primaryText: semantic.action.primaryText,
219
- secondaryBackground: semantic.action.secondary,
627
+ // G1: the secondary surface is resolved per theme (transparent + bordered
628
+ // for DSFR's outlined secondary; filled neutral for base/Carbon).
629
+ secondaryBackground: buttonSecondary.background,
630
+ secondaryBorder: buttonSecondary.border,
631
+ secondaryHoverBackground: buttonSecondary.hoverBackground,
220
632
  secondaryText: semantic.action.secondaryText,
221
633
  anatomy: buttonAnatomy
222
634
  },
@@ -228,17 +640,74 @@ export function createComponent(semantic, foundation) {
228
640
  anatomy: linkAnatomy
229
641
  },
230
642
  alert: {
231
- background: semantic.surface.raised,
232
- text: semantic.text.primary,
643
+ // Existing leaves keep their names (consumers/docs unchanged); background
644
+ // and text now resolve through `alertOf` (identical defaults = unchanged base).
645
+ background: alertResolved.background,
646
+ text: alertResolved.text,
233
647
  border: semantic.border.subtle,
234
- infoBorder: semantic.feedback.info,
235
- successBorder: semantic.feedback.success,
236
- warningBorder: semantic.feedback.warning,
237
- errorBorder: semantic.feedback.error,
238
- radius: foundation.radius.lg
648
+ // Per-severity accent colours now resolve through `alertOf` (default = the
649
+ // matching feedback role → base unchanged; Carbon overrides accentInfo).
650
+ infoBorder: alertResolved.accentInfo,
651
+ successBorder: alertResolved.accentSuccess,
652
+ warningBorder: alertResolved.accentWarning,
653
+ errorBorder: alertResolved.accentError,
654
+ radius: foundation.radius.lg,
655
+ // P-B additive leaves — per-theme alert anatomy (base = unchanged).
656
+ borderTop: alertResolved.borderTop,
657
+ borderRight: alertResolved.borderRight,
658
+ borderBottom: alertResolved.borderBottom,
659
+ // Left accent: a real left border of `accentWidth` (base/Carbon) OR a
660
+ // `::before` filet of `filetWidth` drawn inside the box (DSFR) so the
661
+ // measured left border stays 0. Both coloured per severity by the component.
662
+ accentWidth: alertResolved.accentWidth,
663
+ filetWidth: alertResolved.filetWidth,
664
+ paddingTop: alertResolved.paddingTop,
665
+ paddingRight: alertResolved.paddingRight,
666
+ paddingBottom: alertResolved.paddingBottom,
667
+ paddingLeft: alertResolved.paddingLeft,
668
+ fontSize: alertResolved.fontSize,
669
+ lineHeight: alertResolved.lineHeight,
670
+ letterSpacing: alertResolved.letterSpacing
671
+ },
672
+ accordion: {
673
+ // P-B — per-theme accordion-trigger anatomy (base = unchanged).
674
+ text: accordionResolved.text,
675
+ paddingBlock: accordionResolved.paddingBlock,
676
+ paddingInline: accordionResolved.paddingInline,
677
+ fontSize: accordionResolved.fontSize,
678
+ fontWeight: accordionResolved.fontWeight,
679
+ lineHeight: accordionResolved.lineHeight
680
+ },
681
+ tag: {
682
+ // P-C — per-theme tag anatomy (base = unchanged via resolver defaults).
683
+ radius: tagResolved.radius,
684
+ paddingBlock: tagResolved.paddingBlock,
685
+ paddingInline: tagResolved.paddingInline,
686
+ fontSize: tagResolved.fontSize,
687
+ fontWeight: tagResolved.fontWeight,
688
+ lineHeight: tagResolved.lineHeight,
689
+ letterSpacing: tagResolved.letterSpacing,
690
+ textTransform: tagResolved.textTransform,
691
+ minHeight: tagResolved.minHeight,
692
+ neutralBackground: tagResolved.neutralBackground,
693
+ neutralText: tagResolved.neutralText
694
+ },
695
+ badge: {
696
+ // P-C — per-theme badge anatomy (base = unchanged via resolver defaults).
697
+ radius: badgeResolved.radius,
698
+ paddingBlock: badgeResolved.paddingBlock,
699
+ paddingInline: badgeResolved.paddingInline,
700
+ fontSize: badgeResolved.fontSize,
701
+ fontWeight: badgeResolved.fontWeight,
702
+ lineHeight: badgeResolved.lineHeight,
703
+ letterSpacing: badgeResolved.letterSpacing,
704
+ textTransform: badgeResolved.textTransform,
705
+ minHeight: badgeResolved.minHeight,
706
+ infoBackground: badgeResolved.infoBackground,
707
+ infoText: badgeResolved.infoText
239
708
  },
240
709
  card: {
241
- background: semantic.surface.raised,
710
+ background: cardBackground,
242
711
  border: semantic.border.subtle,
243
712
  radius: foundation.radius.lg,
244
713
  shadow: foundation.shadow.subtle,
@@ -305,9 +774,38 @@ export function createComponent(semantic, foundation) {
305
774
  checkedBackground: semantic.action.primary,
306
775
  checkedText: semantic.action.primaryText,
307
776
  border: semantic.border.subtle,
308
- switchTrack: semantic.border.strong,
309
- switchTrackChecked: semantic.action.primary,
310
- switchThumb: semantic.surface.default
777
+ // Existing switch colour leaves now resolve through `toggleOf` (identical
778
+ // defaults = unchanged base; DSFR/Carbon set their measured track colours).
779
+ switchTrack: toggleResolved.trackColor,
780
+ switchTrackChecked: toggleResolved.trackCheckedColor,
781
+ switchThumb: toggleResolved.thumbColor,
782
+ // P-D additive — Choice (Checkbox/Radio) label typography (base = unchanged).
783
+ choiceLabelFontSize: choiceResolved.labelFontSize,
784
+ choiceLabelLineHeight: choiceResolved.labelLineHeight,
785
+ choiceRadioLineHeight: choiceResolved.radioLineHeight,
786
+ choiceLabelLetterSpacing: choiceResolved.labelLetterSpacing,
787
+ choiceLabelColor: choiceResolved.labelColor,
788
+ // P-D additive — Toggle/Switch track geometry + label typography (base =
789
+ // unchanged via the resolver defaults).
790
+ toggleTrackRadius: toggleResolved.trackRadius,
791
+ toggleTrackPadding: toggleResolved.trackPadding,
792
+ toggleTrackWidth: toggleResolved.trackWidth,
793
+ toggleTrackHeight: toggleResolved.trackHeight,
794
+ toggleThumbSize: toggleResolved.thumbSize,
795
+ toggleFontSize: toggleResolved.fontSize,
796
+ toggleLineHeight: toggleResolved.lineHeight,
797
+ toggleLetterSpacing: toggleResolved.letterSpacing,
798
+ toggleTextColor: toggleResolved.textColor
799
+ },
800
+ search: {
801
+ // P-D additive — Search field box padding + input typography (base =
802
+ // unchanged via the resolver defaults). The field box (fill/border/radius)
803
+ // stays the shared `control.anatomy.field` already mapped like Input.
804
+ paddingBlock: searchResolved.paddingBlock,
805
+ paddingInline: searchResolved.paddingInline,
806
+ fontSize: searchResolved.fontSize,
807
+ lineHeight: searchResolved.lineHeight,
808
+ letterSpacing: searchResolved.letterSpacing
311
809
  },
312
810
  overlay: {
313
811
  backdrop: semantic.surface.overlay,
@@ -367,27 +865,57 @@ export function createComponent(semantic, foundation) {
367
865
  radius: foundation.radius.lg
368
866
  },
369
867
  tabs: {
370
- activeText: semantic.text.primary,
868
+ // F7/F8: active-tab roles/metrics resolved per theme (base render = current).
869
+ activeText: tabsResolved.activeText,
870
+ activeBackground: tabsResolved.activeBackground,
871
+ // G2: resting-tab fill (default transparent; DSFR = light grey-blue).
872
+ inactiveBackground: tabsResolved.inactiveBackground,
873
+ activeWeight: tabsResolved.activeWeight,
371
874
  inactiveText: semantic.text.secondary,
372
875
  border: semantic.border.subtle,
373
876
  indicator: semantic.action.primary,
374
877
  panelBackground: semantic.surface.default,
878
+ tabPaddingBlock: tabsResolved.paddingBlock,
879
+ tabPaddingInline: tabsResolved.paddingInline,
880
+ tabFontSize: tabsResolved.fontSize,
881
+ tabLineHeight: tabsResolved.lineHeight,
882
+ activeBorderTopWidth: tabsResolved.activeBorderTopWidth,
883
+ activeBorderBottomWidth: tabsResolved.activeBorderBottomWidth,
884
+ activeShadow: tabsResolved.activeShadow,
375
885
  anatomy: tabsAnatomy
376
886
  },
377
887
  pagination: {
378
- background: semantic.surface.default,
379
- border: semantic.border.subtle,
380
- text: semantic.text.primary,
381
- activeBackground: semantic.action.primary,
382
- activeText: semantic.action.primaryText,
383
- disabledText: semantic.text.muted,
384
- radius: foundation.radius.md
888
+ // Existing leaves keep their names (consumers/docs unchanged); they now
889
+ // resolve through `paginationOf` (identical defaults = unchanged base).
890
+ background: paginationResolved.background,
891
+ border: paginationResolved.border,
892
+ text: paginationResolved.text,
893
+ activeBackground: paginationResolved.activeBackground,
894
+ activeText: paginationResolved.activeText,
895
+ disabledText: paginationResolved.disabledText,
896
+ radius: paginationResolved.radius,
897
+ // F10 additive leaves — per-theme active-page metrics (base = unchanged).
898
+ borderWidth: paginationResolved.borderWidth,
899
+ activeBorder: paginationResolved.activeBorder,
900
+ activeBorderWidth: paginationResolved.activeBorderWidth,
901
+ activeWeight: paginationResolved.activeWeight,
902
+ paddingBlock: paginationResolved.paddingBlock,
903
+ paddingInline: paginationResolved.paddingInline,
904
+ minSize: paginationResolved.minSize,
905
+ fontSize: paginationResolved.fontSize,
906
+ lineHeight: paginationResolved.lineHeight,
907
+ letterSpacing: paginationResolved.letterSpacing
385
908
  },
386
909
  breadcrumb: {
387
- text: semantic.text.secondary,
388
- currentText: semantic.text.primary,
389
- separator: semantic.text.muted,
390
- linkText: semantic.text.link
910
+ text: breadcrumbResolved.text,
911
+ currentText: breadcrumbResolved.currentText,
912
+ separator: breadcrumbResolved.separator,
913
+ linkText: breadcrumbResolved.linkText,
914
+ // F10 additive leaves — per-theme breadcrumb typography (base = unchanged).
915
+ fontSize: breadcrumbResolved.fontSize,
916
+ lineHeight: breadcrumbResolved.lineHeight,
917
+ letterSpacing: breadcrumbResolved.letterSpacing,
918
+ currentWeight: breadcrumbResolved.currentWeight
391
919
  },
392
920
  sideNav: {
393
921
  background: semantic.surface.default,