@multiplatform.one/theme 7.46.0 → 7.47.1

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 (86) hide show
  1. package/README.md +33 -0
  2. package/expo.cjs +3 -0
  3. package/package.json +22 -6
  4. package/src/audit/constraintAudit.ts +2 -1
  5. package/src/audit/themeMatrix.ts +1 -0
  6. package/src/expo.spec.ts +83 -0
  7. package/src/expo.ts +57 -0
  8. package/src/expoConfig.spec.ts +67 -0
  9. package/src/figma/figmaTokens.spec.ts +4 -3
  10. package/src/theme/FontKnobStyles.tsx +13 -1
  11. package/src/theme/Heading.tsx +70 -0
  12. package/src/theme/Intent.stories.tsx +2 -2
  13. package/src/theme/Preset.stories.tsx +2 -2
  14. package/src/theme/Preset.tsx +4 -2
  15. package/src/theme/Surface.stories.tsx +2 -1
  16. package/src/theme/Surface.tsx +4 -2
  17. package/src/theme/Text.tsx +152 -0
  18. package/src/theme/ThemeProvider.stories.tsx +2 -1
  19. package/src/theme/Tint.stories.tsx +2 -1
  20. package/src/theme/accentFloor.spec.ts +5 -3
  21. package/src/theme/accentOnSurface.palette.spec.tsx +75 -0
  22. package/src/theme/accentOnSurface.spec.tsx +149 -0
  23. package/src/theme/colorThemeAccent.spec.ts +5 -2
  24. package/src/theme/cookies.spec.ts +2 -2
  25. package/src/theme/createDefaultThemeConfig.spec.ts +2 -2
  26. package/src/theme/createThemes.ts +91 -11
  27. package/src/theme/defaults/builderOptions.ts +18 -0
  28. package/src/theme/devtools/ColorLineVisualizer.tsx +3 -0
  29. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +2 -0
  30. package/src/theme/hairline.native.ts +3 -0
  31. package/src/theme/hairline.ts +9 -0
  32. package/src/theme/hairlineToken.spec.ts +110 -0
  33. package/src/theme/index.ts +4 -0
  34. package/src/theme/knobs.ts +2 -2
  35. package/src/theme/mpo19Proof.spec.ts +4 -1
  36. package/src/theme/overlaySurfaces.tsx +9 -3
  37. package/src/theme/pageGutter.tsx +61 -0
  38. package/src/theme/preset.spec.tsx +29 -1
  39. package/src/theme/presets.spec.ts +1 -1
  40. package/src/theme/presets.ts +2 -2
  41. package/src/theme/ramps.spec.ts +53 -0
  42. package/src/theme/ramps.ts +75 -0
  43. package/src/theme/readableColor.ts +111 -70
  44. package/src/theme/recipes.ts +51 -2
  45. package/src/theme/resolveFontTokenFamily.spec.ts +44 -0
  46. package/src/theme/resolveKnobs.spec.ts +30 -20
  47. package/src/theme/resolveKnobs.ts +87 -20
  48. package/src/theme/selectedState.spec.ts +127 -0
  49. package/types/audit/constraintAudit.d.ts.map +1 -1
  50. package/types/audit/themeMatrix.d.ts.map +1 -1
  51. package/types/expo.d.ts +29 -0
  52. package/types/expo.d.ts.map +1 -0
  53. package/types/theme/FontKnobStyles.d.ts +7 -0
  54. package/types/theme/FontKnobStyles.d.ts.map +1 -1
  55. package/types/theme/Heading.d.ts +10 -0
  56. package/types/theme/Heading.d.ts.map +1 -0
  57. package/types/theme/Preset.d.ts.map +1 -1
  58. package/types/theme/Surface.d.ts +2 -1
  59. package/types/theme/Surface.d.ts.map +1 -1
  60. package/types/theme/Text.d.ts +13 -0
  61. package/types/theme/Text.d.ts.map +1 -0
  62. package/types/theme/colorRules.d.ts +1 -1
  63. package/types/theme/createThemes.d.ts.map +1 -1
  64. package/types/theme/defaults/builderOptions.d.ts.map +1 -1
  65. package/types/theme/defaults/fonts.d.ts +30 -30
  66. package/types/theme/devtools/ColorLineVisualizer.d.ts.map +1 -1
  67. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
  68. package/types/theme/hairline.d.ts +8 -0
  69. package/types/theme/hairline.d.ts.map +1 -1
  70. package/types/theme/hairline.native.d.ts +2 -0
  71. package/types/theme/hairline.native.d.ts.map +1 -1
  72. package/types/theme/index.d.ts +4 -0
  73. package/types/theme/index.d.ts.map +1 -1
  74. package/types/theme/knobs.d.ts +1 -1
  75. package/types/theme/knobs.d.ts.map +1 -1
  76. package/types/theme/overlaySurfaces.d.ts.map +1 -1
  77. package/types/theme/pageGutter.d.ts +41 -0
  78. package/types/theme/pageGutter.d.ts.map +1 -0
  79. package/types/theme/ramps.d.ts +20 -0
  80. package/types/theme/ramps.d.ts.map +1 -0
  81. package/types/theme/readableColor.d.ts +24 -12
  82. package/types/theme/readableColor.d.ts.map +1 -1
  83. package/types/theme/recipes.d.ts +44 -4
  84. package/types/theme/recipes.d.ts.map +1 -1
  85. package/types/theme/resolveKnobs.d.ts +10 -4
  86. package/types/theme/resolveKnobs.d.ts.map +1 -1
@@ -97,45 +97,6 @@ export function useLegibleInkOn(fill_: string | undefined, preferred: string): s
97
97
  }, [fill, preferred, preferredValue, paper, ink]);
98
98
  }
99
99
 
100
- /**
101
- * Accent-hued text/link color readable on the page surfaces (color1/color2).
102
- * Scans the theme's own accent ramp for the most chromatic step that clears
103
- * the AA text floor against BOTH page surfaces; falls back to readable neutral
104
- * ink (`$color12`) when no accent step qualifies or the ramp is absent (e.g.
105
- * tint sub-themes, where neutral text is the house default anyway).
106
- */
107
- export function useAccentOnSurface(): string {
108
- const theme = useTamaguiTheme() as unknown as Record<string, { val?: unknown } | undefined>;
109
- const c1 = resolveTokenValue(theme, "color1");
110
- const c2 = resolveTokenValue(theme, "color2");
111
- const ink = resolveTokenValue(theme, "color12") ?? "$color12";
112
- const steps: (string | undefined)[] = [];
113
- for (let i = 1; i <= 12; i++) steps.push(resolveTokenValue(theme, `accent${i}`));
114
- // eslint-disable-next-line react-hooks/exhaustive-deps
115
- return useMemo(() => {
116
- const bg1 = c1 ? normalizeToHex(c1) : null;
117
- const bg2 = c2 ? normalizeToHex(c2) : null;
118
- if (!bg1 || !bg2) return ink;
119
- const l1 = relativeLuminance(bg1);
120
- const l2 = relativeLuminance(bg2);
121
- let best: string | undefined;
122
- let bestMin = Number.POSITIVE_INFINITY;
123
- for (const step of steps) {
124
- const hex = step ? normalizeToHex(step) : null;
125
- if (!step || !hex) continue;
126
- const stepLum = relativeLuminance(hex);
127
- const min = Math.min(contrastRatio(stepLum, l1), contrastRatio(stepLum, l2));
128
- // Most chromatic passing step = the lowest contrast that still clears AA
129
- // (accent steps get paler/whiter as contrast climbs).
130
- if (min >= aaTextContrastRatio && min < bestMin) {
131
- bestMin = min;
132
- best = step;
133
- }
134
- }
135
- return best ?? ink;
136
- }, [c1, c2, ink, steps.join("|")]);
137
- }
138
-
139
100
  /** Below this sRGB chroma a step reads as grey, not as a hue. */
140
101
  const IDENTITY_CHROMA_FLOOR = 0.1;
141
102
 
@@ -144,25 +105,22 @@ function chromaOf(hex: string): number {
144
105
  return Math.max(...rgb) - Math.min(...rgb);
145
106
  }
146
107
 
147
- /**
148
- * Pure resolver behind {@link useIdentityInkOnSurface}: the most chromatic
149
- * candidate that clears AA text contrast on every surface, else `ink`.
150
- */
151
- export function resolveIdentityInk({
152
- candidates,
153
- surfaces,
154
- ink,
155
- }: {
156
- candidates: readonly (string | undefined)[];
157
- surfaces: readonly (string | undefined)[];
158
- ink: string;
159
- }): string {
160
- const surfaceLums: number[] = [];
108
+ function surfaceLuminances(surfaces: readonly (string | undefined)[]): number[] | null {
109
+ const lums: number[] = [];
161
110
  for (const surface of surfaces) {
162
111
  const hex = surface ? normalizeToHex(surface) : null;
163
- if (!hex) return ink;
164
- surfaceLums.push(relativeLuminance(hex));
112
+ if (!hex) return null;
113
+ lums.push(relativeLuminance(hex));
165
114
  }
115
+ return lums;
116
+ }
117
+
118
+ function pickIdentityInk(
119
+ candidates: readonly (string | undefined)[],
120
+ surfaces: readonly (string | undefined)[],
121
+ ): string | undefined {
122
+ const surfaceLums = surfaceLuminances(surfaces);
123
+ if (!surfaceLums) return undefined;
166
124
  let best: string | undefined;
167
125
  let bestChroma = IDENTITY_CHROMA_FLOOR;
168
126
  for (const candidate of candidates) {
@@ -176,31 +134,114 @@ export function resolveIdentityInk({
176
134
  best = candidate;
177
135
  }
178
136
  }
137
+ return best;
138
+ }
139
+
140
+ /**
141
+ * Pure resolver behind {@link useIdentityInkOnSurface}: the most chromatic
142
+ * candidate that clears AA text contrast on every surface, else `ink`.
143
+ */
144
+ export function resolveIdentityInk({
145
+ candidates,
146
+ surfaces,
147
+ ink,
148
+ }: {
149
+ candidates: readonly (string | undefined)[];
150
+ surfaces: readonly (string | undefined)[];
151
+ ink: string;
152
+ }): string {
153
+ return pickIdentityInk(candidates, surfaces) ?? ink;
154
+ }
155
+
156
+ /**
157
+ * Pure resolver behind {@link useAccentOnSurface}. The identity hue first
158
+ * (`identity`, see {@link useIdentityInkOnSurface}), so a colour theme's
159
+ * accent ink is that colour. With no hue, the accent ramp's least-contrast
160
+ * step that still clears AA on every surface, else `ink`.
161
+ */
162
+ export function resolveAccentOnSurface({
163
+ identity,
164
+ accentSteps,
165
+ surfaces,
166
+ ink,
167
+ }: {
168
+ identity: readonly (string | undefined)[];
169
+ accentSteps: readonly (string | undefined)[];
170
+ surfaces: readonly (string | undefined)[];
171
+ ink: string;
172
+ }): string {
173
+ const hued = pickIdentityInk(identity, surfaces);
174
+ if (hued) return hued;
175
+ const surfaceLums = surfaceLuminances(surfaces);
176
+ if (!surfaceLums) return ink;
177
+ let best: string | undefined;
178
+ let bestMin = Number.POSITIVE_INFINITY;
179
+ for (const step of accentSteps) {
180
+ const hex = step ? normalizeToHex(step) : null;
181
+ if (!step || !hex) continue;
182
+ const stepLum = relativeLuminance(hex);
183
+ const min = Math.min(...surfaceLums.map((surface) => contrastRatio(stepLum, surface)));
184
+ if (min >= aaTextContrastRatio && min < bestMin) {
185
+ bestMin = min;
186
+ best = step;
187
+ }
188
+ }
179
189
  return best ?? ink;
180
190
  }
181
191
 
192
+ const accentStepKeys = Array.from({ length: 12 }, (_, index) => `accent${index + 1}`);
193
+
194
+ /**
195
+ * The live theme's identity candidates and page surfaces. Under a tint
196
+ * (`dark_orange`) the hue is the tint's own ramp, whose text tiers the build
197
+ * hands back to neutral, so the candidates are its `color8`..`color10` plus
198
+ * the raw hue's steps 11 and 12; otherwise the accent ramp.
199
+ */
200
+ function useIdentityInputs() {
201
+ const themeName = (useThemeName() as string | undefined) ?? "";
202
+ const theme = useTamaguiTheme() as unknown as Record<string, { val?: unknown } | undefined>;
203
+ const hue = themeName.split("_")[1];
204
+ const isTint = hue !== undefined && tintHueNames.has(hue as never);
205
+ const keys = isTint ? ["color8", "color9", "color10", `${hue}11`, `${hue}12`] : accentStepKeys;
206
+ return {
207
+ candidates: keys.map((key) => resolveTokenValue(theme, key)),
208
+ accentSteps: accentStepKeys.map((key) => resolveTokenValue(theme, key)),
209
+ c1: resolveTokenValue(theme, "color1"),
210
+ c2: resolveTokenValue(theme, "color2"),
211
+ ink: resolveTokenValue(theme, "color12") ?? "$color12",
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Accent-hued text/link ink readable on the page surfaces (color1/color2),
217
+ * shared by links, Toast, DatePicker, table filters, Button and every other
218
+ * accent-ink consumer. Under a colour theme (a tint or a hue accent) it is
219
+ * the theme's identity hue, the same pick as {@link useIdentityInkOnSurface}:
220
+ * a tint leaves the accent ramp neutral, so reading the ramp alone painted
221
+ * grey there (FZ-7783, LC-123). With no hue it keeps the accent ramp's least-
222
+ * contrast step that clears AA, and readable neutral ink (`$color12`) when
223
+ * none does.
224
+ */
225
+ export function useAccentOnSurface(): string {
226
+ const { candidates, accentSteps, c1, c2, ink } = useIdentityInputs();
227
+ const candidateKey = candidates.join("|");
228
+ const stepKey = accentSteps.join("|");
229
+ return useMemo(
230
+ () => resolveAccentOnSurface({ identity: candidates, accentSteps, surfaces: [c1, c2], ink }),
231
+ // eslint-disable-next-line react-hooks/exhaustive-deps
232
+ [candidateKey, stepKey, c1, c2, ink],
233
+ );
234
+ }
235
+
182
236
  /**
183
237
  * The theme's identity hue as ink on the page surfaces (color1/color2): the
184
- * most chromatic step that clears AA on both. Under a tint (`dark_orange`)
185
- * the hue is the tint's own ramp, whose text tiers the build hands back to
186
- * neutral, so the candidates are its `color8`..`color10` plus the raw hue's
187
- * steps 11 and 12; otherwise the accent ramp. A theme with no hue (a neutral
238
+ * most chromatic step that clears AA on both. A theme with no hue (a neutral
188
239
  * accent) returns the primary ink (`color12`): a mark that means "current"
189
240
  * never reads quieter than the secondary ink beside it, which
190
241
  * {@link useAccentOnSurface} does there.
191
242
  */
192
243
  export function useIdentityInkOnSurface(): string {
193
- const themeName = (useThemeName() as string | undefined) ?? "";
194
- const theme = useTamaguiTheme() as unknown as Record<string, { val?: unknown } | undefined>;
195
- const hue = themeName.split("_")[1];
196
- const isTint = hue !== undefined && tintHueNames.has(hue as never);
197
- const keys = isTint
198
- ? ["color8", "color9", "color10", `${hue}11`, `${hue}12`]
199
- : Array.from({ length: 12 }, (_, index) => `accent${index + 1}`);
200
- const candidates = keys.map((key) => resolveTokenValue(theme, key));
201
- const c1 = resolveTokenValue(theme, "color1");
202
- const c2 = resolveTokenValue(theme, "color2");
203
- const ink = resolveTokenValue(theme, "color12") ?? "$color12";
244
+ const { candidates, c1, c2, ink } = useIdentityInputs();
204
245
  const candidateKey = candidates.join("|");
205
246
  return useMemo(
206
247
  () => resolveIdentityInk({ candidates, surfaces: [c1, c2], ink }),
@@ -1,8 +1,28 @@
1
1
  import type { MotionRole, MotionRoleName } from "./motion";
2
- import type { ColorTokens, FontSizeTokens, TransitionProp } from "tamagui";
2
+ import type {
3
+ ColorTokens,
4
+ FontLetterSpacingTokens,
5
+ FontLineHeightTokens,
6
+ FontSizeTokens,
7
+ TransitionProp,
8
+ } from "tamagui";
3
9
  import type { Knobs } from "./knobs";
4
10
  import type { HitSlopInsets } from "./layoutTokens";
5
11
 
12
+ /**
13
+ * The page-title size fragment: the D-11 step, and below `$sm` its compact
14
+ * type step (LC-143). The compact entry carries type geometry only, so a
15
+ * preset's or consumer's weight and colour still win on a phone.
16
+ */
17
+ export type PageTitleFragment = {
18
+ size: FontSizeTokens;
19
+ "$max-sm"?: {
20
+ fontSize: FontSizeTokens;
21
+ lineHeight: FontLineHeightTokens;
22
+ letterSpacing: FontLetterSpacingTokens;
23
+ };
24
+ };
25
+
6
26
  // ── State knob prop types (by concern) ───────────────────────────────────
7
27
 
8
28
  export interface ControlStateProps {
@@ -216,6 +236,12 @@ export interface KnobProps {
216
236
  shadowRadius: number;
217
237
  } & CornerSmoothingCarrier;
218
238
  inputSurface: { borderWidth: number };
239
+ /**
240
+ * LC-148: the `borderWidth` stop the edges above draw (outlined + none is
241
+ * `small`). The widths alone can't name it: on web `small` and `medium` both
242
+ * draw 1px and differ only in colour.
243
+ */
244
+ borderWidthStop: Knobs["borderWidth"];
219
245
  /** Container inset role (SP-GAP): `$space.$4` of the active scale. */
220
246
  panelPadding: { padding: string };
221
247
  /** Tight role (SP-GAP): label to control, chip to chip, icon to text. `$space.$2`. */
@@ -293,7 +319,7 @@ export interface KnobProps {
293
319
  * screens land on the moderate step; the `hero` preset (or an explicit
294
320
  * `pageTitleScale` override) re-opens the display scale for marketing.
295
321
  */
296
- pageTitle: { size: FontSizeTokens };
322
+ pageTitle: PageTitleFragment;
297
323
  /** color is set only for solid semantic-intent Buttons (error/warning/success). */
298
324
  body: { fontFamily: string; fontWeight: string; color?: ColorTokens };
299
325
  typography: {
@@ -323,6 +349,27 @@ export interface KnobProps {
323
349
  formAutofocus: string;
324
350
  }
325
351
 
352
+ // ── Selected state recipe (LC-131 SELECTED-STATE) ────────────────────
353
+
354
+ /**
355
+ * LC-131: the one drawing of "selected" every choice part merges (Chip,
356
+ * ToggleGroup item, Tabs trigger, segmented control, option row, colour
357
+ * swatch): a soft accent tint behind accent text. Colour only, so selecting
358
+ * never resizes a part: the part keeps its own border, border width and weight,
359
+ * which leaves the accent border to focus (LC-133).
360
+ */
361
+ export interface SelectedRecipe {
362
+ /** The part's frame: its fill takes the soft accent tint. */
363
+ surfaceKnobProps: { backgroundColor: string };
364
+ /** The part's label and glyphs. */
365
+ textKnobProps: { color: string };
366
+ }
367
+
368
+ /** State recipes beyond the four interaction maps. */
369
+ export interface StateRecipes {
370
+ selected: SelectedRecipe;
371
+ }
372
+
326
373
  // ── Return type ───────────────────────────────────────────────────────────
327
374
 
328
375
  export interface ResolvedKnobs {
@@ -332,6 +379,8 @@ export interface ResolvedKnobs {
332
379
  elevation: KnobRecipe<ElevationStateProps>;
333
380
  /** LC-40: resolved disabled treatment (named to avoid `disabled` prop clashes). */
334
381
  disabledState: DisabledRecipe;
382
+ /** LC-131: what a part merges while it is selected. */
383
+ state: StateRecipes;
335
384
  }
336
385
 
337
386
  export type KnobPropsOverride = (knobProps: KnobProps, knobs: Knobs) => Partial<KnobProps>;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * resolveFontTokenFamily (FZ-7742): a resolved font token names the stack the
3
+ * Tamagui config registers for it, so text no Tamagui font reaches (mermaid's
4
+ * SVG labels) can follow the bodyFont knob.
5
+ */
6
+ import { describe, expect, it, vi } from "vitest";
7
+
8
+ const { config } = vi.hoisted(() => ({ config: { current: undefined as unknown } }));
9
+
10
+ vi.mock("@tamagui/web", async (importOriginal) => ({
11
+ ...(await importOriginal<typeof import("@tamagui/web")>()),
12
+ getConfig: () => {
13
+ if (!config.current) throw new Error("no Tamagui config");
14
+ return config.current;
15
+ },
16
+ }));
17
+
18
+ import { getConfiguredFontFamily, resolveFontTokenFamily } from "./FontKnobStyles";
19
+
20
+ describe("resolveFontTokenFamily", () => {
21
+ it("reads a parsed variable family, a plain family, and a config font by key", () => {
22
+ config.current = {
23
+ fontsParsed: {
24
+ $body: { family: { val: "Inter, sans-serif" } },
25
+ $serif: { family: "Georgia, serif" },
26
+ },
27
+ fonts: { slab: { family: "'Roboto Slab', serif" } },
28
+ };
29
+ expect(resolveFontTokenFamily("$body")).toBe("Inter, sans-serif");
30
+ expect(resolveFontTokenFamily("$serif")).toBe("Georgia, serif");
31
+ expect(resolveFontTokenFamily("serif")).toBe("Georgia, serif");
32
+ expect(resolveFontTokenFamily("$slab")).toBe("'Roboto Slab', serif");
33
+ expect(getConfiguredFontFamily("serif")).toBe("Georgia, serif");
34
+ });
35
+
36
+ it("is undefined for a token the config lacks, a non-string, and no runtime", () => {
37
+ config.current = { fontsParsed: {}, fonts: {} };
38
+ expect(resolveFontTokenFamily("$pixel")).toBeUndefined();
39
+ expect(resolveFontTokenFamily(undefined)).toBeUndefined();
40
+ expect(resolveFontTokenFamily("")).toBeUndefined();
41
+ config.current = undefined;
42
+ expect(resolveFontTokenFamily("$body")).toBeUndefined();
43
+ });
44
+ });
@@ -32,6 +32,7 @@ import {
32
32
  sizedElevation,
33
33
  } from "./resolveKnobs";
34
34
  import { getActiveScaleTokens, setActiveScaleTokens } from "./scale/activeScale";
35
+ import { hairlineBorderWidth } from "./hairline";
35
36
  import { generateScale } from "./scale/generateScale";
36
37
  import { defaultScaleInputs } from "./scale/scaleInputs";
37
38
  import {
@@ -1001,19 +1002,19 @@ describe("resolveKnobs", () => {
1001
1002
  expect(knobProps.inputSurface.borderWidth).toBe(0);
1002
1003
  });
1003
1004
 
1004
- it("outlined + none still draws the hairline (small stop, 0.5)", () => {
1005
+ it("outlined + none still draws the hairline (the small stop's token)", () => {
1005
1006
  const { knobProps } = resolveKnobs(knobsWith({ fillStyle: "outlined", borderWidth: "none" }));
1006
- expect(knobProps.borderRadius.borderWidth).toBe(0.5);
1007
- expect(knobProps.surface.borderWidth).toBe(0.5);
1008
- expect(knobProps.elevatedSurface.borderWidth).toBe(0.5);
1009
- expect(knobProps.inputSurface.borderWidth).toBe(0.5);
1007
+ expect(knobProps.borderRadius.borderWidth).toBe(hairlineBorderWidth);
1008
+ expect(knobProps.surface.borderWidth).toBe(hairlineBorderWidth);
1009
+ expect(knobProps.elevatedSurface.borderWidth).toBe(hairlineBorderWidth);
1010
+ expect(knobProps.inputSurface.borderWidth).toBe(hairlineBorderWidth);
1010
1011
  });
1011
1012
 
1012
- it("outlined + small stays at the hairline, not a hidden 1px floor", () => {
1013
+ it("outlined + small stays at the hairline token, not a hidden floor", () => {
1013
1014
  const { knobProps } = resolveKnobs(
1014
1015
  knobsWith({ fillStyle: "outlined", borderWidth: "small" }),
1015
1016
  );
1016
- expect(knobProps.borderRadius.borderWidth).toBe(0.5);
1017
+ expect(knobProps.borderRadius.borderWidth).toBe(hairlineBorderWidth);
1017
1018
  });
1018
1019
 
1019
1020
  it("outlined + medium/large keep the requested width", () => {
@@ -1124,31 +1125,40 @@ describe("resolveKnobs", () => {
1124
1125
  // ── pageTitleScale (D-11 hero-H1 dial) ──────────────────────
1125
1126
 
1126
1127
  describe("pageTitleScale knob", () => {
1128
+ const compact = (step: string) => ({ fontSize: step, lineHeight: step, letterSpacing: step });
1129
+ const moderateTitle = { size: "$10", "$max-sm": compact("$9") };
1130
+ const displayTitle = { size: "$12", "$max-sm": compact("$11") };
1131
+
1127
1132
  it("defaults to the moderate page-title step ($10 = 40px)", () => {
1128
- expect(resolveKnobs(defaultKnobs).knobProps.pageTitle).toEqual({ size: "$10" });
1133
+ expect(resolveKnobs(defaultKnobs).knobProps.pageTitle).toEqual(moderateTitle);
1129
1134
  });
1130
1135
 
1131
1136
  it('"display" re-opens the hero step ($12 = 52px)', () => {
1132
- expect(resolveKnobs(knobsWith({ pageTitleScale: "display" })).knobProps.pageTitle).toEqual({
1133
- size: "$12",
1134
- });
1137
+ expect(resolveKnobs(knobsWith({ pageTitleScale: "display" })).knobProps.pageTitle).toEqual(
1138
+ displayTitle,
1139
+ );
1135
1140
  });
1136
1141
 
1137
1142
  it("an omitted knob still resolves moderate (preset constructions predate the dial)", () => {
1138
1143
  const { pageTitleScale: _omitted, ...withoutDial } = defaultKnobs;
1139
- expect(resolveKnobs(withoutDial as Knobs).knobProps.pageTitle).toEqual({ size: "$10" });
1144
+ expect(resolveKnobs(withoutDial as Knobs).knobProps.pageTitle).toEqual(moderateTitle);
1140
1145
  });
1141
1146
 
1142
1147
  it("resolvePageTitleScale is the one table both channels read", () => {
1143
- expect(resolvePageTitleScale("moderate")).toEqual({ size: "$10" });
1144
- expect(resolvePageTitleScale("display")).toEqual({ size: "$12" });
1148
+ expect(resolvePageTitleScale("moderate")).toEqual(moderateTitle);
1149
+ expect(resolvePageTitleScale("display")).toEqual(displayTitle);
1150
+ });
1151
+
1152
+ it("drops one token below $sm, for both steps, as type geometry only (LC-143)", () => {
1153
+ expect(resolvePageTitleScale("moderate")["$max-sm"]).toEqual(compact("$9"));
1154
+ expect(resolvePageTitleScale("display")["$max-sm"]).toEqual(compact("$11"));
1145
1155
  });
1146
1156
 
1147
1157
  it("product presets ship moderate; `hero` is the only display opt-in", () => {
1148
1158
  expect(defaultPreset.knobs.pageTitleScale).toBe("moderate");
1149
1159
  expect(boldPreset.knobs.pageTitleScale).toBe("moderate");
1150
1160
  expect(heroPreset.knobs.pageTitleScale).toBe("display");
1151
- expect(resolveKnobs(heroPreset.knobs).knobProps.pageTitle).toEqual({ size: "$12" });
1161
+ expect(resolveKnobs(heroPreset.knobs).knobProps.pageTitle).toEqual(displayTitle);
1152
1162
  });
1153
1163
 
1154
1164
  it("`hero` differs from the default preset in nothing but the title step", () => {
@@ -1239,7 +1249,7 @@ describe("house decision knobs", () => {
1239
1249
  it("defaultKnobs use recommended house defaults", () => {
1240
1250
  expect(defaultKnobs.fieldLabelPlacement).toBe(FieldLabelPlacement.Top);
1241
1251
  expect(defaultKnobs.requiredMarking).toBe(RequiredMarking.Minority);
1242
- expect(defaultKnobs.tableZebra).toBe(TableZebra.Off);
1252
+ expect(defaultKnobs.tableZebra).toBe(TableZebra.On);
1243
1253
  expect(defaultKnobs.bulkBarPlacement).toBe(BulkBarPlacement.Top);
1244
1254
  expect(defaultKnobs.selectAllScope).toBe(SelectAllScope.Page);
1245
1255
  expect(defaultKnobs.timestampStyle).toBe(TimestampStyle.Absolute);
@@ -1251,7 +1261,7 @@ describe("house decision knobs", () => {
1251
1261
  const { knobProps } = resolveKnobs(defaultKnobs);
1252
1262
  expect(knobProps.fieldLabelPlacement).toBe("top");
1253
1263
  expect(knobProps.requiredMarking).toBe("minority");
1254
- expect(knobProps.tableZebra).toBe("off");
1264
+ expect(knobProps.tableZebra).toBe("on");
1255
1265
  expect(knobProps.bulkBarPlacement).toBe("top");
1256
1266
  expect(knobProps.selectAllScope).toBe("page");
1257
1267
  expect(knobProps.timestampStyle).toBe("absolute");
@@ -1264,7 +1274,7 @@ describe("house decision knobs", () => {
1264
1274
  knobsWith({
1265
1275
  fieldLabelPlacement: "floating",
1266
1276
  requiredMarking: "optional",
1267
- tableZebra: "on",
1277
+ tableZebra: "off",
1268
1278
  bulkBarPlacement: "bottom",
1269
1279
  selectAllScope: "filtered",
1270
1280
  timestampStyle: "relative",
@@ -1274,7 +1284,7 @@ describe("house decision knobs", () => {
1274
1284
  );
1275
1285
  expect(knobProps.fieldLabelPlacement).toBe("floating");
1276
1286
  expect(knobProps.requiredMarking).toBe("optional");
1277
- expect(knobProps.tableZebra).toBe("on");
1287
+ expect(knobProps.tableZebra).toBe("off");
1278
1288
  expect(knobProps.bulkBarPlacement).toBe("bottom");
1279
1289
  expect(knobProps.selectAllScope).toBe("filtered");
1280
1290
  expect(knobProps.timestampStyle).toBe("relative");
@@ -1287,7 +1297,7 @@ describe("house decision knobs", () => {
1287
1297
  const housey = resolveKnobs(
1288
1298
  knobsWith({
1289
1299
  fieldLabelPlacement: "side",
1290
- tableZebra: "on",
1300
+ tableZebra: "off",
1291
1301
  formAutofocus: "on",
1292
1302
  }),
1293
1303
  );