@multiplatform.one/theme 7.83.2 → 7.84.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 (106) hide show
  1. package/dist/cjs/expo.cjs +8 -1
  2. package/dist/cjs/expo.native.js +11 -1
  3. package/dist/cjs/expo.native.js.map +1 -1
  4. package/dist/cjs/fonts/createFontLoader.native.js +5 -4
  5. package/dist/cjs/fonts/index.cjs +1 -0
  6. package/dist/cjs/fonts/index.native.js +1 -0
  7. package/dist/cjs/fonts/index.native.js.map +1 -1
  8. package/dist/cjs/fonts/interFaces.cjs +38 -0
  9. package/dist/cjs/fonts/interFaces.native.js +41 -0
  10. package/dist/cjs/fonts/interFaces.native.js.map +1 -0
  11. package/dist/cjs/theme/colorRules.cjs +23 -0
  12. package/dist/cjs/theme/colorRules.native.js +29 -0
  13. package/dist/cjs/theme/colorRules.native.js.map +1 -1
  14. package/dist/cjs/theme/controlTiers.cjs +1 -1
  15. package/dist/cjs/theme/controlTiers.native.js +1 -1
  16. package/dist/cjs/theme/controlTiers.native.js.map +1 -1
  17. package/dist/cjs/theme/defaults/fonts.cjs +4 -17
  18. package/dist/cjs/theme/defaults/fonts.native.js +7 -17
  19. package/dist/cjs/theme/defaults/fonts.native.js.map +1 -1
  20. package/dist/cjs/theme/glyphTint.cjs +2 -25
  21. package/dist/cjs/theme/glyphTint.native.js +2 -31
  22. package/dist/cjs/theme/glyphTint.native.js.map +1 -1
  23. package/dist/cjs/theme/hairline.cjs +20 -3
  24. package/dist/cjs/theme/readableColor.cjs +36 -0
  25. package/dist/cjs/theme/readableColor.native.js +39 -0
  26. package/dist/cjs/theme/readableColor.native.js.map +1 -1
  27. package/dist/cjs/theme/resolveKnobs.cjs +162 -74
  28. package/dist/cjs/theme/resolveKnobs.native.js +170 -81
  29. package/dist/cjs/theme/resolveKnobs.native.js.map +1 -1
  30. package/dist/esm/expo.mjs +7 -1
  31. package/dist/esm/expo.mjs.map +1 -1
  32. package/dist/esm/expo.native.js +10 -1
  33. package/dist/esm/expo.native.js.map +1 -1
  34. package/dist/esm/fonts/createFontLoader.native.js +5 -4
  35. package/dist/esm/fonts/index.mjs +1 -0
  36. package/dist/esm/fonts/index.mjs.map +1 -1
  37. package/dist/esm/fonts/index.native.js +1 -0
  38. package/dist/esm/fonts/index.native.js.map +1 -1
  39. package/dist/esm/fonts/interFaces.mjs +12 -0
  40. package/dist/esm/fonts/interFaces.mjs.map +1 -0
  41. package/dist/esm/fonts/interFaces.native.js +12 -0
  42. package/dist/esm/fonts/interFaces.native.js.map +1 -0
  43. package/dist/esm/theme/colorRules.mjs +23 -1
  44. package/dist/esm/theme/colorRules.mjs.map +1 -1
  45. package/dist/esm/theme/colorRules.native.js +29 -1
  46. package/dist/esm/theme/colorRules.native.js.map +1 -1
  47. package/dist/esm/theme/controlTiers.mjs +1 -1
  48. package/dist/esm/theme/controlTiers.mjs.map +1 -1
  49. package/dist/esm/theme/controlTiers.native.js +1 -1
  50. package/dist/esm/theme/controlTiers.native.js.map +1 -1
  51. package/dist/esm/theme/defaults/fonts.mjs +4 -17
  52. package/dist/esm/theme/defaults/fonts.mjs.map +1 -1
  53. package/dist/esm/theme/defaults/fonts.native.js +7 -17
  54. package/dist/esm/theme/defaults/fonts.native.js.map +1 -1
  55. package/dist/esm/theme/glyphTint.mjs +2 -24
  56. package/dist/esm/theme/glyphTint.mjs.map +1 -1
  57. package/dist/esm/theme/glyphTint.native.js +2 -30
  58. package/dist/esm/theme/glyphTint.native.js.map +1 -1
  59. package/dist/esm/theme/hairline.mjs +20 -4
  60. package/dist/esm/theme/hairline.mjs.map +1 -1
  61. package/dist/esm/theme/readableColor.mjs +36 -2
  62. package/dist/esm/theme/readableColor.mjs.map +1 -1
  63. package/dist/esm/theme/readableColor.native.js +39 -2
  64. package/dist/esm/theme/readableColor.native.js.map +1 -1
  65. package/dist/esm/theme/resolveKnobs.mjs +162 -74
  66. package/dist/esm/theme/resolveKnobs.mjs.map +1 -1
  67. package/dist/esm/theme/resolveKnobs.native.js +170 -81
  68. package/dist/esm/theme/resolveKnobs.native.js.map +1 -1
  69. package/package.json +7 -7
  70. package/src/expo.ts +20 -0
  71. package/src/fonts/createFontLoader.ts +8 -4
  72. package/src/fonts/index.ts +1 -0
  73. package/src/fonts/interFaces.spec.ts +87 -0
  74. package/src/fonts/interFaces.ts +26 -0
  75. package/src/theme/accentButtonLabel.spec.ts +116 -0
  76. package/src/theme/colorRules.ts +41 -0
  77. package/src/theme/controlTiers.ts +6 -4
  78. package/src/theme/defaults/fonts.spec.ts +9 -2
  79. package/src/theme/defaults/fonts.ts +5 -8
  80. package/src/theme/devtools/ColorLineVisualizer.spec.tsx +104 -0
  81. package/src/theme/glyphTint.spec.ts +30 -2
  82. package/src/theme/glyphTint.ts +1 -47
  83. package/src/theme/hairline.spec.ts +60 -3
  84. package/src/theme/hairline.ts +35 -5
  85. package/src/theme/readableColor.ts +49 -0
  86. package/src/theme/resolveKnobs.ts +172 -101
  87. package/types/expo.d.ts +10 -0
  88. package/types/expo.d.ts.map +1 -1
  89. package/types/fonts/createFontLoader.d.ts.map +1 -1
  90. package/types/fonts/index.d.ts +1 -0
  91. package/types/fonts/index.d.ts.map +1 -1
  92. package/types/fonts/interFaces.d.ts +23 -0
  93. package/types/fonts/interFaces.d.ts.map +1 -0
  94. package/types/theme/colorRules.d.ts +6 -0
  95. package/types/theme/colorRules.d.ts.map +1 -1
  96. package/types/theme/controlTiers.d.ts +5 -3
  97. package/types/theme/controlTiers.d.ts.map +1 -1
  98. package/types/theme/defaults/fonts.d.ts +30 -30
  99. package/types/theme/defaults/fonts.d.ts.map +1 -1
  100. package/types/theme/glyphTint.d.ts +0 -6
  101. package/types/theme/glyphTint.d.ts.map +1 -1
  102. package/types/theme/hairline.d.ts +14 -3
  103. package/types/theme/hairline.d.ts.map +1 -1
  104. package/types/theme/readableColor.d.ts +19 -0
  105. package/types/theme/readableColor.d.ts.map +1 -1
  106. package/types/theme/resolveKnobs.d.ts.map +1 -1
@@ -0,0 +1,116 @@
1
+ /**
2
+ * LC-172 FILL-CONTRAST for the stock accent Button (FZ-7697). `<Button accent>`
3
+ * is `theme="accent"` on a `Button` frame, so it paints from whichever theme
4
+ * Tamagui's name walk lands on: the scope's segments from longest to shortest,
5
+ * `<base>_accent_Button` before `<base>_accent`. The house scope finds
6
+ * `<scheme>_accent_Button` (template surface2), a hue scope its
7
+ * `<scheme>_<hue>_accent` solid, and an intent scope falls back to the
8
+ * scheme's. Every scope a Button can render under (each scheme bare, every hue
9
+ * and intent scope) must give its accent Button and its bare accent sub-theme a
10
+ * label that clears WCAG AA 4.5:1 and APCA Lc 60 on its fill.
11
+ */
12
+ import { describe, expect, it, vi } from "vitest";
13
+
14
+ vi.mock("tamagui", () => ({
15
+ createTamagui: vi.fn((config: Record<string, unknown>) => config),
16
+ }));
17
+ vi.mock("./animations/index", () => ({ animations: {} }));
18
+
19
+ import {
20
+ aaTextContrastRatio,
21
+ contrastRatio,
22
+ normalizeToHex,
23
+ relativeLuminance,
24
+ } from "./colorRules";
25
+ import { createDefaultThemeConfig } from "./createDefaultThemeConfig";
26
+ import { resolveAllThemes } from "./dedupeInherited";
27
+ import { apcaContrast, fillLabelLcFloor } from "./fillContrast";
28
+ import {
29
+ boldPreset,
30
+ consolePreset,
31
+ defaultPreset,
32
+ densePreset,
33
+ heroPreset,
34
+ roomyPreset,
35
+ storefrontPreset,
36
+ } from "./presets";
37
+
38
+ const resolved = resolveAllThemes(
39
+ createDefaultThemeConfig().tamagui.themes as unknown as Record<string, Record<string, unknown>>,
40
+ );
41
+
42
+ const scopes = Object.keys(resolved).filter(
43
+ (name) => /^(light|dark)(_[a-z]+)?$/.test(name) && !/_(accent|active)$/.test(name),
44
+ );
45
+
46
+ function accentThemeName(scope: string, componentName?: string) {
47
+ const parts = scope.split("_");
48
+ for (let end = parts.length; end > 0; end--) {
49
+ const base = parts.slice(0, end).join("_");
50
+ const candidates = componentName
51
+ ? [`${base}_accent_${componentName}`, `${base}_accent`]
52
+ : [`${base}_accent`];
53
+ const found = candidates.find((name) => name in resolved);
54
+ if (found) return found;
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ function measure(name: string) {
60
+ const theme = resolved[name];
61
+ expect(theme, name).toBeDefined();
62
+ const fill = normalizeToHex(String(theme?.background));
63
+ const label = normalizeToHex(String(theme?.color));
64
+ expect(
65
+ fill && label,
66
+ `${name}: ${String(theme?.color)} on ${String(theme?.background)}`,
67
+ ).toBeTruthy();
68
+ return {
69
+ fill: fill as string,
70
+ label: label as string,
71
+ wcag: contrastRatio(relativeLuminance(fill as string), relativeLuminance(label as string)),
72
+ lc: Math.abs(apcaContrast(label as string, fill as string)),
73
+ };
74
+ }
75
+
76
+ describe("LC-172: the stock accent Button's label is readable on its fill (FZ-7697)", () => {
77
+ it("covers both schemes, the hueless house scope and every hue", () => {
78
+ expect(scopes).toEqual(expect.arrayContaining(["light", "dark", "light_blue", "dark_purple"]));
79
+ expect(scopes.length).toBeGreaterThanOrEqual(18);
80
+ });
81
+
82
+ it("covers the scope every catalog preset renders under", () => {
83
+ for (const preset of [
84
+ defaultPreset,
85
+ heroPreset,
86
+ boldPreset,
87
+ densePreset,
88
+ roomyPreset,
89
+ consolePreset,
90
+ storefrontPreset,
91
+ ]) {
92
+ for (const scheme of ["light", "dark"]) {
93
+ const scope = preset.theme ? `${scheme}_${preset.theme}` : scheme;
94
+ expect(scopes, scope).toContain(scope);
95
+ }
96
+ }
97
+ });
98
+
99
+ it("resolves the house scope's accent Button to its own component theme", () => {
100
+ expect(accentThemeName("light", "Button")).toBe("light_accent_Button");
101
+ expect(accentThemeName("dark", "Button")).toBe("dark_accent_Button");
102
+ });
103
+
104
+ for (const scope of scopes) {
105
+ for (const componentName of [undefined, "Button"]) {
106
+ const target = componentName ? `${scope} accent ${componentName}` : `${scope} accent`;
107
+ it(`${target}: the label clears WCAG AA 4.5 and APCA Lc 60 on the fill`, () => {
108
+ const name = accentThemeName(scope, componentName);
109
+ expect(name, `${target} resolves to no theme`).toBeDefined();
110
+ const { fill, label, wcag, lc } = measure(name as string);
111
+ expect(wcag, `${label} on ${fill}`).toBeGreaterThanOrEqual(aaTextContrastRatio);
112
+ expect(lc, `${label} on ${fill}`).toBeGreaterThanOrEqual(fillLabelLcFloor);
113
+ });
114
+ }
115
+ }
116
+ });
@@ -326,6 +326,47 @@ export function holdInkFloor(fill: string, ink: string, floor = aaTextContrastRa
326
326
  return onWhite >= onBlack ? "#ffffff" : "#000000";
327
327
  }
328
328
 
329
+ function mix(hex: string, pole: number, share: number): string {
330
+ return `#${[1, 3, 5]
331
+ .map((offset) => {
332
+ const channel = parseInt(hex.slice(offset, offset + 2), 16);
333
+ return Math.round(channel + (pole - channel) * share)
334
+ .toString(16)
335
+ .padStart(2, "0");
336
+ })
337
+ .join("")}`;
338
+ }
339
+
340
+ /**
341
+ * `hue` moved toward the scheme's ink pole (black on a light page, white on a
342
+ * dark one) by the least amount that clears `floor` on every surface, so the
343
+ * hue survives and only its lightness gives.
344
+ */
345
+ export function holdHueToFloor(
346
+ hue: string,
347
+ surfaces: readonly (string | undefined)[],
348
+ dark: boolean,
349
+ floor: number,
350
+ ): string {
351
+ const hueHex = normalizeToHex(hue);
352
+ const lums = surfaces
353
+ .map((surface) => (surface ? normalizeToHex(surface) : null))
354
+ .filter((hex): hex is string => hex != null)
355
+ .map(relativeLuminance);
356
+ if (!hueHex || lums.length === 0) return hue;
357
+ const clears = (hex: string) => {
358
+ const lum = relativeLuminance(hex);
359
+ return lums.every((surface) => contrastRatio(lum, surface) >= floor);
360
+ };
361
+ if (clears(hueHex)) return hue;
362
+ const pole = dark ? 255 : 0;
363
+ for (let step = 1; step <= 50; step++) {
364
+ const candidate = mix(hueHex, pole, step * 0.02);
365
+ if (clears(candidate)) return candidate;
366
+ }
367
+ return dark ? "#ffffff" : "#000000";
368
+ }
369
+
329
370
  /**
330
371
  * Given a 12-color scale (hex strings), a background step, and a starting
331
372
  * text step, scan towards `preferredDir` for the first step whose contrast
@@ -15,9 +15,11 @@
15
15
  * wash on hover and press. The bar's position carries the affordance.
16
16
  *
17
17
  * Disabled, both tiers drain the fill to transparent and set the ink on
18
- * `$color9`, which clears AA on the page in both schemes on the stock ramp;
19
- * the tinted tier keeps LC-40's `$color6` edge so the drained box still reads
20
- * as a control.
18
+ * `$textMuted`, the secondary ink (LC-152), which clears 7:1 on the page in
19
+ * both schemes. Under a tint it stays neutral: a tint's `$color9` is its
20
+ * hue's solid step, the colour of a live action, so a disabled label inked
21
+ * with it read as available (FZ-8939, LC-40). The tinted tier keeps LC-40's
22
+ * `$color6` edge so the drained box still reads as a control.
21
23
  */
22
24
  export type ControlTier = "tinted" | "plain";
23
25
 
@@ -60,7 +62,7 @@ export function controlTierDisabled(tier: ControlTier): {
60
62
  return {
61
63
  backgroundColor: "transparent",
62
64
  borderColor: tier === "tinted" ? "$color6" : "transparent",
63
- color: "$color9",
65
+ color: "$textMuted",
64
66
  };
65
67
  }
66
68
 
@@ -304,10 +304,17 @@ describe("heading scope categories", () => {
304
304
  try {
305
305
  const fonts = createFontLoader({ inter: true, fonts: [] }).importFonts();
306
306
  expect(Object.keys(fonts).sort()).toEqual(
307
- ["Inter", "InterMedium", "InterSemiBold", "InterBold", "InterExtraBold"].sort(),
307
+ [
308
+ "Inter",
309
+ "Inter-Regular",
310
+ "Inter-Medium",
311
+ "Inter-SemiBold",
312
+ "Inter-Bold",
313
+ "Inter-ExtraBold",
314
+ ].sort(),
308
315
  );
309
316
  for (const font of [defaultHeadingFont, defaultBodyFont]) {
310
- expect(font.face[500]?.normal).toBe("InterMedium");
317
+ expect(font.face[500]?.normal).toBe("Inter-Medium");
311
318
  for (const face of Object.values(font.face)) {
312
319
  expect(fonts).toHaveProperty(face.normal!.split(",")[0]!.trim());
313
320
  }
@@ -4,6 +4,7 @@ import { createInterFont } from "@tamagui/font-inter";
4
4
  // whole `tamagui` module down to `createTamagui`, and this module is on that
5
5
  // spec's import graph.
6
6
  import { createFont, type GenericFont } from "@tamagui/web";
7
+ import { interFaceNames } from "../../fonts/interFaces";
7
8
  import { flooredLineHeight, fontCategoryMetrics } from "../fontCategoryMetrics";
8
9
  import { fontCategoryStacks } from "../fontCategoryStacks";
9
10
  import type { FontCategory } from "../knobs";
@@ -93,14 +94,10 @@ export const bodyFontLineHeights: Record<string, number> = {
93
94
  ...defaultTypeTables.bodyLineHeights,
94
95
  };
95
96
 
96
- /** Shipped Inter faces: 400/500/600/700/800. */
97
- const interFaces = {
98
- 400: { normal: "Inter" },
99
- 500: { normal: "InterMedium" },
100
- 600: { normal: "InterSemiBold" },
101
- 700: { normal: "InterBold" },
102
- 800: { normal: "InterExtraBold" },
103
- } as const;
97
+ /** Shipped Inter faces: 400/500/600/700/800, by PostScript name (FZ-8572). */
98
+ const interFaces = Object.fromEntries(
99
+ Object.entries(interFaceNames).map(([weight, name]) => [weight, { normal: name }]),
100
+ ) as { [W in keyof typeof interFaceNames]: { normal: (typeof interFaceNames)[W] } };
104
101
 
105
102
  /**
106
103
  * Concrete family stacks for each `headingFont`/`bodyFont` category. Alias of
@@ -0,0 +1,104 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { semanticGroups } from "../colorRules";
4
+ import { defaultKnobs } from "../knobs";
5
+ import { ColorLineVisualizer } from "./ColorLineVisualizer";
6
+ import { cellSize } from "./vizConstants";
7
+
8
+ const ramp = (hue: number) =>
9
+ Array.from({ length: 12 }, (_, i) => `hsl(${hue}, 60%, ${90 - i * 6}%)`);
10
+
11
+ function normalized(color: string) {
12
+ const probe = document.createElement("div");
13
+ probe.style.background = color;
14
+ return probe.style.background;
15
+ }
16
+
17
+ function stepCells() {
18
+ return Array.from({ length: 12 }, (_, i) => screen.getAllByTitle(`$color${i + 1}`));
19
+ }
20
+
21
+ afterEach(() => {
22
+ cleanup();
23
+ document.documentElement.removeAttribute("style");
24
+ });
25
+
26
+ describe("ColorLineVisualizer", () => {
27
+ it("paints the twelve steps of the given ramp in order, numbered 1 to 12", () => {
28
+ const colors = ramp(210);
29
+ render(<ColorLineVisualizer knobs={defaultKnobs} isDark={false} themeColors={colors} />);
30
+ expect(screen.getByText("Theme")).toBeTruthy();
31
+ stepCells().forEach(([cell], i) => {
32
+ expect(cell.textContent).toBe(String(i + 1));
33
+ expect(cell.style.background).toBe(normalized(colors[i]));
34
+ expect(cell.style.width).toBe(`${cellSize}px`);
35
+ expect(cell.style.height).toBe(`${cellSize}px`);
36
+ });
37
+ });
38
+
39
+ it("brackets every semantic group over the steps it spans", () => {
40
+ render(<ColorLineVisualizer knobs={defaultKnobs} isDark={false} themeColors={ramp(30)} />);
41
+ for (const group of semanticGroups) {
42
+ const bracket = screen.getByText(group.label).parentElement as HTMLElement;
43
+ expect(bracket.style.left).toBe(`${(group.from - 1) * cellSize}px`);
44
+ expect(bracket.style.width).toBe(`${(group.to - group.from + 1) * cellSize}px`);
45
+ }
46
+ });
47
+
48
+ it("adds a half-height opposite row and an accent section only for full ramps", () => {
49
+ const { rerender } = render(
50
+ <ColorLineVisualizer
51
+ knobs={defaultKnobs}
52
+ isDark={false}
53
+ themeColors={ramp(210)}
54
+ oppositeThemeColors={ramp(200).slice(0, 11)}
55
+ accentColors={ramp(120).slice(0, 4)}
56
+ />,
57
+ );
58
+ expect(stepCells().every((cells) => cells.length === 1)).toBe(true);
59
+ expect(screen.queryByText("accent")).toBeNull();
60
+
61
+ const opposite = ramp(200);
62
+ const accent = ramp(120);
63
+ rerender(
64
+ <ColorLineVisualizer
65
+ knobs={defaultKnobs}
66
+ isDark={false}
67
+ themeColors={ramp(210)}
68
+ oppositeThemeColors={opposite}
69
+ accentColors={accent}
70
+ oppositeAccentColors={ramp(100)}
71
+ />,
72
+ );
73
+ expect(screen.getByText("accent")).toBeTruthy();
74
+ const cells = stepCells();
75
+ expect(cells.every((rows) => rows.length === 4)).toBe(true);
76
+ const [, oppositeRow, accentRow, oppositeAccentRow] = cells[0];
77
+ expect(oppositeRow.style.height).toBe(`${Math.round(cellSize / 2)}px`);
78
+ expect(oppositeRow.style.background).toBe(normalized(opposite[0]));
79
+ expect(accentRow.style.height).toBe(`${cellSize}px`);
80
+ expect(accentRow.style.background).toBe(normalized(accent[0]));
81
+ expect(oppositeAccentRow.style.height).toBe(`${Math.round(cellSize / 2)}px`);
82
+ });
83
+
84
+ it("reads the page's --color1..12 when no ramp is passed", () => {
85
+ const colors = ramp(330);
86
+ colors.forEach((color, i) =>
87
+ document.documentElement.style.setProperty(`--color${i + 1}`, color),
88
+ );
89
+ render(<ColorLineVisualizer knobs={defaultKnobs} isDark={false} />);
90
+ stepCells().forEach(([cell], i) => {
91
+ expect(cell.style.background).toBe(normalized(colors[i]));
92
+ });
93
+ });
94
+
95
+ it("draws its card in the scheme it is told", () => {
96
+ const { container, rerender } = render(
97
+ <ColorLineVisualizer knobs={defaultKnobs} isDark={false} themeColors={ramp(0)} />,
98
+ );
99
+ const light = (container.firstElementChild as HTMLElement).style.background;
100
+ rerender(<ColorLineVisualizer knobs={defaultKnobs} isDark themeColors={ramp(0)} />);
101
+ const dark = (container.firstElementChild as HTMLElement).style.background;
102
+ expect(light).not.toBe(dark);
103
+ });
104
+ });
@@ -1,12 +1,12 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { contrastRatio, relativeLuminance } from "./colorRules";
2
+ import { contrastRatio, holdHueToFloor, relativeLuminance } from "./colorRules";
3
3
  import {
4
4
  GLYPH_CONTRAST_FLOOR,
5
5
  glyphTintFallback,
6
- holdHueToFloor,
7
6
  platformSystemTint,
8
7
  resolveGlyphTint,
9
8
  } from "./glyphTint";
9
+ import { resolveActiveTabInk } from "./readableColor";
10
10
 
11
11
  const ratio = (a: string, b: string) => contrastRatio(relativeLuminance(a), relativeLuminance(b));
12
12
 
@@ -51,3 +51,31 @@ describe("LC-176 glyph tint (FZ-8020 r4)", () => {
51
51
  expect(parseInt(held.slice(5, 7), 16)).toBeGreaterThan(parseInt(held.slice(1, 3), 16));
52
52
  });
53
53
  });
54
+
55
+ describe("LC-150 active tab ink (FZ-7995 r3)", () => {
56
+ const light = { dark: false, surfaces: ["#f7f7f7", "#ffffff"], ink: "#111111" };
57
+ const dark = { dark: true, surfaces: ["#111111", "#191919"], ink: "#eeeeee" };
58
+ const chroma = (hex: string) => {
59
+ const rgb = [1, 3, 5].map((offset) => parseInt(hex.slice(offset, offset + 2), 16));
60
+ return Math.max(...rgb) - Math.min(...rgb);
61
+ };
62
+
63
+ it("keeps a hued theme's identity hue", () => {
64
+ expect(resolveActiveTabInk({ ...light, hue: "#c2410c", solid: "#0090ff" })).toBe("#c2410c");
65
+ });
66
+
67
+ for (const cell of [light, dark]) {
68
+ const scheme = cell.dark ? "dark" : "light";
69
+ it(`${scheme}, no hue: takes the accent solid, held to AA on every page surface`, () => {
70
+ const ink = resolveActiveTabInk({ ...cell, hue: undefined, solid: "#0090ff" });
71
+ expect(ink).not.toBe(cell.ink);
72
+ expect(chroma(ink)).toBeGreaterThanOrEqual(0.1 * 255);
73
+ for (const surface of cell.surfaces) expect(ratio(ink, surface)).toBeGreaterThanOrEqual(4.5);
74
+ });
75
+ }
76
+
77
+ it("falls back to the primary ink only when the solid has no hue", () => {
78
+ expect(resolveActiveTabInk({ ...light, hue: undefined, solid: "#6f6f6f" })).toBe("#111111");
79
+ expect(resolveActiveTabInk({ ...light, hue: undefined, solid: undefined })).toBe("#111111");
80
+ });
81
+ });
@@ -1,11 +1,6 @@
1
1
  import { createContext, useContext, useMemo } from "react";
2
2
  import { useThemeName } from "tamagui";
3
- import {
4
- aaTextContrastRatio,
5
- contrastRatio,
6
- normalizeToHex,
7
- relativeLuminance,
8
- } from "./colorRules";
3
+ import { aaTextContrastRatio, holdHueToFloor } from "./colorRules";
9
4
  import { isDarkThemeName } from "./controlTiers";
10
5
  import { useIdentityHue } from "./readableColor";
11
6
 
@@ -47,47 +42,6 @@ const GlyphTintFallbackContext = createContext<GlyphTintFallback>(glyphTintFallb
47
42
  /** Renders a subtree under another fallback, so one Storybook build can compare them. */
48
43
  export const GlyphTintFallbackProvider = GlyphTintFallbackContext.Provider;
49
44
 
50
- function mix(hex: string, pole: number, share: number): string {
51
- return `#${[1, 3, 5]
52
- .map((offset) => {
53
- const channel = parseInt(hex.slice(offset, offset + 2), 16);
54
- return Math.round(channel + (pole - channel) * share)
55
- .toString(16)
56
- .padStart(2, "0");
57
- })
58
- .join("")}`;
59
- }
60
-
61
- /**
62
- * `hue` moved toward the scheme's ink pole (black on a light page, white on a
63
- * dark one) by the least amount that clears `floor` on every surface, so the
64
- * hue survives and only its lightness gives.
65
- */
66
- export function holdHueToFloor(
67
- hue: string,
68
- surfaces: readonly (string | undefined)[],
69
- dark: boolean,
70
- floor: number,
71
- ): string {
72
- const hueHex = normalizeToHex(hue);
73
- const lums = surfaces
74
- .map((surface) => (surface ? normalizeToHex(surface) : null))
75
- .filter((hex): hex is string => hex != null)
76
- .map(relativeLuminance);
77
- if (!hueHex || lums.length === 0) return hue;
78
- const clears = (hex: string) => {
79
- const lum = relativeLuminance(hex);
80
- return lums.every((surface) => contrastRatio(lum, surface) >= floor);
81
- };
82
- if (clears(hueHex)) return hue;
83
- const pole = dark ? 255 : 0;
84
- for (let step = 1; step <= 50; step++) {
85
- const candidate = mix(hueHex, pole, step * 0.02);
86
- if (clears(candidate)) return candidate;
87
- }
88
- return dark ? "#ffffff" : "#000000";
89
- }
90
-
91
45
  export interface ResolveGlyphTintInput {
92
46
  /** The theme's own identity hue on the page, when it has one. */
93
47
  hue: string | undefined;
@@ -1,5 +1,11 @@
1
1
  import { beforeEach, describe, expect, it } from "vitest";
2
- import { ensureHairlineFallback, hairline, hairlineFallbackCss, hairlineWidth } from "./hairline";
2
+ import {
3
+ ensureHairlineFallback,
4
+ hairline,
5
+ hairlineFallbackCss,
6
+ hairlineThickness,
7
+ hairlineWidth,
8
+ } from "./hairline";
3
9
 
4
10
  describe("hairline (Axiom 15 OPTICS)", () => {
5
11
  it("resolves half a CSS pixel on web", () => {
@@ -43,8 +49,46 @@ describe("hairline (Axiom 15 OPTICS)", () => {
43
49
  expect(hairlineFallbackCss).toContain("border-inline-end-width: 1px !important");
44
50
  // filled-line fallbacks must pin max-height/max-width too (the `line`
45
51
  // treatment sets both, so height alone could not win)
46
- expect(hairlineFallbackCss).toContain("height: 1px !important; max-height: 1px !important");
47
- expect(hairlineFallbackCss).toContain("width: 1px !important; max-width: 1px !important");
52
+ // and never under one device pixel when zoomed out below 1dppx
53
+ expect(hairlineFallbackCss).toContain(
54
+ "height: max(1px, var(--mp-hairline, 1px)) !important; max-height: max(1px, var(--mp-hairline, 1px)) !important",
55
+ );
56
+ expect(hairlineFallbackCss).toContain(
57
+ "width: max(1px, var(--mp-hairline, 1px)) !important; max-width: max(1px, var(--mp-hairline, 1px)) !important",
58
+ );
59
+ });
60
+
61
+ it("keeps a zoomed-out ratio under 1 at one device pixel, past the 1px floor", () => {
62
+ for (const ratio of [0.5, 0.67, 0.8, 0.9]) {
63
+ const thickness = Math.max(1, hairlineThickness(ratio));
64
+ expect(thickness * ratio, `ratio ${ratio}`).toBeGreaterThanOrEqual(1);
65
+ }
66
+ });
67
+
68
+ // FZ-8395: a filled box under one device pixel snaps to 0 on alternate rows.
69
+ it("draws filled lines at least one device pixel at every ratio, never 0", () => {
70
+ for (const ratio of [1, 1.25, 1.5, 1.75, 1.8, 2, 2.25, 2.5, 2.625, 3, 3.5, 4]) {
71
+ const devicePx = hairlineThickness(ratio) * ratio;
72
+ expect(devicePx, `ratio ${ratio}`).toBeGreaterThanOrEqual(1);
73
+ expect(devicePx, `ratio ${ratio}`).toBeLessThan(1.1);
74
+ // On Blink's 1/64 px layout grid, so layout cannot round it under.
75
+ expect(Number.isInteger(hairlineThickness(ratio) * 64), `ratio ${ratio}`).toBe(true);
76
+ }
77
+ expect(hairlineThickness(2)).toBe(0.5);
78
+ expect(hairlineThickness(Number.NaN)).toBe(1);
79
+ });
80
+
81
+ it("sizes the filled treatments from --mp-hairline, above the inline 0.5px", () => {
82
+ expect(hairlineFallbackCss).toContain(
83
+ ".mp-hairline-h { height: var(--mp-hairline, 0.5px) !important",
84
+ );
85
+ expect(hairlineFallbackCss).toContain(
86
+ ".mp-hairline-w { width: var(--mp-hairline, 0.5px) !important",
87
+ );
88
+ // The low-DPI 1px block comes after, so it still wins there.
89
+ expect(hairlineFallbackCss.indexOf("@media")).toBeGreaterThan(
90
+ hairlineFallbackCss.indexOf("var(--mp-hairline"),
91
+ );
48
92
  });
49
93
 
50
94
  describe("fallback stylesheet mount", () => {
@@ -59,5 +103,18 @@ describe("hairline (Axiom 15 OPTICS)", () => {
59
103
  expect(tags).toHaveLength(1);
60
104
  expect(tags[0]?.textContent).toBe(hairlineFallbackCss);
61
105
  });
106
+
107
+ it("points --mp-hairline at one device pixel of the live ratio", () => {
108
+ const original = window.devicePixelRatio;
109
+ Object.defineProperty(window, "devicePixelRatio", { configurable: true, value: 1.75 });
110
+ try {
111
+ ensureHairlineFallback();
112
+ expect(document.documentElement.style.getPropertyValue("--mp-hairline")).toBe(
113
+ `${hairlineThickness(1.75)}px`,
114
+ );
115
+ } finally {
116
+ Object.defineProperty(window, "devicePixelRatio", { configurable: true, value: original });
117
+ }
118
+ });
62
119
  });
63
120
  });
@@ -81,21 +81,50 @@ export const hairline = {
81
81
  const STYLE_TAG_ID = "mp-hairline-styles";
82
82
 
83
83
  /**
84
- * 1px fallback for screens where 0.5px cannot map to a whole device pixel.
84
+ * FZ-8395: the filled lines' thickness, one device pixel rounded UP to
85
+ * Blink's 1/64 px layout unit. A filled box under one device pixel tall snaps
86
+ * to 0 or 1 device pixel depending on where its top lands, so 0.5 CSS px
87
+ * vanished on alternate rows at ratios between 1.5 and 2 (Windows 150-175%
88
+ * scaling, browser zoom). Exactly one device pixel or more never rounds to 0.
89
+ */
90
+ export function hairlineThickness(devicePixelRatio: number): number {
91
+ const ratio = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1;
92
+ return Math.ceil(64 / ratio) / 64;
93
+ }
94
+
95
+ /**
96
+ * The filled `line`, `vline` and `height` treatments read their thickness
97
+ * from `--mp-hairline` (set from the live ratio below; 0.5px until it is), and
98
+ * low-DPI screens get a 1px floor for every treatment; a zoomed-out ratio
99
+ * under 1 keeps the filled ones at one device pixel, past that floor.
85
100
  * (`-webkit-max-device-pixel-ratio` covers Safari versions without
86
101
  * `resolution` media-query support.) Exported for unit tests.
87
102
  */
88
- export const hairlineFallbackCss = `@media (max-resolution: 1.49dppx), (-webkit-max-device-pixel-ratio: 1.49) {
103
+ export const hairlineFallbackCss = `.mp-hairline-h { height: var(--mp-hairline, 0.5px) !important; max-height: var(--mp-hairline, 0.5px) !important; flex-basis: var(--mp-hairline, 0.5px) !important; }
104
+ .mp-hairline-w { width: var(--mp-hairline, 0.5px) !important; max-width: var(--mp-hairline, 0.5px) !important; flex-basis: var(--mp-hairline, 0.5px) !important; }
105
+ @media (max-resolution: 1.49dppx), (-webkit-max-device-pixel-ratio: 1.49) {
89
106
  .mp-hairline-b { border-bottom-width: 1px !important; }
90
107
  .mp-hairline-t { border-top-width: 1px !important; }
91
108
  .mp-hairline-r { border-right-width: 1px !important; }
92
109
  .mp-hairline-l { border-left-width: 1px !important; }
93
- .mp-hairline-h { height: 1px !important; max-height: 1px !important; flex-basis: 1px !important; }
94
- .mp-hairline-w { width: 1px !important; max-width: 1px !important; flex-basis: 1px !important; }
110
+ .mp-hairline-h { height: max(1px, var(--mp-hairline, 1px)) !important; max-height: max(1px, var(--mp-hairline, 1px)) !important; flex-basis: max(1px, var(--mp-hairline, 1px)) !important; }
111
+ .mp-hairline-w { width: max(1px, var(--mp-hairline, 1px)) !important; max-width: max(1px, var(--mp-hairline, 1px)) !important; flex-basis: max(1px, var(--mp-hairline, 1px)) !important; }
95
112
  .mp-hairline-ie { border-inline-end-width: 1px !important; }
96
113
  }`;
97
114
 
98
- /** Idempotently mount the low-DPI fallback stylesheet (no-op off-DOM). */
115
+ let watchedRatio: MediaQueryList | undefined;
116
+
117
+ /** Point `--mp-hairline` at one device pixel, and again whenever the ratio changes. */
118
+ function trackDevicePixel(): void {
119
+ if (typeof window === "undefined") return;
120
+ const ratio = window.devicePixelRatio || 1;
121
+ document.documentElement.style.setProperty("--mp-hairline", `${hairlineThickness(ratio)}px`);
122
+ watchedRatio?.removeEventListener?.("change", trackDevicePixel);
123
+ watchedRatio = window.matchMedia?.(`(resolution: ${ratio}dppx)`);
124
+ watchedRatio?.addEventListener?.("change", trackDevicePixel);
125
+ }
126
+
127
+ /** Idempotently mount the hairline stylesheet (no-op off-DOM). */
99
128
  export function ensureHairlineFallback(): void {
100
129
  if (typeof document === "undefined") return;
101
130
  if (document.getElementById(STYLE_TAG_ID)) return;
@@ -103,6 +132,7 @@ export function ensureHairlineFallback(): void {
103
132
  tag.id = STYLE_TAG_ID;
104
133
  tag.textContent = hairlineFallbackCss;
105
134
  document.head.appendChild(tag);
135
+ trackDevicePixel();
106
136
  }
107
137
 
108
138
  // The fallback is static CSS (no knob/theme dependency), so mount it as soon
@@ -3,11 +3,13 @@ import { useTheme as useTamaguiTheme, useThemeName } from "tamagui";
3
3
  import {
4
4
  aaTextContrastRatio,
5
5
  contrastRatio,
6
+ holdHueToFloor,
6
7
  holdInkFloor,
7
8
  normalizeToHex,
8
9
  pickReadableForeground,
9
10
  relativeLuminance,
10
11
  } from "./colorRules";
12
+ import { isDarkThemeName } from "./controlTiers";
11
13
  import { tintHueNames } from "./createThemes";
12
14
 
13
15
  /**
@@ -314,3 +316,50 @@ export function useAccentTintedSurface(): string | undefined {
314
316
  const paper = resolveTokenValue(theme, "color1");
315
317
  return useMemo(() => resolveAccentTintedSurface({ solid, paper }), [solid, paper]);
316
318
  }
319
+
320
+ /** LC-172's floor for a hue: the spread between the strongest and weakest sRGB channel. */
321
+ const ACTIVE_TAB_CHROMA_FLOOR = 0.1;
322
+
323
+ export interface ResolveActiveTabInkInput {
324
+ /** The theme's identity hue on the page, when it has one. */
325
+ hue: string | undefined;
326
+ /** The theme's accent solid (`$accentBackground`). */
327
+ solid: string | undefined;
328
+ dark: boolean;
329
+ surfaces: readonly (string | undefined)[];
330
+ ink: string;
331
+ }
332
+
333
+ /**
334
+ * LC-150: the active tab's ink, one pick for the MPO TabBar and the system
335
+ * tab bar. A hued theme keeps its identity hue. A theme with no identity hue
336
+ * but a hued accent solid takes that solid held to AA text contrast on the
337
+ * page. The house hue-less theme's solid is the neutral inverse, so the tab
338
+ * takes the primary ink, bold mono, never the grey ramp step.
339
+ */
340
+ export function resolveActiveTabInk({
341
+ hue,
342
+ solid,
343
+ dark,
344
+ surfaces,
345
+ ink,
346
+ }: ResolveActiveTabInkInput): string {
347
+ if (hue) return hue;
348
+ const solidHex = solid ? normalizeToHex(solid) : null;
349
+ if (solidHex && chromaOf(solidHex) >= ACTIVE_TAB_CHROMA_FLOOR) {
350
+ return holdHueToFloor(solidHex, surfaces, dark, aaTextContrastRatio);
351
+ }
352
+ return ink;
353
+ }
354
+
355
+ /** LC-150: the live theme's active-tab ink (see {@link resolveActiveTabInk}). */
356
+ export function useActiveTabInk(): string {
357
+ const dark = isDarkThemeName(useThemeName() as string | undefined);
358
+ const { hue, c1, c2, ink } = useIdentityHue();
359
+ const theme = useTamaguiTheme() as unknown as Record<string, { val?: unknown } | undefined>;
360
+ const solid = resolveTokenValue(theme, "accentBackground");
361
+ return useMemo(
362
+ () => resolveActiveTabInk({ hue, solid, dark, surfaces: [c1, c2], ink }),
363
+ [hue, solid, dark, c1, c2, ink],
364
+ );
365
+ }