@multiplatform.one/theme 6.0.4 → 6.3.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/README.md +73 -0
- package/package.json +22 -9
- package/src/dates.spec.ts +75 -0
- package/src/dates.ts +130 -0
- package/src/devWarn.spec.ts +144 -0
- package/src/devWarn.ts +235 -0
- package/src/figma/figmaTokens.spec.ts +244 -0
- package/src/figma/figmaTokens.ts +503 -0
- package/src/font.ts +6 -8
- package/src/fonts/createFontLoader.web.ts +15 -4
- package/src/index.ts +7 -0
- package/src/keyboardFocusRing.ts +45 -0
- package/src/menuRow.spec.ts +32 -0
- package/src/menuRow.ts +31 -0
- package/src/theme/FontKnobStyles.native.tsx +24 -0
- package/src/theme/FontKnobStyles.tsx +256 -0
- package/src/theme/animations/reactNative.ts +6 -1
- package/src/theme/chartPalette.spec.ts +159 -0
- package/src/theme/chartPalette.ts +170 -0
- package/src/theme/colorRules.ts +33 -1
- package/src/theme/cookies.spec.ts +32 -0
- package/src/theme/cookiesPersistence.spec.ts +86 -0
- package/src/theme/cornerSmoothing.native.ts +16 -0
- package/src/theme/cornerSmoothing.spec.ts +27 -0
- package/src/theme/cornerSmoothing.ts +46 -0
- package/src/theme/createDefaultThemeConfig.ts +32 -8
- package/src/theme/createThemeConfig.spec.ts +137 -0
- package/src/theme/createThemes.ts +77 -2
- package/src/theme/defaults/accent.ts +39 -0
- package/src/theme/defaults/base.ts +63 -0
- package/src/theme/defaults/builderOptions.ts +174 -0
- package/src/theme/defaults/fonts.spec.ts +87 -0
- package/src/theme/defaults/fonts.ts +89 -0
- package/src/theme/defaults/index.ts +4 -0
- package/src/theme/devtools/ThemeDevtoolsPanel.tsx +196 -2
- package/src/theme/devtools/buildSnapshot.ts +11 -0
- package/src/theme/devtools/knobIcons.tsx +141 -0
- package/src/theme/devtools/vizConstants.ts +1 -1
- package/src/theme/focusState.spec.ts +174 -0
- package/src/theme/focusState.ts +112 -0
- package/src/theme/fontKnobStyles.spec.ts +98 -0
- package/src/theme/hairline.native.ts +43 -0
- package/src/theme/hairline.spec.ts +63 -0
- package/src/theme/hairline.ts +101 -0
- package/src/theme/index.ts +8 -0
- package/src/theme/intent.spec.tsx +2 -2
- package/src/theme/intents.ts +6 -2
- package/src/theme/knobs.ts +134 -2
- package/src/theme/layoutTokens.spec.ts +75 -0
- package/src/theme/layoutTokens.ts +213 -0
- package/src/theme/layoutTokensHooks.spec.tsx +157 -0
- package/src/theme/presets.spec.ts +17 -0
- package/src/theme/presets.ts +22 -2
- package/src/theme/readableColor.ts +101 -0
- package/src/theme/recipes.ts +130 -7
- package/src/theme/resolveKnobs.spec.ts +560 -21
- package/src/theme/resolveKnobs.ts +267 -23
- package/src/theme/shared.tsx +26 -2
- package/src/theme/useResolvedKnobs.ts +67 -26
- package/src/theme/useResolvedKnobsBehavior.spec.tsx +166 -0
- package/types/audit/constraintAudit.d.ts +83 -0
- package/types/audit/constraintAudit.d.ts.map +1 -0
- package/types/audit/diffReport.d.ts +45 -0
- package/types/audit/diffReport.d.ts.map +1 -0
- package/types/audit/index.d.ts +5 -0
- package/types/audit/index.d.ts.map +1 -0
- package/types/dates.d.ts +52 -0
- package/types/dates.d.ts.map +1 -0
- package/types/devWarn.d.ts +104 -0
- package/types/devWarn.d.ts.map +1 -0
- package/types/figma/figmaTokens.d.ts +168 -0
- package/types/figma/figmaTokens.d.ts.map +1 -0
- package/types/font.d.ts +5 -0
- package/types/font.d.ts.map +1 -0
- package/types/fonts/createFontLoader.d.ts +3 -0
- package/types/fonts/createFontLoader.d.ts.map +1 -0
- package/types/fonts/createFontLoader.web.d.ts +3 -0
- package/types/fonts/createFontLoader.web.d.ts.map +1 -0
- package/types/fonts/index.d.ts +3 -0
- package/types/fonts/index.d.ts.map +1 -0
- package/types/fonts/types.d.ts +12 -0
- package/types/fonts/types.d.ts.map +1 -0
- package/types/index.d.ts +9 -0
- package/types/index.d.ts.map +1 -0
- package/types/keyboardFocusRing.d.ts +32 -0
- package/types/keyboardFocusRing.d.ts.map +1 -0
- package/types/menuRow.d.ts +32 -0
- package/types/menuRow.d.ts.map +1 -0
- package/types/theme/FontKnobStyles.d.ts +49 -0
- package/types/theme/FontKnobStyles.d.ts.map +1 -0
- package/types/theme/FontKnobStyles.native.d.ts +12 -0
- package/types/theme/FontKnobStyles.native.d.ts.map +1 -0
- package/types/theme/Intent.d.ts +20 -0
- package/types/theme/Intent.d.ts.map +1 -0
- package/types/theme/Preset.d.ts +15 -0
- package/types/theme/Preset.d.ts.map +1 -0
- package/types/theme/PresetContext.d.ts +18 -0
- package/types/theme/PresetContext.d.ts.map +1 -0
- package/types/theme/Tint.d.ts +19 -0
- package/types/theme/Tint.d.ts.map +1 -0
- package/types/theme/animations/css.d.ts +24 -0
- package/types/theme/animations/css.d.ts.map +1 -0
- package/types/theme/animations/index.d.ts +2 -0
- package/types/theme/animations/index.d.ts.map +1 -0
- package/types/theme/animations/index.web.d.ts +2 -0
- package/types/theme/animations/index.web.d.ts.map +1 -0
- package/types/theme/animations/reactNative.d.ts +104 -0
- package/types/theme/animations/reactNative.d.ts.map +1 -0
- package/types/theme/chartPalette.d.ts +62 -0
- package/types/theme/chartPalette.d.ts.map +1 -0
- package/types/theme/colorRules.d.ts +69 -0
- package/types/theme/colorRules.d.ts.map +1 -0
- package/types/theme/cookies.d.ts +76 -0
- package/types/theme/cookies.d.ts.map +1 -0
- package/types/theme/cornerSmoothing.d.ts +31 -0
- package/types/theme/cornerSmoothing.d.ts.map +1 -0
- package/types/theme/cornerSmoothing.native.d.ts +14 -0
- package/types/theme/cornerSmoothing.native.d.ts.map +1 -0
- package/types/theme/createDefaultThemeConfig.d.ts +37 -0
- package/types/theme/createDefaultThemeConfig.d.ts.map +1 -0
- package/types/theme/createThemes.d.ts +38 -0
- package/types/theme/createThemes.d.ts.map +1 -0
- package/types/theme/defaults/accent.d.ts +3 -0
- package/types/theme/defaults/accent.d.ts.map +1 -0
- package/types/theme/defaults/base.d.ts +3 -0
- package/types/theme/defaults/base.d.ts.map +1 -0
- package/types/theme/defaults/builderOptions.d.ts +3 -0
- package/types/theme/defaults/builderOptions.d.ts.map +1 -0
- package/types/theme/defaults/fonts.d.ts +7 -0
- package/types/theme/defaults/fonts.d.ts.map +1 -0
- package/types/theme/defaults/index.d.ts +5 -0
- package/types/theme/defaults/index.d.ts.map +1 -0
- package/types/theme/devtools/ColorLineVisualizer.d.ts +17 -0
- package/types/theme/devtools/ColorLineVisualizer.d.ts.map +1 -0
- package/types/theme/devtools/ThemeDevtoolsPanel.d.ts +87 -0
- package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -0
- package/types/theme/devtools/buildSnapshot.d.ts +12 -0
- package/types/theme/devtools/buildSnapshot.d.ts.map +1 -0
- package/types/theme/devtools/knobIcons.d.ts +33 -0
- package/types/theme/devtools/knobIcons.d.ts.map +1 -0
- package/types/theme/devtools/vizConstants.d.ts +5 -0
- package/types/theme/devtools/vizConstants.d.ts.map +1 -0
- package/types/theme/focusState.d.ts +42 -0
- package/types/theme/focusState.d.ts.map +1 -0
- package/types/theme/hairline.d.ts +88 -0
- package/types/theme/hairline.d.ts.map +1 -0
- package/types/theme/hairline.native.d.ts +42 -0
- package/types/theme/hairline.native.d.ts.map +1 -0
- package/types/theme/index.d.ts +29 -0
- package/types/theme/index.d.ts.map +1 -0
- package/types/theme/intents.d.ts +3 -0
- package/types/theme/intents.d.ts.map +1 -0
- package/types/theme/knobs.d.ts +236 -0
- package/types/theme/knobs.d.ts.map +1 -0
- package/types/theme/layoutTokens.d.ts +104 -0
- package/types/theme/layoutTokens.d.ts.map +1 -0
- package/types/theme/preset.types.d.ts +21 -0
- package/types/theme/preset.types.d.ts.map +1 -0
- package/types/theme/presets.d.ts +4 -0
- package/types/theme/presets.d.ts.map +1 -0
- package/types/theme/readableColor.d.ts +17 -0
- package/types/theme/readableColor.d.ts.map +1 -0
- package/types/theme/recipes.d.ts +200 -0
- package/types/theme/recipes.d.ts.map +1 -0
- package/types/theme/resolveKnobs.d.ts +22 -0
- package/types/theme/resolveKnobs.d.ts.map +1 -0
- package/types/theme/shared.d.ts +40 -0
- package/types/theme/shared.d.ts.map +1 -0
- package/types/theme/theme.d.ts +19 -0
- package/types/theme/theme.d.ts.map +1 -0
- package/types/theme/theme.native.d.ts +10 -0
- package/types/theme/theme.native.d.ts.map +1 -0
- package/types/theme/useColorScale.d.ts +7 -0
- package/types/theme/useColorScale.d.ts.map +1 -0
- package/types/theme/useColorScale.native.d.ts +6 -0
- package/types/theme/useColorScale.native.d.ts.map +1 -0
- package/types/theme/useResolvedKnobs.d.ts +32 -0
- package/types/theme/useResolvedKnobs.d.ts.map +1 -0
- package/types/theme/useTheme.d.ts +31 -0
- package/types/theme/useTheme.d.ts.map +1 -0
- package/types/theme/useTheme.native.d.ts +10 -0
- package/types/theme/useTheme.native.d.ts.map +1 -0
- package/CHANGELOG.md +0 -46
- package/src/theme/__snapshots__/resolveKnobs.spec.ts.snap +0 -69
- package/src/theme/pokemonScreen.spec.ts +0 -109
- package/tsconfig.json +0 -11
- package/vitest.config.mjs +0 -9
|
@@ -28,6 +28,19 @@ export function sizeIcon(value: string, color: string): ReactNode {
|
|
|
28
28
|
return <div style={{ width: 4, height: height, background: color, borderRadius: 1 }} />;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
export function densityIcon(value: string, color: string): ReactNode {
|
|
32
|
+
// Comfortable = taller stack; compact = compressed stack.
|
|
33
|
+
const gap = value === "compact" ? 1 : 2;
|
|
34
|
+
const barH = value === "compact" ? 3 : 4;
|
|
35
|
+
return (
|
|
36
|
+
<div style={{ display: "flex", flexDirection: "column", gap, width: 14 }}>
|
|
37
|
+
<div style={{ height: barH, background: color, borderRadius: 1 }} />
|
|
38
|
+
<div style={{ height: barH, background: color, borderRadius: 1 }} />
|
|
39
|
+
<div style={{ height: barH, background: color, borderRadius: 1 }} />
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
export function borderRadiusIcon(value: string, color: string): ReactNode {
|
|
32
45
|
const radius =
|
|
33
46
|
({ none: 0, small: 2, medium: 4, large: 7, full: 10 } as Record<string, number>)[value] ?? 0;
|
|
@@ -36,6 +49,26 @@ export function borderRadiusIcon(value: string, color: string): ReactNode {
|
|
|
36
49
|
);
|
|
37
50
|
}
|
|
38
51
|
|
|
52
|
+
export function cornerSmoothingIcon(value: string, color: string): ReactNode {
|
|
53
|
+
// Same nominal corner size, different curvature: circular-arc rounded
|
|
54
|
+
// rect for `round`, continuous-curvature superellipse for `smooth`.
|
|
55
|
+
const smooth = value === "smooth";
|
|
56
|
+
return (
|
|
57
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" role="img" aria-label={value}>
|
|
58
|
+
<title>{value}</title>
|
|
59
|
+
{smooth ? (
|
|
60
|
+
<path
|
|
61
|
+
d="M7 1 C2.8 1 1 2.8 1 7 C1 11.2 2.8 13 7 13 C11.2 13 13 11.2 13 7 C13 2.8 11.2 1 7 1 Z"
|
|
62
|
+
stroke={color}
|
|
63
|
+
strokeWidth="1.5"
|
|
64
|
+
/>
|
|
65
|
+
) : (
|
|
66
|
+
<rect x="1" y="1" width="12" height="12" rx="4" stroke={color} strokeWidth="1.5" />
|
|
67
|
+
)}
|
|
68
|
+
</svg>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
39
72
|
export function borderWidthIcon(value: string, color: string): ReactNode {
|
|
40
73
|
if (value === "none") {
|
|
41
74
|
return (
|
|
@@ -215,3 +248,111 @@ export function modeIcon(value: "system" | "light" | "dark", color: string): Rea
|
|
|
215
248
|
}
|
|
216
249
|
return <div style={{ ...base, background: color }} />;
|
|
217
250
|
}
|
|
251
|
+
|
|
252
|
+
/** Compact text glyph used by house-decision knobs in the panel. */
|
|
253
|
+
function glyph(label: string, color: string, size = 9): ReactNode {
|
|
254
|
+
return (
|
|
255
|
+
<span
|
|
256
|
+
style={{
|
|
257
|
+
fontSize: size,
|
|
258
|
+
fontWeight: 600,
|
|
259
|
+
color,
|
|
260
|
+
fontFamily: "system-ui, sans-serif",
|
|
261
|
+
lineHeight: 1,
|
|
262
|
+
letterSpacing: "-0.02em",
|
|
263
|
+
}}
|
|
264
|
+
>
|
|
265
|
+
{label}
|
|
266
|
+
</span>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function fieldLabelPlacementIcon(value: string, color: string): ReactNode {
|
|
271
|
+
if (value === "side") return glyph("⊟", color, 12);
|
|
272
|
+
if (value === "floating") return glyph("⬚", color, 12);
|
|
273
|
+
return glyph("⊞", color, 12); // top
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function requiredMarkingIcon(value: string, color: string): ReactNode {
|
|
277
|
+
if (value === "asterisk") return glyph("*", color, 14);
|
|
278
|
+
if (value === "optional") return glyph("opt", color, 8);
|
|
279
|
+
return glyph("min", color, 8); // minority
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function tableZebraIcon(value: string, color: string): ReactNode {
|
|
283
|
+
const on = value === "on";
|
|
284
|
+
return (
|
|
285
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 1, width: 14 }}>
|
|
286
|
+
<div style={{ height: 3, background: color, borderRadius: 1, opacity: on ? 1 : 0.35 }} />
|
|
287
|
+
<div style={{ height: 3, background: color, borderRadius: 1, opacity: on ? 0.35 : 0.35 }} />
|
|
288
|
+
<div style={{ height: 3, background: color, borderRadius: 1, opacity: on ? 1 : 0.35 }} />
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function bulkBarPlacementIcon(value: string, color: string): ReactNode {
|
|
294
|
+
const top = value !== "bottom";
|
|
295
|
+
return (
|
|
296
|
+
<div
|
|
297
|
+
style={{
|
|
298
|
+
width: 14,
|
|
299
|
+
height: 14,
|
|
300
|
+
border: `1px solid ${color}`,
|
|
301
|
+
borderRadius: 2,
|
|
302
|
+
display: "flex",
|
|
303
|
+
flexDirection: "column",
|
|
304
|
+
justifyContent: top ? "flex-start" : "flex-end",
|
|
305
|
+
padding: 1,
|
|
306
|
+
}}
|
|
307
|
+
>
|
|
308
|
+
<div style={{ height: 3, background: color, borderRadius: 1 }} />
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function selectAllScopeIcon(value: string, color: string): ReactNode {
|
|
314
|
+
const filtered = value === "filtered";
|
|
315
|
+
// Three rows; page scope highlights only the top rows (current page),
|
|
316
|
+
// filtered scope highlights all rows (all matching).
|
|
317
|
+
return (
|
|
318
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 1, width: 14 }}>
|
|
319
|
+
<div style={{ height: 3, background: color, borderRadius: 1 }} />
|
|
320
|
+
<div style={{ height: 3, background: color, borderRadius: 1 }} />
|
|
321
|
+
<div style={{ height: 3, background: color, borderRadius: 1, opacity: filtered ? 1 : 0.3 }} />
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function timestampStyleIcon(value: string, color: string): ReactNode {
|
|
327
|
+
return glyph(value === "relative" ? "~t" : "12:00", color, value === "relative" ? 10 : 7);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function disabledStyleIcon(value: string, color: string): ReactNode {
|
|
331
|
+
if (value === "dimWhole") {
|
|
332
|
+
return (
|
|
333
|
+
<div style={{ width: 14, height: 14, background: color, borderRadius: 2, opacity: 0.35 }} />
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
// keepLabel — solid label bar over a dimmed body
|
|
337
|
+
return (
|
|
338
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 2, width: 14 }}>
|
|
339
|
+
<div style={{ height: 3, background: color, borderRadius: 1 }} />
|
|
340
|
+
<div style={{ height: 7, background: color, borderRadius: 1, opacity: 0.3 }} />
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function formAutofocusIcon(value: string, color: string): ReactNode {
|
|
346
|
+
return (
|
|
347
|
+
<div
|
|
348
|
+
style={{
|
|
349
|
+
width: 14,
|
|
350
|
+
height: 14,
|
|
351
|
+
border: `1.5px solid ${color}`,
|
|
352
|
+
borderRadius: 2,
|
|
353
|
+
boxShadow: value === "on" ? `0 0 0 2px ${color}` : undefined,
|
|
354
|
+
opacity: value === "on" ? 1 : 0.45,
|
|
355
|
+
}}
|
|
356
|
+
/>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { __resetDevWarnSeen, warnOutlineNone, warnPositiveTabIndex } from "../devWarn";
|
|
3
|
+
import { contrastRatio, normalizeToHex, relativeLuminance } from "./colorRules";
|
|
4
|
+
import { createThemesBuilder } from "./createThemes";
|
|
5
|
+
import { defaultAccentTheme } from "./defaults/accent";
|
|
6
|
+
import { defaultBaseTheme } from "./defaults/base";
|
|
7
|
+
import { defaultBuilderOptions } from "./defaults/builderOptions";
|
|
8
|
+
import {
|
|
9
|
+
ensureFocusVisibleRing,
|
|
10
|
+
focusRingColorMeetsContrast,
|
|
11
|
+
FOCUS_RING_MIN_CONTRAST,
|
|
12
|
+
FOCUS_RING_MIN_WIDTH,
|
|
13
|
+
FOCUS_VISIBLE_RING,
|
|
14
|
+
STATE_LAYER_HOVER,
|
|
15
|
+
STATE_LAYER_PRESS,
|
|
16
|
+
withDefaultStateLayer,
|
|
17
|
+
} from "./focusState";
|
|
18
|
+
import { defaultKnobs } from "./knobs";
|
|
19
|
+
import { resolveKnobs } from "./resolveKnobs";
|
|
20
|
+
|
|
21
|
+
describe("focusState tokens (DG-A11Y-01 / DG-STATE-01 / W12)", () => {
|
|
22
|
+
it("FOCUS_VISIBLE_RING meets ≥2px solid outline contract", () => {
|
|
23
|
+
expect(FOCUS_VISIBLE_RING.outlineWidth).toBeGreaterThanOrEqual(FOCUS_RING_MIN_WIDTH);
|
|
24
|
+
expect(FOCUS_VISIBLE_RING.outlineStyle).toBe("solid");
|
|
25
|
+
expect(FOCUS_VISIBLE_RING.outlineColor).toBe("$outlineColor");
|
|
26
|
+
expect(FOCUS_RING_MIN_CONTRAST).toBeGreaterThanOrEqual(3);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("ensureFocusVisibleRing always emits a ≥2px solid ring", () => {
|
|
30
|
+
expect(ensureFocusVisibleRing()).toMatchObject(FOCUS_VISIBLE_RING);
|
|
31
|
+
expect(ensureFocusVisibleRing({})).toMatchObject(FOCUS_VISIBLE_RING);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("ensureFocusVisibleRing clamps outlineWidth < 2 and rejects outline:none", () => {
|
|
35
|
+
const clamped = ensureFocusVisibleRing({
|
|
36
|
+
outlineWidth: 0,
|
|
37
|
+
outlineStyle: "none",
|
|
38
|
+
outlineColor: "transparent",
|
|
39
|
+
borderWidth: 1,
|
|
40
|
+
});
|
|
41
|
+
expect(clamped.outlineWidth).toBe(FOCUS_RING_MIN_WIDTH);
|
|
42
|
+
expect(clamped.outlineStyle).toBe("solid");
|
|
43
|
+
expect(clamped.outlineColor).toBe("$outlineColor");
|
|
44
|
+
expect(clamped.borderWidth).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("ensureFocusVisibleRing allows thicker rings and preserves extras", () => {
|
|
48
|
+
const thick = ensureFocusVisibleRing({
|
|
49
|
+
outlineWidth: 4,
|
|
50
|
+
borderColor: "$borderColorFocus",
|
|
51
|
+
} as { outlineWidth: number; borderColor: string });
|
|
52
|
+
expect(thick.outlineWidth).toBe(4);
|
|
53
|
+
expect(thick.borderColor).toBe("$borderColorFocus");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("ensureFocusVisibleRing reverts alpha-carrying ring colors (DF-03)", () => {
|
|
57
|
+
// The stock Tamagui ring (20%-alpha ink) can't guarantee ≥3:1.
|
|
58
|
+
for (const outlineColor of ["rgba(36, 34, 38, 0.2)", "hsla(270, 6%, 14%, 0.2)", "#2422260a"]) {
|
|
59
|
+
expect(
|
|
60
|
+
ensureFocusVisibleRing({ outlineColor } as { outlineColor: string }).outlineColor,
|
|
61
|
+
).toBe(FOCUS_VISIBLE_RING.outlineColor);
|
|
62
|
+
}
|
|
63
|
+
// A solid override is respected.
|
|
64
|
+
expect(
|
|
65
|
+
ensureFocusVisibleRing({ outlineColor: "#5b5563" } as { outlineColor: string }).outlineColor,
|
|
66
|
+
).toBe("#5b5563");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("focusRingColorMeetsContrast enforces the ≥3:1 color floor", () => {
|
|
70
|
+
expect(focusRingColorMeetsContrast("#767085", "#ffffff")).toBe(true);
|
|
71
|
+
expect(focusRingColorMeetsContrast("#e6e4e7", "#ffffff")).toBe(false);
|
|
72
|
+
// Alpha colors can't be reduced to a single hue → treated as failing.
|
|
73
|
+
expect(focusRingColorMeetsContrast("rgba(36,34,38,0.2)", "#ffffff")).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("withDefaultStateLayer fills background when unset; knob wins when set", () => {
|
|
77
|
+
expect(withDefaultStateLayer({}, STATE_LAYER_HOVER)).toEqual(STATE_LAYER_HOVER);
|
|
78
|
+
expect(withDefaultStateLayer({ borderWidth: 2 }, STATE_LAYER_PRESS)).toEqual({
|
|
79
|
+
backgroundColor: "$backgroundPress",
|
|
80
|
+
borderWidth: 2,
|
|
81
|
+
});
|
|
82
|
+
expect(withDefaultStateLayer({ backgroundColor: "transparent" }, STATE_LAYER_HOVER)).toEqual({
|
|
83
|
+
backgroundColor: "transparent",
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("$outlineColor token contrast (DF-03 / DG-A11Y-01)", () => {
|
|
89
|
+
const themes = createThemesBuilder(
|
|
90
|
+
defaultBaseTheme,
|
|
91
|
+
defaultAccentTheme,
|
|
92
|
+
defaultBuilderOptions,
|
|
93
|
+
).themes() as Record<string, Record<string, string>>;
|
|
94
|
+
|
|
95
|
+
for (const scheme of ["light", "dark"] as const) {
|
|
96
|
+
it(`${scheme} $outlineColor clears ≥3:1 against color1 and color2`, () => {
|
|
97
|
+
const theme = themes[scheme];
|
|
98
|
+
expect(theme).toBeTruthy();
|
|
99
|
+
const ring = normalizeToHex(theme.outlineColor);
|
|
100
|
+
const bg1 = normalizeToHex(theme.color1);
|
|
101
|
+
const bg2 = normalizeToHex(theme.color2);
|
|
102
|
+
expect(ring).toBeTruthy();
|
|
103
|
+
expect(bg1).toBeTruthy();
|
|
104
|
+
expect(bg2).toBeTruthy();
|
|
105
|
+
const c1 = contrastRatio(relativeLuminance(ring!), relativeLuminance(bg1!));
|
|
106
|
+
const c2 = contrastRatio(relativeLuminance(ring!), relativeLuminance(bg2!));
|
|
107
|
+
expect(Math.min(c1, c2)).toBeGreaterThanOrEqual(FOCUS_RING_MIN_CONTRAST);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("resolveKnobs control recipes (W12)", () => {
|
|
113
|
+
it("always emits hover/press state layers and focus-visible ring", () => {
|
|
114
|
+
const { control } = resolveKnobs(defaultKnobs);
|
|
115
|
+
expect(control.hoverKnobProps).toEqual(STATE_LAYER_HOVER);
|
|
116
|
+
expect(control.pressKnobProps).toEqual(STATE_LAYER_PRESS);
|
|
117
|
+
expect(control.focusVisibleKnobProps).toMatchObject(FOCUS_VISIBLE_RING);
|
|
118
|
+
expect(control.focusVisibleKnobProps?.outlineWidth).toBeGreaterThanOrEqual(2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("merges knob overrides without removing the focus-visible ring", () => {
|
|
122
|
+
const { control } = resolveKnobs({
|
|
123
|
+
...defaultKnobs,
|
|
124
|
+
focusVisible: { borderWidth: "large", borderRadius: "full" },
|
|
125
|
+
hover: { borderWidth: "large" },
|
|
126
|
+
press: { fillStyle: "outlined" },
|
|
127
|
+
});
|
|
128
|
+
expect(control.focusVisibleKnobProps).toMatchObject({
|
|
129
|
+
...FOCUS_VISIBLE_RING,
|
|
130
|
+
borderWidth: 2,
|
|
131
|
+
borderRadius: "$12",
|
|
132
|
+
});
|
|
133
|
+
expect(control.hoverKnobProps).toMatchObject({
|
|
134
|
+
backgroundColor: "$backgroundHover",
|
|
135
|
+
borderWidth: 2,
|
|
136
|
+
});
|
|
137
|
+
expect(control.pressKnobProps?.backgroundColor).toBe("transparent");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("theme DEV warn (outline-none / positive-tabindex)", () => {
|
|
142
|
+
afterEach(() => {
|
|
143
|
+
__resetDevWarnSeen();
|
|
144
|
+
vi.restoreAllMocks();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("warnOutlineNone fires once for outline:none / width 0", () => {
|
|
148
|
+
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
149
|
+
warnOutlineNone({ outlineStyle: "none" }, { component: "Test" });
|
|
150
|
+
warnOutlineNone({ outlineStyle: "none" }, { component: "Test" });
|
|
151
|
+
warnOutlineNone({ outlineWidth: 0 }, { component: "Other" });
|
|
152
|
+
expect(spy).toHaveBeenCalledWith(expect.stringContaining("outline-none"));
|
|
153
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("warnOutlineNone ignores healthy focus rings", () => {
|
|
157
|
+
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
158
|
+
warnOutlineNone(FOCUS_VISIBLE_RING, { component: "Test" });
|
|
159
|
+
expect(spy).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("warnPositiveTabIndex fires for tabIndex > 0 only", () => {
|
|
163
|
+
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
164
|
+
warnPositiveTabIndex({ value: 0, component: "Test" });
|
|
165
|
+
warnPositiveTabIndex({ value: -1, component: "Test" });
|
|
166
|
+
warnPositiveTabIndex({ value: undefined, component: "Test" });
|
|
167
|
+
expect(spy).not.toHaveBeenCalled();
|
|
168
|
+
warnPositiveTabIndex({ value: 2, component: "Test" });
|
|
169
|
+
warnPositiveTabIndex({ value: 2, component: "Test" });
|
|
170
|
+
expect(spy).toHaveBeenCalledWith(expect.stringContaining("positive-tabindex"));
|
|
171
|
+
expect(spy).toHaveBeenCalledWith(expect.stringContaining("value=2"));
|
|
172
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { contrastRatio, normalizeToHex, relativeLuminance } from "./colorRules";
|
|
2
|
+
import type { ControlStateProps } from "./recipes";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* DG-A11Y-01 — keyboard focus-visible ring.
|
|
6
|
+
* Width ≥2px; color is the theme `$outlineColor` token (scheme-aware, ≥3:1
|
|
7
|
+
* against adjacent surface in both light and dark).
|
|
8
|
+
*/
|
|
9
|
+
export const FOCUS_RING_MIN_WIDTH = 2;
|
|
10
|
+
export const FOCUS_RING_MIN_CONTRAST = 3;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* True when a CSS color carries alpha < 1 (rgba/hsla/#rrggbbaa). Such a ring
|
|
14
|
+
* cannot guarantee the ≥3:1 floor — its effective contrast depends on the
|
|
15
|
+
* backdrop it composites over (this is exactly how the stock Tamagui ring,
|
|
16
|
+
* `rgba(ink, 0.2)`, dropped to 1.36:1). The guard reverts these to the solid
|
|
17
|
+
* `$outlineColor` token.
|
|
18
|
+
*/
|
|
19
|
+
function ringColorCarriesAlpha(color: string): boolean {
|
|
20
|
+
const s = color.trim().toLowerCase();
|
|
21
|
+
const fn = s.match(/^(?:rgba|hsla)\([^)]*[,/]\s*([\d.]+%?)\s*\)$/);
|
|
22
|
+
if (fn) {
|
|
23
|
+
const raw = fn[1];
|
|
24
|
+
const a = raw.endsWith("%") ? Number.parseFloat(raw) / 100 : Number.parseFloat(raw);
|
|
25
|
+
return Number.isFinite(a) && a < 0.999;
|
|
26
|
+
}
|
|
27
|
+
if (/^#[0-9a-f]{8}$/.test(s)) {
|
|
28
|
+
return Number.parseInt(s.slice(7, 9), 16) / 255 < 0.999;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* VERIFY helper (DG-A11Y-01 / Axiom 12): does a *resolved* ring color clear
|
|
35
|
+
* the ≥3:1 contrast floor against a given surface? Both args are CSS color
|
|
36
|
+
* strings (hex/hsl/named). Returns false when either can't be parsed (e.g. an
|
|
37
|
+
* alpha-blended color that can't be reduced to a single hue) so callers treat
|
|
38
|
+
* unresolved rings as failing.
|
|
39
|
+
*/
|
|
40
|
+
export function focusRingColorMeetsContrast(ringColor: string, surfaceColor: string): boolean {
|
|
41
|
+
const ring = normalizeToHex(ringColor);
|
|
42
|
+
const surface = normalizeToHex(surfaceColor);
|
|
43
|
+
if (!ring || !surface) return false;
|
|
44
|
+
return (
|
|
45
|
+
contrastRatio(relativeLuminance(ring), relativeLuminance(surface)) >= FOCUS_RING_MIN_CONTRAST
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const FOCUS_VISIBLE_RING = {
|
|
50
|
+
outlineWidth: FOCUS_RING_MIN_WIDTH,
|
|
51
|
+
outlineStyle: "solid" as const,
|
|
52
|
+
outlineColor: "$outlineColor",
|
|
53
|
+
outlineOffset: 2,
|
|
54
|
+
} satisfies ControlStateProps;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* DG-STATE-01 — Material-style state layers as theme tokens.
|
|
58
|
+
* Ramp lives in `defaults/builderOptions` (hover/press background steps);
|
|
59
|
+
* recipes overlay those tokens rather than inventing a second channel.
|
|
60
|
+
*/
|
|
61
|
+
export const STATE_LAYER_HOVER = {
|
|
62
|
+
backgroundColor: "$backgroundHover",
|
|
63
|
+
} satisfies ControlStateProps;
|
|
64
|
+
|
|
65
|
+
export const STATE_LAYER_PRESS = {
|
|
66
|
+
backgroundColor: "$backgroundPress",
|
|
67
|
+
} satisfies ControlStateProps;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Merge + clamp so variants/knobs cannot remove the focus-visible ring.
|
|
71
|
+
* Transparent / none / sub-2px outlines are restored to the baseline token.
|
|
72
|
+
* Extra style keys (e.g. `borderColor`) are preserved for consumer merges.
|
|
73
|
+
*/
|
|
74
|
+
export function ensureFocusVisibleRing<T extends ControlStateProps>(
|
|
75
|
+
override?: (T & ControlStateProps) | null,
|
|
76
|
+
): T & ControlStateProps {
|
|
77
|
+
const merged = {
|
|
78
|
+
...FOCUS_VISIBLE_RING,
|
|
79
|
+
...override,
|
|
80
|
+
} as T & ControlStateProps;
|
|
81
|
+
const width =
|
|
82
|
+
typeof merged.outlineWidth === "number" ? merged.outlineWidth : FOCUS_RING_MIN_WIDTH;
|
|
83
|
+
merged.outlineWidth = Math.max(width, FOCUS_RING_MIN_WIDTH);
|
|
84
|
+
if (merged.outlineStyle !== "solid") {
|
|
85
|
+
merged.outlineStyle = "solid";
|
|
86
|
+
}
|
|
87
|
+
if (
|
|
88
|
+
!merged.outlineColor ||
|
|
89
|
+
merged.outlineColor === "transparent" ||
|
|
90
|
+
merged.outlineColor === "none" ||
|
|
91
|
+
// Enforce COLOR, not just shape: an alpha-carrying ring can't guarantee
|
|
92
|
+
// ≥3:1 (its contrast depends on the backdrop), so restore the solid token.
|
|
93
|
+
(typeof merged.outlineColor === "string" && ringColorCarriesAlpha(merged.outlineColor))
|
|
94
|
+
) {
|
|
95
|
+
merged.outlineColor = FOCUS_VISIBLE_RING.outlineColor;
|
|
96
|
+
}
|
|
97
|
+
if (typeof merged.outlineOffset !== "number") {
|
|
98
|
+
merged.outlineOffset = FOCUS_VISIBLE_RING.outlineOffset;
|
|
99
|
+
}
|
|
100
|
+
return merged;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Apply default hover/press state-layer fill when knobs left background unset. */
|
|
104
|
+
export function withDefaultStateLayer(
|
|
105
|
+
control: ControlStateProps,
|
|
106
|
+
layer: ControlStateProps,
|
|
107
|
+
): ControlStateProps {
|
|
108
|
+
if (control.backgroundColor !== undefined) {
|
|
109
|
+
return control;
|
|
110
|
+
}
|
|
111
|
+
return { ...layer, ...control };
|
|
112
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { FontScopeTables } from "./FontKnobStyles";
|
|
3
|
+
import { buildFontKnobCss, fontCategoryMetrics } from "./FontKnobStyles";
|
|
4
|
+
import type { FontCategory } from "./knobs";
|
|
5
|
+
|
|
6
|
+
const FAMILIES: Partial<Record<FontCategory, string>> = {
|
|
7
|
+
slab: "'Roboto Slab', 'Rockwell', 'Courier New', serif",
|
|
8
|
+
mono: "monospace",
|
|
9
|
+
blackletter: "'UnifrakturCook', 'Fraktur', serif",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const resolveFamily = (category: FontCategory) => FAMILIES[category];
|
|
13
|
+
const noTables = () => undefined;
|
|
14
|
+
|
|
15
|
+
// Inter-shaped tables: display sizes tight (~1.16), small sizes loose, and
|
|
16
|
+
// negative display tracking — the exact shape the floors exist to correct.
|
|
17
|
+
const TABLES: Record<"heading" | "body", FontScopeTables> = {
|
|
18
|
+
heading: {
|
|
19
|
+
size: { 7: 28, 10: 64 },
|
|
20
|
+
lineHeight: { 7: 38, 10: 74 },
|
|
21
|
+
letterSpacing: { 7: 0, 10: -3 },
|
|
22
|
+
},
|
|
23
|
+
body: {
|
|
24
|
+
size: { 4: 14, 10: 46 },
|
|
25
|
+
lineHeight: { 4: 24, 10: 56 },
|
|
26
|
+
// positive caption tracking (tracking-by-size curve) + negative display
|
|
27
|
+
letterSpacing: { 1: 0.05, 10: -3 },
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
const resolveTables = (scope: "heading" | "body") => TABLES[scope];
|
|
31
|
+
|
|
32
|
+
describe("buildFontKnobCss", () => {
|
|
33
|
+
it("emits only zero-specificity default leading when both knobs are at the sans-serif default (LC-11)", () => {
|
|
34
|
+
const css = buildFontKnobCss("sans-serif", "sans-serif", resolveFamily, noTables);
|
|
35
|
+
expect(css).toContain(":where(:root .font_heading) { line-height: 1.25; }");
|
|
36
|
+
expect(css).toContain(":where(:root .font_body) { line-height: 1.5; }");
|
|
37
|
+
expect(css).not.toContain("--f-family");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("overrides the heading font scope for a non-default headingFont", () => {
|
|
41
|
+
const css = buildFontKnobCss("slab", "sans-serif", resolveFamily, noTables);
|
|
42
|
+
expect(css).toContain(
|
|
43
|
+
":root .font_heading { --f-family: 'Roboto Slab', 'Rockwell', 'Courier New', serif !important; }",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("overrides the body font scope for a non-default bodyFont", () => {
|
|
48
|
+
const css = buildFontKnobCss("sans-serif", "mono", resolveFamily, noTables);
|
|
49
|
+
expect(css).toContain(":root .font_body { --f-family: monospace !important; }");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("overrides both scopes independently", () => {
|
|
53
|
+
const css = buildFontKnobCss("blackletter", "mono", resolveFamily, noTables);
|
|
54
|
+
expect(css).toContain("--f-family: 'UnifrakturCook', 'Fraktur', serif !important");
|
|
55
|
+
expect(css).toContain(":root .font_body { --f-family: monospace !important");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("skips the family override for unregistered fonts but keeps the category's default leading", () => {
|
|
59
|
+
const css = buildFontKnobCss("cursive", "cursive", resolveFamily, noTables);
|
|
60
|
+
expect(css).not.toContain("--f-family");
|
|
61
|
+
// cursive heading floor 1.3 beats the 1.25 minimum; body max(1.5, 1.5)
|
|
62
|
+
expect(css).toContain(":where(:root .font_heading) { line-height: 1.3; }");
|
|
63
|
+
expect(css).toContain(":where(:root .font_body) { line-height: 1.5; }");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("lifts heading lineHeight to the category floor only where base leading is tighter", () => {
|
|
67
|
+
// mono heading floor 1.25: $10 base 74/64 = 1.16 -> 80px; $7 base 38/28 = 1.36 -> untouched
|
|
68
|
+
const css = buildFontKnobCss("mono", "sans-serif", resolveFamily, resolveTables);
|
|
69
|
+
expect(css).toContain("--f-lineHeight-10: 80px !important");
|
|
70
|
+
expect(css).not.toContain("--f-lineHeight-7");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("lifts body lineHeight to the body floor for large sizes", () => {
|
|
74
|
+
// mono body floor 1.45: $10 base 56/46 = 1.22 -> 67px; $4 base 24/14 = 1.71 -> untouched
|
|
75
|
+
const css = buildFontKnobCss("sans-serif", "mono", resolveFamily, resolveTables);
|
|
76
|
+
expect(css).toContain("--f-lineHeight-10: 67px !important");
|
|
77
|
+
expect(css).not.toContain("--f-lineHeight-4");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("zeroes all non-zero tracking for neutral-tracking categories only (LC-11)", () => {
|
|
81
|
+
const mono = buildFontKnobCss("mono", "sans-serif", resolveFamily, resolveTables);
|
|
82
|
+
expect(mono).toContain("--f-letterSpacing-10: 0px !important");
|
|
83
|
+
expect(mono).not.toContain("--f-letterSpacing-7"); // base 0 stays
|
|
84
|
+
// positive caption tracking is also Inter-specific: neutral zeroes it too
|
|
85
|
+
const monoBody = buildFontKnobCss("sans-serif", "mono", resolveFamily, resolveTables);
|
|
86
|
+
expect(monoBody).toContain("--f-letterSpacing-1: 0px !important");
|
|
87
|
+
expect(monoBody).toContain("--f-letterSpacing-10: 0px !important");
|
|
88
|
+
const slab = buildFontKnobCss("slab", "sans-serif", resolveFamily, resolveTables);
|
|
89
|
+
expect(slab).not.toContain("--f-letterSpacing"); // "inherit" tracking
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("keeps every category's heading floor at or above 1.15 and body at or above 1.4", () => {
|
|
93
|
+
for (const metrics of Object.values(fontCategoryMetrics)) {
|
|
94
|
+
expect(metrics.heading).toBeGreaterThanOrEqual(1.15);
|
|
95
|
+
expect(metrics.body).toBeGreaterThanOrEqual(1.4);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OPTICS hairline (Axiom 15) — native twin of `hairline.ts`.
|
|
3
|
+
*
|
|
4
|
+
* React Native's StyleSheet.hairlineWidth already resolves to one device
|
|
5
|
+
* pixel for the current screen density (e.g. 1/3 on 3x, 1/2 on 2x), so no
|
|
6
|
+
* fallback plumbing is needed; `className` entries are omitted (web-only).
|
|
7
|
+
*/
|
|
8
|
+
import { StyleSheet } from "react-native";
|
|
9
|
+
|
|
10
|
+
/** Divider line width: one device pixel for the current screen density. */
|
|
11
|
+
export const hairlineWidth: number = StyleSheet.hairlineWidth;
|
|
12
|
+
|
|
13
|
+
/** Spreadable divider treatments — see `hairline.ts` for usage. */
|
|
14
|
+
export const hairline = {
|
|
15
|
+
/** Standalone horizontal rule as a filled hairline-height line. */
|
|
16
|
+
line: {
|
|
17
|
+
height: hairlineWidth,
|
|
18
|
+
maxHeight: hairlineWidth,
|
|
19
|
+
flexBasis: hairlineWidth,
|
|
20
|
+
borderBottomWidth: 0,
|
|
21
|
+
backgroundColor: "$borderColor",
|
|
22
|
+
},
|
|
23
|
+
/** Standalone vertical rule as a filled hairline-width line. */
|
|
24
|
+
vline: {
|
|
25
|
+
width: hairlineWidth,
|
|
26
|
+
maxWidth: hairlineWidth,
|
|
27
|
+
flexBasis: hairlineWidth,
|
|
28
|
+
borderRightWidth: 0,
|
|
29
|
+
backgroundColor: "$borderColor",
|
|
30
|
+
},
|
|
31
|
+
/** Already-filled 1px line elements (e.g. menu separator views). */
|
|
32
|
+
height: { height: hairlineWidth },
|
|
33
|
+
bottom: { borderBottomWidth: hairlineWidth },
|
|
34
|
+
top: { borderTopWidth: hairlineWidth },
|
|
35
|
+
right: { borderRightWidth: hairlineWidth },
|
|
36
|
+
left: { borderLeftWidth: hairlineWidth },
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
/** Web-only fallback stylesheet — no-op on native. */
|
|
40
|
+
export const hairlineFallbackCss = "";
|
|
41
|
+
|
|
42
|
+
/** Web-only fallback mount — no-op on native. */
|
|
43
|
+
export function ensureHairlineFallback(): void {}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { ensureHairlineFallback, hairline, hairlineFallbackCss, hairlineWidth } from "./hairline";
|
|
3
|
+
|
|
4
|
+
describe("hairline (Axiom 15 OPTICS)", () => {
|
|
5
|
+
it("resolves half a CSS pixel on web", () => {
|
|
6
|
+
expect(hairlineWidth).toBe(0.5);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("renders standalone rules as filled fractional-size lines (Blink floors sub-1px borders)", () => {
|
|
10
|
+
expect(hairline.line).toEqual({
|
|
11
|
+
height: 0.5,
|
|
12
|
+
maxHeight: 0.5,
|
|
13
|
+
flexBasis: 0.5,
|
|
14
|
+
borderBottomWidth: 0,
|
|
15
|
+
backgroundColor: "$borderColor",
|
|
16
|
+
className: "mp-hairline-h",
|
|
17
|
+
});
|
|
18
|
+
expect(hairline.vline).toEqual({
|
|
19
|
+
width: 0.5,
|
|
20
|
+
maxWidth: 0.5,
|
|
21
|
+
flexBasis: 0.5,
|
|
22
|
+
borderRightWidth: 0,
|
|
23
|
+
backgroundColor: "$borderColor",
|
|
24
|
+
className: "mp-hairline-w",
|
|
25
|
+
});
|
|
26
|
+
expect(hairline.height).toEqual({ height: 0.5, className: "mp-hairline-h" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("pairs each edge treatment with its low-DPI fallback class", () => {
|
|
30
|
+
expect(hairline.bottom).toEqual({ borderBottomWidth: 0.5, className: "mp-hairline-b" });
|
|
31
|
+
expect(hairline.top).toEqual({ borderTopWidth: 0.5, className: "mp-hairline-t" });
|
|
32
|
+
expect(hairline.right).toEqual({ borderRightWidth: 0.5, className: "mp-hairline-r" });
|
|
33
|
+
expect(hairline.left).toEqual({ borderLeftWidth: 0.5, className: "mp-hairline-l" });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("falls back to 1px through a resolution media query on low-DPI screens", () => {
|
|
37
|
+
expect(hairlineFallbackCss).toMatch(/@media \(max-resolution: 1\.49dppx\)/);
|
|
38
|
+
// every fallback class resolves a full CSS pixel
|
|
39
|
+
for (const cls of ["b", "t", "r", "l", "h", "w", "ie"]) {
|
|
40
|
+
expect(hairlineFallbackCss).toContain(`.mp-hairline-${cls}`);
|
|
41
|
+
}
|
|
42
|
+
expect(hairlineFallbackCss).toContain("border-bottom-width: 1px !important");
|
|
43
|
+
expect(hairlineFallbackCss).toContain("border-inline-end-width: 1px !important");
|
|
44
|
+
// filled-line fallbacks must pin max-height/max-width too (the `line`
|
|
45
|
+
// 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");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("fallback stylesheet mount", () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
document.getElementById("mp-hairline-styles")?.remove();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("mounts the fallback stylesheet once (idempotent)", () => {
|
|
56
|
+
ensureHairlineFallback();
|
|
57
|
+
ensureHairlineFallback();
|
|
58
|
+
const tags = document.querySelectorAll("#mp-hairline-styles");
|
|
59
|
+
expect(tags).toHaveLength(1);
|
|
60
|
+
expect(tags[0]?.textContent).toBe(hairlineFallbackCss);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|