@tangle-network/brand 1.22.0 → 1.23.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.
@@ -0,0 +1,225 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { blockIn, contrastRatio, declIn, hexIn, hslIn, hslToRgb, oklchLightness } from "./css-test-utils";
5
+
6
+ /**
7
+ * The surface ladder and the interaction palette.
8
+ *
9
+ * A card, a card in a card and a field each need their own step from the plane
10
+ * they sit on, or the page reads as one flat sheet with text on it: that was
11
+ * Hospitality's "everything is on the same background" and "the search bar is
12
+ * the same colour as the background". The ladder is specified in OKLCH
13
+ * lightness (x100); the numbers below are ROOT-SPEC section (a), and a change
14
+ * to them is a design decision, not a refactor.
15
+ */
16
+ const read = (name: string) => readFileSync(path.resolve(import.meta.dirname, name), "utf8");
17
+ const tokens = read("tokens.css");
18
+ const themes = read("named-themes.css");
19
+ const rules = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, "");
20
+
21
+ const light = blockIn(themes, '[data-theme="hospitality"]:where(:not(.dark))');
22
+ const dark = blockIn(themes, '.dark[data-theme="hospitality"]');
23
+
24
+ /** A token as RGB, whether it is declared as an `H S% L%` triple or a hex. */
25
+ function rgbOf(css: string, token: string) {
26
+ return /^#/.test(declIn(css, token)) ? hexIn(css, token) : hslToRgb(hslIn(css, token));
27
+ }
28
+ const L = (css: string, token: string) => oklchLightness(rgbOf(css, token));
29
+
30
+ const ladder: Array<[role: string, token: string, light: number, dark: number]> = [
31
+ ["page", "hsl-background", 91.6, 16.9],
32
+ ["sidebar", "sidebar-background", 96.9, 21.6],
33
+ ["card", "hsl-card", 100, 31.1],
34
+ ["raised", "hsl-raised", 95.6, 37.9],
35
+ ["raised-2", "hsl-raised-2", 100, 43.9],
36
+ ["popover", "hsl-popover", 100, 42.0],
37
+ ["well on page", "well-page", 100, 12.4],
38
+ ["well in card", "well-card", 94.9, 12.4],
39
+ ["well in raised", "well-raised", 100, 22.1],
40
+ ["well in raised-2", "well-raised-2", 94.9, 22.1],
41
+ ["hairline", "hsl-border", 85.0, 42.0],
42
+ ["field border", "field-border", 60.0, 64.0],
43
+ ];
44
+
45
+ describe("Hospitality surface ladder", () => {
46
+ for (const [role, token, lightL, darkL] of ladder) {
47
+ it(`${role} (--${token}) lands at L ${lightL} light / ${darkL} dark`, () => {
48
+ expect(L(light, token)).toBeCloseTo(lightL, 0);
49
+ expect(L(dark, token)).toBeCloseTo(darkL, 0);
50
+ });
51
+ }
52
+
53
+ // Each plane against the one it is placed on. 3 points is the probe's
54
+ // flat-on-parent threshold; every step here clears 4.4.
55
+ const steps: Array<[child: string, parent: string]> = [
56
+ ["sidebar-background", "hsl-background"],
57
+ ["hsl-card", "hsl-background"],
58
+ ["hsl-raised", "hsl-card"],
59
+ ["hsl-raised-2", "hsl-raised"],
60
+ ["well-page", "hsl-background"],
61
+ ["well-card", "hsl-card"],
62
+ ["well-raised", "hsl-raised"],
63
+ ["well-raised-2", "hsl-raised-2"],
64
+ ];
65
+ it.each([
66
+ ["light", light],
67
+ ["dark", dark],
68
+ ])("never puts a plane or a well within 3 L points of its parent in %s", (_mode, css) => {
69
+ for (const [child, parent] of steps) {
70
+ expect(Math.abs(L(css, child) - L(css, parent)), `${child} on ${parent}`).toBeGreaterThanOrEqual(4.4);
71
+ }
72
+ });
73
+
74
+ it("sinks every dark well below the plane it sits on", () => {
75
+ for (const [well, parent] of steps.filter(([child]) => child.startsWith("well"))) {
76
+ expect(L(dark, well), `${well} under ${parent}`).toBeLessThan(L(dark, parent));
77
+ }
78
+ });
79
+
80
+ it("separates the dark card from the page at about 1.5:1", () => {
81
+ expect(contrastRatio(rgbOf(dark, "hsl-card"), rgbOf(dark, "hsl-background"))).toBeGreaterThanOrEqual(1.45);
82
+ });
83
+
84
+ it.each([
85
+ ["light", light],
86
+ ["dark", dark],
87
+ ])("draws a field border that clears 3:1 against the card and the well in %s (WCAG 1.4.11)", (_mode, css) => {
88
+ const border = rgbOf(css, "field-border");
89
+ expect(contrastRatio(border, rgbOf(css, "hsl-card")), "on the card").toBeGreaterThanOrEqual(3);
90
+ expect(contrastRatio(border, rgbOf(css, "well-card")), "on the well in a card").toBeGreaterThanOrEqual(3);
91
+ expect(declIn(css, "border-strong")).toBe(declIn(css, "field-border"));
92
+ });
93
+
94
+ it.each([
95
+ ["light", light, 4.5],
96
+ ["dark", dark, 4.5],
97
+ ])("keeps muted text readable from the page up to the popover in %s", (_mode, css, floor) => {
98
+ const muted = rgbOf(css, "hsl-muted-foreground");
99
+ for (const plane of ["hsl-background", "hsl-card", "hsl-raised", "hsl-popover"]) {
100
+ expect(contrastRatio(muted, rgbOf(css, plane)), `muted on ${plane}`).toBeGreaterThanOrEqual(floor);
101
+ }
102
+ expect(declIn(css, "text-dim")).toBe(declIn(css, "text-muted"));
103
+ });
104
+
105
+ it("lifts dark raised planes with a top highlight and leaves light planes to the shadow", () => {
106
+ expect(declIn(dark, "edge-highlight")).toBe("inset 0 1px 0 rgba(255, 255, 255, 0.06)");
107
+ expect(declIn(light, "edge-highlight")).toBe("0 0 0 0 transparent");
108
+ for (const css of [light, dark]) {
109
+ for (const token of ["shadow-card", "shadow-raised", "shadow-well"]) {
110
+ expect(declIn(css, token), `--${token}`).not.toBe("none");
111
+ }
112
+ }
113
+ });
114
+ });
115
+
116
+ describe("Hospitality status surfaces (StatusPill tones)", () => {
117
+ // ROOT-SPEC section (c). A status box must never read as a flat card: light
118
+ // fills sit at L 91-93, at least 7 under the white card; dark fills at L 37-38,
119
+ // about 6 over the card (the specified fills measure 5.9-6.8). Hue stays the
120
+ // meaning.
121
+ const hue = ([r, g, b]: [number, number, number]) => {
122
+ const max = Math.max(r, g, b);
123
+ const min = Math.min(r, g, b);
124
+ if (max === min) return 0;
125
+ const d = max - min;
126
+ const h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
127
+ return (h * 60 + 360) % 360;
128
+ };
129
+ const families: Record<string, (h: number) => boolean> = {
130
+ success: (h) => h >= 100 && h <= 170,
131
+ warning: (h) => h >= 25 && h <= 50,
132
+ danger: (h) => h <= 10 || h >= 350,
133
+ neutral: () => true,
134
+ };
135
+ for (const tone of ["success", "warning", "danger", "neutral"]) {
136
+ it.each([
137
+ ["light", light],
138
+ ["dark", dark],
139
+ ])(`${tone}: text clears 5:1 on its fill, and the fill steps off the card, in %s`, (mode, css) => {
140
+ const bg = hexIn(css, `surface-${tone}-bg`);
141
+ const text = hexIn(css, `surface-${tone}-text`);
142
+ expect(contrastRatio(text, bg)).toBeGreaterThanOrEqual(5);
143
+ const step = oklchLightness(bg) - L(css, "hsl-card");
144
+ if (mode === "light") {
145
+ expect(oklchLightness(bg)).toBeGreaterThanOrEqual(90.5);
146
+ expect(oklchLightness(bg)).toBeLessThanOrEqual(93.5);
147
+ expect(step).toBeLessThanOrEqual(-7);
148
+ } else {
149
+ expect(oklchLightness(bg)).toBeGreaterThanOrEqual(36.5);
150
+ expect(oklchLightness(bg)).toBeLessThanOrEqual(38.5);
151
+ expect(step).toBeGreaterThanOrEqual(5.8);
152
+ }
153
+ expect(families[tone]!(hue(text)), `${tone} text hue ${hue(text).toFixed(0)}`).toBe(true);
154
+ hexIn(css, `surface-${tone}-border`);
155
+ });
156
+ }
157
+ });
158
+
159
+ describe("shared surface context and interaction tokens (tokens.css)", () => {
160
+ const css = rules(tokens);
161
+ const shared = css.match(/([^{}]*)\{\s*--hsl-raised:[^}]*\}/);
162
+ const body = shared?.[0] ?? "";
163
+
164
+ it("is declared on every scope that can redefine the ring or the ladder", () => {
165
+ expect(shared, "the shared surface rule").not.toBeNull();
166
+ for (const scope of [":root", ".dark", ".light", "[data-sandbox-ui]", "[data-sandbox-theme]", "[data-theme]"]) {
167
+ expect(shared![1]).toContain(scope);
168
+ }
169
+ });
170
+
171
+ it("defaults every new ladder token to the plain spine, so other themes look as they did", () => {
172
+ expect(declIn(body, "hsl-raised")).toBe("var(--hsl-card)");
173
+ expect(declIn(body, "hsl-raised-2")).toBe("var(--hsl-card)");
174
+ for (const well of ["well-page", "well-card", "well-raised", "well-raised-2"]) {
175
+ expect(declIn(body, well)).toBe("var(--bg-input)");
176
+ }
177
+ expect(declIn(body, "well")).toBe("var(--well-page)");
178
+ expect(declIn(body, "field-border")).toBe("hsl(var(--hsl-border))");
179
+ expect(declIn(body, "shadow-raised")).toBe("var(--shadow-card)");
180
+ // A shadow token joins a comma list; "none" would void the list.
181
+ expect(declIn(body, "shadow-well")).toBe("0 0 0 0 transparent");
182
+ expect(declIn(body, "edge-highlight")).toBe("0 0 0 0 transparent");
183
+ });
184
+
185
+ it("derives the four interaction states from the focus ring at the specified alphas", () => {
186
+ expect(declIn(body, "ix-hover")).toBe("0 0 0 1px hsl(var(--hsl-ring) / 0.45), 0 0 0 4px hsl(var(--hsl-ring) / 0.16)");
187
+ expect(declIn(body, "ix-hover-border")).toBe("hsl(var(--hsl-ring) / 0.7)");
188
+ expect(declIn(body, "ix-press")).toBe("0 0 0 1px hsl(var(--hsl-ring) / 0.95), 0 0 0 4px hsl(var(--hsl-ring) / 0.3)");
189
+ expect(declIn(body, "ix-after")).toBe("0 0 0 1px hsl(var(--hsl-ring) / 0.6), 0 0 0 3px hsl(var(--hsl-ring) / 0.14)");
190
+ expect(declIn(body, "ix-sel-ring")).toBe("hsl(var(--hsl-ring) / 0.85)");
191
+ expect(declIn(body, "ix-sel-bg")).toBe("hsl(var(--hsl-ring) / 0.18)");
192
+ expect(declIn(body, "ix-hover-bg")).toBe("hsl(var(--hsl-ring) / 0.08)");
193
+ const lightTint = css.match(/([^{}]*)\{\s*--ix-sel-bg:\s*hsl\(var\(--hsl-ring\) \/ 0\.13\);\s*--ix-hover-bg:\s*hsl\(var\(--hsl-ring\) \/ 0\.07\);\s*\}/);
194
+ expect(lightTint, "light tint override").not.toBeNull();
195
+ expect(lightTint![1]).toContain('[data-theme="hospitality"]:where(:not(.dark))');
196
+ expect(lightTint![1]).toContain(".light");
197
+ });
198
+
199
+ it("re-declares --card and --well per card level, keyed only on the Card primitive", () => {
200
+ const level = (selector: string) => blockIn(tokens, selector);
201
+ const one = level('[data-slot="card"] :where(*)');
202
+ expect(declIn(one, "card")).toBe("var(--hsl-raised)");
203
+ expect(declIn(one, "well")).toBe("var(--well-card)");
204
+ const two = level('[data-slot="card"] [data-slot="card"] :where(*)');
205
+ expect(declIn(two, "card")).toBe("var(--hsl-raised-2)");
206
+ expect(declIn(two, "well")).toBe("var(--well-raised)");
207
+ const three = level('[data-slot="card"] [data-slot="card"] [data-slot="card"] :where(*)');
208
+ expect(declIn(three, "well")).toBe("var(--well-raised-2)");
209
+ // Never an app class: the rule is the brand's, the adoption is the Card's.
210
+ const contextSelectors = [...css.matchAll(/([^{}]*\[data-slot="card"\][^{}]*)\{/g)].map((m) => m[1]);
211
+ expect(contextSelectors.length).toBe(3);
212
+ for (const selector of contextSelectors) expect(selector).not.toMatch(/\.[a-z]/);
213
+ });
214
+
215
+ it("declares every ladder token in both Hospitality modes", () => {
216
+ for (const css of [light, dark]) {
217
+ for (const token of [
218
+ "hsl-raised", "hsl-raised-2", "well-page", "well-card", "well-raised", "well-raised-2",
219
+ "field-border", "edge-highlight", "shadow-card", "shadow-raised", "shadow-well",
220
+ ]) {
221
+ expect(css, `--${token}`).toMatch(new RegExp(`--${token}:`));
222
+ }
223
+ }
224
+ });
225
+ });
@@ -940,6 +940,85 @@
940
940
  --border-strong: color-mix(in srgb, hsl(var(--hsl-border)), hsl(var(--hsl-foreground)) 20%);
941
941
  }
942
942
 
943
+ /* Surface ladder and interaction states — the palette behind @tangle-network/ui's
944
+ * interaction helpers (`ixControl`, `ixField`, `ixSelected`, `ixRow` … in ui's
945
+ * lib/interaction.ts) and the Card primitive's surface context.
946
+ *
947
+ * LADDER. A card (`[data-slot="card"]`) re-declares two context variables for
948
+ * everything inside it, so nesting needs no app selector:
949
+ * --card the plane a nested card or `bg-card` box paints: --hsl-raised one
950
+ * level in, --hsl-raised-2 two levels in.
951
+ * --well the field fill: --well-page on the page, --well-card in a card,
952
+ * --well-raised in a card in a card, --well-raised-2 deeper still.
953
+ * A theme that tunes its ladder (Hospitality) sets these per mode. The defaults
954
+ * below reproduce the plain spine exactly — every level paints the card and
955
+ * every well is --bg-input — so a theme that does not set them looks as it did.
956
+ *
957
+ * INTERACTION. Four states, all from the focus ring hue (--hsl-ring), so one
958
+ * green or one indigo marks every interactive thing in a theme:
959
+ * hover 1px edge at .45 plus a 4px halo at .16 (--ix-hover)
960
+ * press 1px edge at .95 plus a 4px halo at .30 (--ix-press)
961
+ * after a mouse-focused control keeps a 1px .60 / 3px .14 ring (--ix-after)
962
+ * selected a .13 tint (.18 in dark) with a 1px inset ring at .85
963
+ * Keyboard focus-visible keeps --focus-border / --focus-halo above.
964
+ *
965
+ * Declared on every scope that can redefine --hsl-ring or the ladder, as the
966
+ * focus rule is, because a custom property is resolved where it is declared. */
967
+ :root,
968
+ .dark,
969
+ .light,
970
+ [data-sandbox-ui],
971
+ [data-sandbox-theme],
972
+ [data-theme] {
973
+ --hsl-raised: var(--hsl-card);
974
+ --hsl-raised-2: var(--hsl-card);
975
+ --well-page: var(--bg-input);
976
+ --well-card: var(--bg-input);
977
+ --well-raised: var(--bg-input);
978
+ --well-raised-2: var(--bg-input);
979
+ --well: var(--well-page);
980
+ --field-border: hsl(var(--hsl-border));
981
+ --shadow-raised: var(--shadow-card);
982
+ /* A shadow token joins a comma list, so "none" would void the whole list. */
983
+ --shadow-well: 0 0 0 0 transparent;
984
+ --edge-highlight: 0 0 0 0 transparent;
985
+ --ix-hover: 0 0 0 1px hsl(var(--hsl-ring) / 0.45), 0 0 0 4px hsl(var(--hsl-ring) / 0.16);
986
+ --ix-hover-border: hsl(var(--hsl-ring) / 0.7);
987
+ --ix-press: 0 0 0 1px hsl(var(--hsl-ring) / 0.95), 0 0 0 4px hsl(var(--hsl-ring) / 0.3);
988
+ --ix-after: 0 0 0 1px hsl(var(--hsl-ring) / 0.6), 0 0 0 3px hsl(var(--hsl-ring) / 0.14);
989
+ --ix-sel-ring: hsl(var(--hsl-ring) / 0.85);
990
+ --ix-sel-bg: hsl(var(--hsl-ring) / 0.18);
991
+ --ix-hover-bg: hsl(var(--hsl-ring) / 0.08);
992
+ }
993
+
994
+ /* Light planes need less tint for the same step. Same scope list as the chart
995
+ * light rule below; dark is the default. */
996
+ [data-sandbox-theme="vault"]:where(:not(.dark)),
997
+ [data-sandbox-theme="dawn"]:where(:not(.dark)),
998
+ [data-theme="hospitality"]:where(:not(.dark)),
999
+ [data-theme="website"]:where(:not(.dark)),
1000
+ [data-theme="light"],
1001
+ .light,
1002
+ [data-theme$="-light"] {
1003
+ --ix-sel-bg: hsl(var(--hsl-ring) / 0.13);
1004
+ --ix-hover-bg: hsl(var(--hsl-ring) / 0.07);
1005
+ }
1006
+
1007
+ /* Surface context: keyed only on the Card primitive's data-slot, never on app
1008
+ * classes. :where(*) keeps it at the specificity of one attribute, so a theme
1009
+ * scope opened inside a card still wins. */
1010
+ [data-slot="card"] :where(*) {
1011
+ --card: var(--hsl-raised);
1012
+ --well: var(--well-card);
1013
+ }
1014
+ [data-slot="card"] [data-slot="card"] :where(*) {
1015
+ --card: var(--hsl-raised-2);
1016
+ --well: var(--well-raised);
1017
+ }
1018
+ [data-slot="card"] [data-slot="card"] [data-slot="card"] :where(*) {
1019
+ --well: var(--well-raised-2);
1020
+ }
1021
+
943
1022
  /* Chart marks — the palette behind @tangle-network/charts.
944
1023
  *
945
1024
  * One accent marks the measure a reader decides on (a rate bar, a frontier