@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.
- package/dist/cjs/expo.cjs +8 -1
- package/dist/cjs/expo.native.js +11 -1
- package/dist/cjs/expo.native.js.map +1 -1
- package/dist/cjs/fonts/createFontLoader.native.js +5 -4
- package/dist/cjs/fonts/index.cjs +1 -0
- package/dist/cjs/fonts/index.native.js +1 -0
- package/dist/cjs/fonts/index.native.js.map +1 -1
- package/dist/cjs/fonts/interFaces.cjs +38 -0
- package/dist/cjs/fonts/interFaces.native.js +41 -0
- package/dist/cjs/fonts/interFaces.native.js.map +1 -0
- package/dist/cjs/theme/colorRules.cjs +23 -0
- package/dist/cjs/theme/colorRules.native.js +29 -0
- package/dist/cjs/theme/colorRules.native.js.map +1 -1
- package/dist/cjs/theme/controlTiers.cjs +1 -1
- package/dist/cjs/theme/controlTiers.native.js +1 -1
- package/dist/cjs/theme/controlTiers.native.js.map +1 -1
- package/dist/cjs/theme/defaults/fonts.cjs +4 -17
- package/dist/cjs/theme/defaults/fonts.native.js +7 -17
- package/dist/cjs/theme/defaults/fonts.native.js.map +1 -1
- package/dist/cjs/theme/glyphTint.cjs +2 -25
- package/dist/cjs/theme/glyphTint.native.js +2 -31
- package/dist/cjs/theme/glyphTint.native.js.map +1 -1
- package/dist/cjs/theme/hairline.cjs +20 -3
- package/dist/cjs/theme/readableColor.cjs +36 -0
- package/dist/cjs/theme/readableColor.native.js +39 -0
- package/dist/cjs/theme/readableColor.native.js.map +1 -1
- package/dist/cjs/theme/resolveKnobs.cjs +162 -74
- package/dist/cjs/theme/resolveKnobs.native.js +170 -81
- package/dist/cjs/theme/resolveKnobs.native.js.map +1 -1
- package/dist/esm/expo.mjs +7 -1
- package/dist/esm/expo.mjs.map +1 -1
- package/dist/esm/expo.native.js +10 -1
- package/dist/esm/expo.native.js.map +1 -1
- package/dist/esm/fonts/createFontLoader.native.js +5 -4
- package/dist/esm/fonts/index.mjs +1 -0
- package/dist/esm/fonts/index.mjs.map +1 -1
- package/dist/esm/fonts/index.native.js +1 -0
- package/dist/esm/fonts/index.native.js.map +1 -1
- package/dist/esm/fonts/interFaces.mjs +12 -0
- package/dist/esm/fonts/interFaces.mjs.map +1 -0
- package/dist/esm/fonts/interFaces.native.js +12 -0
- package/dist/esm/fonts/interFaces.native.js.map +1 -0
- package/dist/esm/theme/colorRules.mjs +23 -1
- package/dist/esm/theme/colorRules.mjs.map +1 -1
- package/dist/esm/theme/colorRules.native.js +29 -1
- package/dist/esm/theme/colorRules.native.js.map +1 -1
- package/dist/esm/theme/controlTiers.mjs +1 -1
- package/dist/esm/theme/controlTiers.mjs.map +1 -1
- package/dist/esm/theme/controlTiers.native.js +1 -1
- package/dist/esm/theme/controlTiers.native.js.map +1 -1
- package/dist/esm/theme/defaults/fonts.mjs +4 -17
- package/dist/esm/theme/defaults/fonts.mjs.map +1 -1
- package/dist/esm/theme/defaults/fonts.native.js +7 -17
- package/dist/esm/theme/defaults/fonts.native.js.map +1 -1
- package/dist/esm/theme/glyphTint.mjs +2 -24
- package/dist/esm/theme/glyphTint.mjs.map +1 -1
- package/dist/esm/theme/glyphTint.native.js +2 -30
- package/dist/esm/theme/glyphTint.native.js.map +1 -1
- package/dist/esm/theme/hairline.mjs +20 -4
- package/dist/esm/theme/hairline.mjs.map +1 -1
- package/dist/esm/theme/readableColor.mjs +36 -2
- package/dist/esm/theme/readableColor.mjs.map +1 -1
- package/dist/esm/theme/readableColor.native.js +39 -2
- package/dist/esm/theme/readableColor.native.js.map +1 -1
- package/dist/esm/theme/resolveKnobs.mjs +162 -74
- package/dist/esm/theme/resolveKnobs.mjs.map +1 -1
- package/dist/esm/theme/resolveKnobs.native.js +170 -81
- package/dist/esm/theme/resolveKnobs.native.js.map +1 -1
- package/package.json +7 -7
- package/src/expo.ts +20 -0
- package/src/fonts/createFontLoader.ts +8 -4
- package/src/fonts/index.ts +1 -0
- package/src/fonts/interFaces.spec.ts +87 -0
- package/src/fonts/interFaces.ts +26 -0
- package/src/theme/accentButtonLabel.spec.ts +116 -0
- package/src/theme/colorRules.ts +41 -0
- package/src/theme/controlTiers.ts +6 -4
- package/src/theme/defaults/fonts.spec.ts +9 -2
- package/src/theme/defaults/fonts.ts +5 -8
- package/src/theme/devtools/ColorLineVisualizer.spec.tsx +104 -0
- package/src/theme/glyphTint.spec.ts +30 -2
- package/src/theme/glyphTint.ts +1 -47
- package/src/theme/hairline.spec.ts +60 -3
- package/src/theme/hairline.ts +35 -5
- package/src/theme/readableColor.ts +49 -0
- package/src/theme/resolveKnobs.ts +172 -101
- package/types/expo.d.ts +10 -0
- package/types/expo.d.ts.map +1 -1
- package/types/fonts/createFontLoader.d.ts.map +1 -1
- package/types/fonts/index.d.ts +1 -0
- package/types/fonts/index.d.ts.map +1 -1
- package/types/fonts/interFaces.d.ts +23 -0
- package/types/fonts/interFaces.d.ts.map +1 -0
- package/types/theme/colorRules.d.ts +6 -0
- package/types/theme/colorRules.d.ts.map +1 -1
- package/types/theme/controlTiers.d.ts +5 -3
- package/types/theme/controlTiers.d.ts.map +1 -1
- package/types/theme/defaults/fonts.d.ts +30 -30
- package/types/theme/defaults/fonts.d.ts.map +1 -1
- package/types/theme/glyphTint.d.ts +0 -6
- package/types/theme/glyphTint.d.ts.map +1 -1
- package/types/theme/hairline.d.ts +14 -3
- package/types/theme/hairline.d.ts.map +1 -1
- package/types/theme/readableColor.d.ts +19 -0
- package/types/theme/readableColor.d.ts.map +1 -1
- 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
|
+
});
|
package/src/theme/colorRules.ts
CHANGED
|
@@ -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
|
-
* `$
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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: "$
|
|
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
|
-
[
|
|
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("
|
|
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
|
-
|
|
99
|
-
|
|
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
|
+
});
|
package/src/theme/glyphTint.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
47
|
-
expect(hairlineFallbackCss).toContain(
|
|
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
|
});
|
package/src/theme/hairline.ts
CHANGED
|
@@ -81,21 +81,50 @@ export const hairline = {
|
|
|
81
81
|
const STYLE_TAG_ID = "mp-hairline-styles";
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|