@multiplatform.one/theme 7.63.3 → 7.64.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/theme",
3
- "version": "7.63.3",
3
+ "version": "7.64.0",
4
4
  "description": "Tamagui theme system for multiplatform.one",
5
5
  "keywords": [
6
6
  "multiplatform",
@@ -72,8 +72,8 @@
72
72
  "access": "public"
73
73
  },
74
74
  "dependencies": {
75
- "@multiplatform.one/platform": "^7.63.3",
76
- "@multiplatform.one/store": "^7.63.3",
75
+ "@multiplatform.one/platform": "^7.64.0",
76
+ "@multiplatform.one/store": "^7.64.0",
77
77
  "@tamagui/animations-react-native": "2.7.6",
78
78
  "@tamagui/colors": "2.7.6",
79
79
  "@tamagui/config": "2.7.6",
@@ -89,8 +89,8 @@
89
89
  },
90
90
  "devDependencies": {
91
91
  "@expo/prebuild-config": "55.0.18",
92
- "@multiplatform.one/config": "7.63.3",
93
- "@multiplatform.one/test-utils": "7.63.3",
92
+ "@multiplatform.one/config": "7.64.0",
93
+ "@multiplatform.one/test-utils": "7.64.0",
94
94
  "@tamagui/animations-css": "2.7.6",
95
95
  "@tamagui/animations-reanimated": "2.7.6",
96
96
  "@tamagui/font-inter": "2.7.6",
@@ -0,0 +1,96 @@
1
+ /**
2
+ * FZ-7944 round 2, LC-71: an overlay's open focus paints a ring only when the
3
+ * open came from the keyboard. Any other open lands on the frame, except a
4
+ * mouse open with a first text field, which takes the caret.
5
+ */
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { ensureKeyboardModalityTracking, focusOnOpen } from "./keyboardFocusRing";
8
+
9
+ function pointer(pointerType: "mouse" | "touch" | "pen") {
10
+ const event = new Event("pointerdown", { bubbles: true });
11
+ Object.defineProperty(event, "pointerType", { value: pointerType });
12
+ document.body.dispatchEvent(event);
13
+ }
14
+
15
+ function key() {
16
+ document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
17
+ }
18
+
19
+ function overlay(tag: "button" | "input", type?: string) {
20
+ const frame = document.createElement("div");
21
+ frame.tabIndex = -1;
22
+ const target = document.createElement(tag);
23
+ if (type) target.setAttribute("type", type);
24
+ frame.append(target);
25
+ document.body.append(frame);
26
+ const focus = { frame: vi.spyOn(frame, "focus"), target: vi.spyOn(target, "focus") };
27
+ return { frame, target, focus };
28
+ }
29
+
30
+ describe("focusOnOpen (LC-71, FZ-7944)", () => {
31
+ beforeEach(() => ensureKeyboardModalityTracking());
32
+ afterEach(() => {
33
+ document.body.replaceChildren();
34
+ });
35
+
36
+ it("a keyboard open focuses the first control and paints its ring", () => {
37
+ key();
38
+ const { frame, target, focus } = overlay("button");
39
+ expect(focusOnOpen(target, frame)).toBe(target);
40
+ expect(focus.target).toHaveBeenCalledWith({ preventScroll: true, focusVisible: true });
41
+ });
42
+
43
+ it("a keyboard open still puts the caret in the first field", () => {
44
+ key();
45
+ const { frame, target, focus } = overlay("input");
46
+ expect(focusOnOpen(target, frame)).toBe(target);
47
+ expect(focus.target).toHaveBeenCalledWith({ preventScroll: true, focusVisible: true });
48
+ });
49
+
50
+ it("a mouse open lands on the frame, so the first button never rings", () => {
51
+ pointer("mouse");
52
+ const { frame, target, focus } = overlay("button");
53
+ expect(focusOnOpen(target, frame)).toBe(frame);
54
+ expect(focus.target).not.toHaveBeenCalled();
55
+ expect(focus.frame).toHaveBeenCalledWith({ preventScroll: true, focusVisible: false });
56
+ });
57
+
58
+ it("a mouse open puts the caret in a first text field", () => {
59
+ pointer("mouse");
60
+ const { frame, target, focus } = overlay("input");
61
+ expect(focusOnOpen(target, frame)).toBe(target);
62
+ expect(focus.target).toHaveBeenCalledWith({ preventScroll: true, focusVisible: false });
63
+ });
64
+
65
+ it("a touch open lands on the frame, not a text field, so no keyboard rises", () => {
66
+ pointer("touch");
67
+ const { frame, target, focus } = overlay("input");
68
+ expect(focusOnOpen(target, frame)).toBe(frame);
69
+ expect(focus.target).not.toHaveBeenCalled();
70
+ expect(focus.frame).toHaveBeenCalledWith({ preventScroll: true, focusVisible: false });
71
+ });
72
+
73
+ it("a touch open lands on the frame for a non-text control too", () => {
74
+ pointer("touch");
75
+ const { frame, target, focus } = overlay("input", "checkbox");
76
+ expect(focusOnOpen(target, frame)).toBe(frame);
77
+ expect(focus.target).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it("a frame without a tabindex is made focusable, so it can take the open", () => {
81
+ pointer("mouse");
82
+ const { frame, target } = overlay("button");
83
+ frame.removeAttribute("tabindex");
84
+ expect(focusOnOpen(target, frame)).toBe(frame);
85
+ expect(frame.getAttribute("tabindex")).toBe("-1");
86
+ expect(document.activeElement).toBe(frame);
87
+ });
88
+
89
+ it("with nothing focusable the frame takes focus", () => {
90
+ pointer("mouse");
91
+ const frame = document.createElement("div");
92
+ frame.tabIndex = -1;
93
+ document.body.append(frame);
94
+ expect(focusOnOpen(null, frame)).toBe(frame);
95
+ });
96
+ });
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  let lastInputWasKeyboard = false;
14
+ let lastPointerWasTouch = false;
14
15
  let hooked = false;
15
16
 
16
17
  /**
@@ -22,7 +23,14 @@ export function ensureKeyboardModalityTracking() {
22
23
  if (hooked || typeof document === "undefined") return;
23
24
  hooked = true;
24
25
  document.addEventListener("keydown", () => (lastInputWasKeyboard = true), true);
25
- document.addEventListener("pointerdown", () => (lastInputWasKeyboard = false), true);
26
+ document.addEventListener(
27
+ "pointerdown",
28
+ (event) => {
29
+ lastInputWasKeyboard = false;
30
+ lastPointerWasTouch = event.pointerType === "touch";
31
+ },
32
+ true,
33
+ );
26
34
  }
27
35
 
28
36
  /**
@@ -36,6 +44,35 @@ export function wasKeyboardFocus(): boolean {
36
44
  return lastInputWasKeyboard;
37
45
  }
38
46
 
47
+ function isTextEntry(node: HTMLElement): boolean {
48
+ if (node.isContentEditable || node.tagName === "TEXTAREA") return true;
49
+ if (node.tagName !== "INPUT") return false;
50
+ const type = (node as HTMLInputElement).type;
51
+ return !["button", "checkbox", "color", "file", "radio", "range", "reset", "submit"].includes(
52
+ type,
53
+ );
54
+ }
55
+
56
+ /**
57
+ * An overlay's open focus (LC-71, FZ-7944). Only a keyboard open lands on a
58
+ * control and rings it. Any other open (pointer, touch, mounted open) lands on
59
+ * the frame, which paints no ring: Chromium ignores `focusVisible: false` and
60
+ * rings a control focused before any input. A mouse open still puts the caret
61
+ * in a first text field, which shows LC-133's border; a touch open does not,
62
+ * so the keyboard does not rise over a sheet the user only looked at. A frame
63
+ * without a tabindex gets -1, or its focus() is a no-op and the browser's
64
+ * own pick stays. Returns the node that took focus.
65
+ */
66
+ export function focusOnOpen(target: HTMLElement | null, frame: HTMLElement): HTMLElement {
67
+ ensureKeyboardModalityTracking();
68
+ const keyboard = lastInputWasKeyboard;
69
+ const caret = target !== null && isTextEntry(target) && !lastPointerWasTouch;
70
+ const node = target && (keyboard || caret) ? target : frame;
71
+ if (node === frame && !frame.hasAttribute("tabindex")) frame.tabIndex = -1;
72
+ node.focus({ preventScroll: true, focusVisible: keyboard } as FocusOptions);
73
+ return node;
74
+ }
75
+
39
76
  import { FOCUS_VISIBLE_RING_INSET } from "./theme/focusState";
40
77
 
41
78
  /**
@@ -700,6 +700,55 @@ const childrenHues = [
700
700
  "gray",
701
701
  ] as const;
702
702
 
703
+ /**
704
+ * FZ-7944 round 2, LC-71: the keyboard ring is the accent, never a white or
705
+ * black neutral. A hue scope rings in its own accent ink. The roots, whose
706
+ * house accent is the neutral inverse, ring in the blue scope's ink, the
707
+ * default accent on Apple platforms. Solid accent sub-themes keep the on-fill
708
+ * ring they paint for content inside the fill.
709
+ */
710
+ function holdFocusRingAccent(
711
+ themes: Record<string, Record<string, string>>,
712
+ ): Record<string, Record<string, string>> {
713
+ const out: Record<string, Record<string, string>> = { ...themes };
714
+ for (const scheme of ["light", "dark"] as const) {
715
+ const base = themes[scheme];
716
+ if (!base) continue;
717
+ const grounds = [base.color1, base.color2].map(luminanceOf);
718
+ if (grounds.some((lum) => lum === null)) continue;
719
+ const holds = (color: string | undefined): color is string => {
720
+ const lum = luminanceOf(color);
721
+ return (
722
+ lum !== null &&
723
+ isChromatic(color) &&
724
+ grounds.every((ground) => contrastRatio(lum, ground as number) >= minContrastRatio)
725
+ );
726
+ };
727
+ const rootRing = [base.accentColor, themes[`${scheme}_blue`]?.accentColor].find(holds);
728
+ for (const [name, theme] of Object.entries(themes)) {
729
+ if (name !== scheme && !name.startsWith(`${scheme}_`)) continue;
730
+ if (theme.outlineColor === undefined) continue;
731
+ const parts = name.split("_");
732
+ if (parts.includes("accent")) continue;
733
+ const hue = (childrenHues as readonly string[]).includes(parts[1] ?? "")
734
+ ? parts[1]
735
+ : undefined;
736
+ const scopeInk = hue ? themes[`${scheme}_${hue}`]?.accentColor : undefined;
737
+ const ring = holds(scopeInk) ? scopeInk : rootRing;
738
+ if (!ring || ring === theme.outlineColor) continue;
739
+ out[name] = { ...theme, outlineColor: ring };
740
+ }
741
+ }
742
+ return out;
743
+ }
744
+
745
+ function isChromatic(color: string | undefined): boolean {
746
+ const hex = color ? normalizeToHex(color) : null;
747
+ if (!hex) return false;
748
+ const channels = [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16));
749
+ return Math.max(...channels) - Math.min(...channels) >= 24;
750
+ }
751
+
703
752
  export function createThemesBuilder(
704
753
  baseTheme: BaseThemeBuilderDefinition,
705
754
  accentTheme: ThemeBuilderDefinition,
@@ -769,12 +818,14 @@ export function createThemesBuilder(
769
818
  const initialTheme = tamaguiCreateThemes(
770
819
  createThemesProps as Parameters<typeof tamaguiCreateThemes>[0],
771
820
  );
772
- const themes = holdColorThemeAccent(
773
- holdSwitchTrack(
774
- holdSyntaxFloor(
775
- holdNamedInkFloor(
776
- holdSelectedState(
777
- holdAccentFloor(neutralizeSemanticEntryText(neutralizeTintText(initialTheme))),
821
+ const themes = holdFocusRingAccent(
822
+ holdColorThemeAccent(
823
+ holdSwitchTrack(
824
+ holdSyntaxFloor(
825
+ holdNamedInkFloor(
826
+ holdSelectedState(
827
+ holdAccentFloor(neutralizeSemanticEntryText(neutralizeTintText(initialTheme))),
828
+ ),
778
829
  ),
779
830
  ),
780
831
  ),
@@ -28,13 +28,13 @@ import { defaultKnobs } from "./knobs";
28
28
  import { resolveKnobs } from "./resolveKnobs";
29
29
 
30
30
  describe("focusState tokens (DG-A11Y-01 / DG-STATE-01 / W12)", () => {
31
- it("FOCUS_VISIBLE_RING meets ≥2px solid outline contract at offset 0 (F1)", () => {
31
+ it("FOCUS_VISIBLE_RING meets ≥2px solid outline contract at offset +2 (FZ-7944)", () => {
32
32
  expect(FOCUS_VISIBLE_RING.outlineWidth).toBeGreaterThanOrEqual(FOCUS_RING_MIN_WIDTH);
33
33
  expect(FOCUS_VISIBLE_RING.outlineStyle).toBe("solid");
34
34
  expect(FOCUS_VISIBLE_RING.outlineColor).toBe("$outlineColor");
35
- // Board verify pass F1: offset 0 — not −2 (band inside the fill, where it
36
- // measured 1.17–1.31:1 on solid intents) and not +2 (a gapped halo).
37
- expect(FOCUS_VISIBLE_RING.outlineOffset).toBe(0);
35
+ // Not −2 (band inside the fill, 1.17–1.31:1 on solid intents). FZ-7944
36
+ // round 2: a 2px gap, the macOS and iPadOS keyboard ring.
37
+ expect(FOCUS_VISIBLE_RING.outlineOffset).toBe(2);
38
38
  expect(FOCUS_RING_MIN_CONTRAST).toBeGreaterThanOrEqual(3);
39
39
  });
40
40
 
@@ -121,14 +121,14 @@ describe("$outlineColor token contrast (DF-03 / DG-A11Y-01)", () => {
121
121
  }
122
122
 
123
123
  // MPO-41 — assert the RATIO on the surface the band actually draws on, not
124
- // just the declaration. At offset 0 the band paints OUTSIDE the border box,
124
+ // just the declaration. At offset +2 the band paints OUTSIDE the border box,
125
125
  // on the page ground behind the control; intent Buttons pin the parent-scope
126
126
  // `$outlineColor` literal on the ring (LC-51), so the pair that must clear
127
127
  // the floor is base `outlineColor` × base ground — for accent and error
128
128
  // controls alike, in both schemes.
129
129
  for (const scheme of ["light", "dark"] as const) {
130
130
  for (const intent of ["accent", "error"] as const) {
131
- it(`${scheme} ${intent}: offset-0 ring clears ≥3:1 on the page ground it draws on`, () => {
131
+ it(`${scheme} ${intent}: the ring clears ≥3:1 on the page ground it draws on`, () => {
132
132
  const base = themes[scheme];
133
133
  expect(themes[`${scheme}_${intent}`]).toBeTruthy();
134
134
  const ring = normalizeToHex(base.outlineColor);
@@ -175,21 +175,21 @@ describe("the ring still paints inside a clipped / fused group (LC-71 §4 inset
175
175
  expect(keyboardFocusRingProps).toEqual(FOCUS_VISIBLE_RING_INSET);
176
176
  });
177
177
 
178
- it("ensureFocusVisibleRing honors the explicit per-call-site inset and defaults to 0", () => {
178
+ it("ensureFocusVisibleRing honors the explicit per-call-site inset and defaults to +2", () => {
179
179
  expect(ensureFocusVisibleRing({ outlineOffset: -2 }).outlineOffset).toBe(-2);
180
- expect(ensureFocusVisibleRing({ outlineWidth: 3 }).outlineOffset).toBe(0);
181
- expect(ensureFocusVisibleRing().outlineOffset).toBe(0);
180
+ expect(ensureFocusVisibleRing({ outlineWidth: 3 }).outlineOffset).toBe(2);
181
+ expect(ensureFocusVisibleRing().outlineOffset).toBe(2);
182
182
  });
183
183
  });
184
184
 
185
185
  describe("LC-71: keyboard-only; measure the ring / text node, not the frame", () => {
186
- it("standalone ring paints a 2px band on the ring-carrying node's box at offset 0", () => {
186
+ it("standalone ring paints a 2px band 2px clear of the ring-carrying node's box", () => {
187
187
  // Measure the node that paints the outline (the ring carrier / text node),
188
188
  // never an ancestor Frame. Band = [offset, offset + width] from THAT box.
189
189
  const { outlineOffset, outlineWidth } = FOCUS_VISIBLE_RING;
190
190
  expect(outlineWidth).toBe(2);
191
- expect(outlineOffset).toBe(0);
192
- expect(outlineOffset + outlineWidth).toBe(2);
191
+ expect(outlineOffset).toBe(2);
192
+ expect(outlineOffset + outlineWidth).toBe(4);
193
193
  });
194
194
 
195
195
  it("chip dismiss rings the glyph node, not the 44px hit-box frame", () => {
@@ -287,10 +287,10 @@ describe("theme DEV warn (outline-none / positive-tabindex)", () => {
287
287
  * shipping and being found in a later audit.
288
288
  */
289
289
  describe("LC-71 §4: the sanctioned focus-ring offset set is closed (MPO-92)", () => {
290
- it("is exactly three offsets — clipping inset, default, shape halo", () => {
290
+ it("is exactly three offsets — clipping inset, flush, default", () => {
291
291
  expect([...SANCTIONED_FOCUS_RING_OFFSETS]).toEqual([-2, 0, 2]);
292
292
  expect(FOCUS_RING_CLIPPED_OFFSET).toBe(-2);
293
- expect(FOCUS_VISIBLE_RING.outlineOffset).toBe(0);
293
+ expect(FOCUS_VISIBLE_RING.outlineOffset).toBe(2);
294
294
  expect(FOCUS_RING_HALO_OFFSET).toBe(2);
295
295
  });
296
296
 
@@ -312,7 +312,7 @@ describe("LC-71 §4: the sanctioned focus-ring offset set is closed (MPO-92)", (
312
312
  });
313
313
  // The band spans [outlineOffset, outlineOffset + outlineWidth] from the
314
314
  // border edge — here [2, 4]. Inner edge ≥ 0 means it paints entirely on the
315
- // surface BEHIND the control, exactly like the default offset 0, so it
315
+ // surface BEHIND the control, exactly like the default ring, so it
316
316
  // measures against the page ground and cannot reproduce the 1.17–1.31:1
317
317
  // inside-the-fill failure MPO-41 retired.
318
318
  expect(FOCUS_VISIBLE_RING_HALO.outlineOffset).toBeGreaterThanOrEqual(0);
@@ -48,31 +48,26 @@ export function focusRingColorMeetsContrast(ringColor: string, surfaceColor: str
48
48
 
49
49
  /**
50
50
  * ONE ring geometry (DG-A11Y-01 / LC-71 §4): 2px solid `$outlineColor` at
51
- * offset **0** — the band hugs the border box and paints OUTSIDE it, on the
52
- * surface behind the control (reference §10; board F1 verify pass: offset 0,
53
- * not −2 and not +2). Offset 0 is what makes the ≥3:1 floor computable: the
54
- * ring contrasts against the page ground, not the control's own fill. The
55
- * old flat inset (−2) painted the band INSIDE solid intent fills and sank to
56
- * 1.17–1.31:1 (light accent 1.27, light error 1.17, dark error 1.31) where
57
- * the same ring on the page ground clears 5.78–6.50:1 (MPO-41 / MPO-19 X7).
51
+ * offset **+2**, a band clear of the border box with a 2px gap, painted on the
52
+ * surface behind the control. FZ-7944 round 2 moved the default from 0 to +2
53
+ * (the macOS and iPadOS keyboard ring); the ≥3:1 floor stays computable
54
+ * because the band still paints on the page ground, never inside a fill. The
55
+ * old flat inset (−2) painted INSIDE solid intent fills and sank to
56
+ * 1.17–1.31:1 (MPO-41 / MPO-19 X7).
58
57
  *
59
- * The inset did not die — it moved to `FOCUS_VISIBLE_RING_INSET`, an
60
- * explicit per-call-site carve-out for clipping ancestors. MPO-19 X7 names
61
- * the same offset `FOCUS_RING_CLIPPED_OFFSET` so call sites do not type `-2`.
62
- *
63
- * MPO-92 ratified a second, shape-scoped carve-out on the other side:
64
- * `FOCUS_VISIBLE_RING_HALO` (+2) for pill/circular thumbs and inline text
65
- * links. The F1 "not +2" reading was about the DEFAULT — a blanket +2 on
66
- * every control — and it stands. The halo is a named carve-out with a written
67
- * shape scope, exactly as the inset is; `SANCTIONED_FOCUS_RING_OFFSETS` below
68
- * is the closed set of all three.
58
+ * Two carve-outs stay named: `FOCUS_VISIBLE_RING_INSET` (−2) for clipping
59
+ * ancestors, and `FOCUS_RING_FLUSH_OFFSET` (0) for rows and cells that sit
60
+ * edge to edge inside a padded overlay, where a gapped band would collide
61
+ * with the neighbour. `SANCTIONED_FOCUS_RING_OFFSETS` is the closed set.
69
62
  */
70
63
  export const FOCUS_RING_CLIPPED_OFFSET = -2;
64
+ export const FOCUS_RING_FLUSH_OFFSET = 0;
65
+ export const FOCUS_RING_HALO_OFFSET = 2;
71
66
  export const FOCUS_VISIBLE_RING = {
72
67
  outlineWidth: FOCUS_RING_MIN_WIDTH,
73
68
  outlineStyle: "solid" as const,
74
69
  outlineColor: "$outlineColor",
75
- outlineOffset: 0,
70
+ outlineOffset: FOCUS_RING_HALO_OFFSET,
76
71
  } satisfies ControlStateProps;
77
72
 
78
73
  /**
@@ -94,29 +89,10 @@ export const FOCUS_VISIBLE_RING_INSET = {
94
89
  } satisfies ControlStateProps;
95
90
 
96
91
  /**
97
- * The LC-71 §4 halo carve-out (MPO-92, ratified 2026-09-02): the SAME ring,
98
- * pushed 2px clear of the border box, for the two shapes where a band hugging
99
- * the edge reads as part of the glyph instead of around it.
100
- *
101
- * Scope is by SHAPE, not by instance, and it is closed at two:
102
- * - **pill / circular thumbs** — a round handle the user drags (Slider
103
- * thumb, Rating star, TimeRangeScrubber handle). At offset 0 the band is
104
- * concentric with the glyph's own edge and reads as a thicker glyph.
105
- * - **inline text links** — a link inside a run of prose (markdown/mdx
106
- * `Link`, ErrorSummary's summary link, backoffice "View All"). At offset 0
107
- * the band crosses the line box and collides with ascenders/descenders of
108
- * the surrounding text.
109
- *
110
- * NOT for standalone rectangles on the page ground — cards, tiles, rows,
111
- * fields. Those are the default `FOCUS_VISIBLE_RING` (offset 0), which is
112
- * what makes the Axiom 12 ≥3:1 floor computable against the page ground.
113
- *
114
- * Contrast is unaffected: like offset 0 the halo paints OUTSIDE the border
115
- * box, on the surface behind the control, so it measures against the same
116
- * page ground the default does — the 1.17–1.31:1 failures MPO-41 retired were
117
- * the INSET painting inside solid intent fills, a defect the halo cannot have.
92
+ * The halo (MPO-92): pill and circular thumbs and inline text links. Since
93
+ * FZ-7944 round 2 it equals the default ring; the name stays so those call
94
+ * sites keep saying why they hold +2 if the default ever moves again.
118
95
  */
119
- export const FOCUS_RING_HALO_OFFSET = 2;
120
96
  export const FOCUS_VISIBLE_RING_HALO = {
121
97
  ...FOCUS_VISIBLE_RING,
122
98
  outlineOffset: FOCUS_RING_HALO_OFFSET,
@@ -124,7 +100,7 @@ export const FOCUS_VISIBLE_RING_HALO = {
124
100
 
125
101
  /**
126
102
  * The CLOSED set of sanctioned focus-ring offsets (LC-71 §4). Three, and only
127
- * three: the default 0, the clipping inset −2, the shape halo +2. A call site
103
+ * three: the clipping inset −2, the flush 0, the default +2. A call site
128
104
  * that wants any other number is a rule gap, not a preference — file it and
129
105
  * amend §4, do not type the number. `focusState.spec.ts` asserts this set is
130
106
  * exactly what the repo paints, so a fourth offset fails a test instead of
@@ -132,8 +108,8 @@ export const FOCUS_VISIBLE_RING_HALO = {
132
108
  */
133
109
  export const SANCTIONED_FOCUS_RING_OFFSETS = [
134
110
  FOCUS_RING_CLIPPED_OFFSET,
111
+ FOCUS_RING_FLUSH_OFFSET,
135
112
  FOCUS_VISIBLE_RING.outlineOffset,
136
- FOCUS_RING_HALO_OFFSET,
137
113
  ] as const;
138
114
 
139
115
  /** True when `offset` is one of the three offsets LC-71 §4 sanctions. */
@@ -50,7 +50,7 @@ describe("LC-148 HAIRLINE-TOKEN: borderWidth small is the theme's hairline", ()
50
50
  controlBorderOutlined: "$borderColorHairline",
51
51
  elevatedSurface: "$borderColorHairline",
52
52
  });
53
- expect(edges({ borderWidth: "small" }, "dark").elevatedSurface).toBe("$color5");
53
+ expect(edges({ borderWidth: "small" }, "dark").elevatedSurface).toBe("transparent");
54
54
  expect(edges({ borderWidth: "none", fillStyle: "outlined" }).borderRadius).toBe(
55
55
  "$borderColorHairline",
56
56
  );
@@ -64,7 +64,7 @@ describe("LC-148 HAIRLINE-TOKEN: borderWidth small is the theme's hairline", ()
64
64
  controlBorderOutlined: "$borderColor",
65
65
  elevatedSurface: "$borderColor",
66
66
  });
67
- expect(edges({ borderWidth }, "dark").elevatedSurface).toBe("$color6");
67
+ expect(edges({ borderWidth }, "dark").elevatedSurface).toBe("transparent");
68
68
  }
69
69
  });
70
70
 
@@ -1,6 +1,12 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import path from "node:path";
1
3
  import { describe, expect, it } from "vitest";
4
+ import { GAP_LG_KNOB } from "../audit/constraintAudit";
2
5
  import { animationConfig, animationDurations, motionStops } from "./animations/css";
6
+ import { transitionProps } from "./transitionProps";
7
+ import { transitionProps as nativeTransitionProps } from "./transitionProps.native";
3
8
  import {
9
+ motionExitFactor,
4
10
  motionFor,
5
11
  motionDurationMs,
6
12
  motionSeconds,
@@ -82,3 +88,134 @@ describe("motion durations", () => {
82
88
  expect(motionSettleMs(["quick", { opacity: "slow" }])).toBe(980);
83
89
  });
84
90
  });
91
+
92
+ const bouncyStops = ["bouncy", "superBouncy"];
93
+ const nonBouncy = motionStops.filter((stop) => !bouncyStops.includes(stop));
94
+ const ms = (stop: unknown) => animationDurations[stop as keyof typeof animationDurations];
95
+
96
+ function bezierYs(timing: string): number[] {
97
+ const match = timing.match(/cubic-bezier\(([^)]+)\)/);
98
+ if (!match) return [];
99
+ const [, y1, , y2] = match[1].split(",").map(Number);
100
+ return [y1, y2];
101
+ }
102
+
103
+ const publicRoot = path.resolve(__dirname, "../../..");
104
+
105
+ function sources(dir: string, out: string[] = []): string[] {
106
+ for (const name of readdirSync(dir)) {
107
+ if (name === "node_modules" || name === "dist" || name === "types") continue;
108
+ const file = path.join(dir, name);
109
+ if (statSync(file).isDirectory()) sources(file, out);
110
+ else if (/\.tsx?$/.test(name) && !/\.(spec|test|stories)\.tsx?$|\.d\.ts$/.test(name)) {
111
+ out.push(file);
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function enterExitObjects(source: string): string[] {
118
+ const objects: string[] = [];
119
+ for (const match of source.matchAll(
120
+ /\b(?:enterStyle|exitStyle)\s*(?:=\{|:)\s*(?:[^{}?]*\?\s*)?(\{[^{}]*\}|[A-Za-z_$][\w$]*)/g,
121
+ )) {
122
+ const value = match[1];
123
+ if (value.startsWith("{")) objects.push(value);
124
+ else {
125
+ const declared = source.match(
126
+ new RegExp(`\\b(?:const|let)\\s+${value}\\s*=\\s*(\\{[^{}]*\\})`),
127
+ );
128
+ if (declared) objects.push(declared[1]);
129
+ }
130
+ }
131
+ return objects;
132
+ }
133
+
134
+ function numbersIn(expression: string, source: string): number[] {
135
+ const values = [...expression.matchAll(/-?\d+(?:\.\d+)?/g)].map((m) => Math.abs(Number(m[0])));
136
+ for (const [identifier] of expression.matchAll(/[A-Za-z_$][\w$]*/g)) {
137
+ const declared = source.match(new RegExp(`\\bconst\\s+${identifier}\\s*=\\s*([^;\\n]+)`));
138
+ if (declared && !/[{(]/.test(declared[1])) {
139
+ values.push(
140
+ ...[...declared[1].matchAll(/-?\d+(?:\.\d+)?/g)].map((m) => Math.abs(Number(m[0]))),
141
+ );
142
+ }
143
+ }
144
+ return values;
145
+ }
146
+
147
+ describe("LC-178 MOTION-CHARACTER: quick in, quicker out, short and still", () => {
148
+ it("exits every role faster than it enters, about two thirds of the enter duration", () => {
149
+ for (const stop of motionStops) {
150
+ for (const [name, role] of Object.entries(motionFor(stop))) {
151
+ const enter = ms(role.transition);
152
+ const exit = ms(role.exit);
153
+ const shorter = nonBouncy.filter((candidate) => ms(candidate) < enter);
154
+ if (shorter.length === 0) {
155
+ expect(role.exit, `${stop}.${name}`).toBe(role.transition);
156
+ continue;
157
+ }
158
+ expect(exit, `${stop}.${name} exit`).toBeLessThan(enter);
159
+ const target = enter * motionExitFactor;
160
+ const best = Math.min(...shorter.map((candidate) => Math.abs(ms(candidate) - target)));
161
+ expect(Math.abs(exit - target), `${stop}.${name} exit near 2/3`).toBe(best);
162
+ }
163
+ }
164
+ for (const role of Object.values(motionFor("none"))) expect(role.exit).toBeUndefined();
165
+ });
166
+
167
+ it("never picks a bouncy stop, so no role overshoots opacity", () => {
168
+ for (const stop of motionStops) {
169
+ for (const [name, role] of Object.entries(motionFor(stop))) {
170
+ const presetBounce = name === "control" && bouncyStops.includes(stop);
171
+ for (const leg of presetBounce ? [role.exit] : [role.transition, role.exit]) {
172
+ expect(bouncyStops, `${stop}.${name}`).not.toContain(leg);
173
+ for (const y of bezierYs(animationConfig[leg as keyof typeof animationConfig])) {
174
+ expect(y, `${stop}.${name} ${String(leg)} curve`).toBeGreaterThanOrEqual(0);
175
+ expect(y, `${stop}.${name} ${String(leg)} curve`).toBeLessThanOrEqual(1);
176
+ }
177
+ }
178
+ }
179
+ }
180
+ });
181
+
182
+ it("hands the exit stop to the driver, and native springs clamp their overshoot", () => {
183
+ const { overlay } = motionFor("quick");
184
+ expect(transitionProps(overlay).transition).toEqual(["quicker", { exit: "quickest" }]);
185
+ expect(nativeTransitionProps(overlay).transition).toEqual([
186
+ "quicker",
187
+ { overshootClamping: true, exit: "quickest" },
188
+ ]);
189
+ const shortest = motionFor("quickestLessBouncy").control;
190
+ expect(nativeTransitionProps(shortest).transition).toEqual([
191
+ "quickestLessBouncy",
192
+ { overshootClamping: true },
193
+ ]);
194
+ });
195
+
196
+ it("keeps every enter and exit offset in public/* within gapLg and its opacity in [0, 1]", () => {
197
+ const limit = GAP_LG_KNOB.gapLg;
198
+ expect(limit).toBeGreaterThan(0);
199
+ const travel: string[] = [];
200
+ const opacity: string[] = [];
201
+ let seen = 0;
202
+ for (const file of sources(publicRoot)) {
203
+ const source = readFileSync(file, "utf8");
204
+ for (const object of enterExitObjects(source)) {
205
+ seen += 1;
206
+ const where = `${path.relative(publicRoot, file)}: ${object}`;
207
+ for (const [, axis, expression] of object.matchAll(/\b([xy])\s*:\s*([^,}]+)/g)) {
208
+ if (numbersIn(expression, source).some((value) => value > limit)) {
209
+ travel.push(`${where} (${axis} past gapLg ${limit})`);
210
+ }
211
+ }
212
+ for (const [, value] of object.matchAll(/\bopacity\s*:\s*(-?\d+(?:\.\d+)?)/g)) {
213
+ if (Number(value) < 0 || Number(value) > 1) opacity.push(where);
214
+ }
215
+ }
216
+ }
217
+ expect(seen).toBeGreaterThan(10);
218
+ expect(travel).toEqual([]);
219
+ expect(opacity).toEqual([]);
220
+ });
221
+ });