@tailor-platform/app-shell 1.18.0 → 1.19.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.
@@ -209,7 +209,7 @@ saying so, and it is never a substitute for scrolling or paging.
209
209
 
210
210
  Columns, left to right:
211
211
 
212
- 1. **Identity** — the item name, with the SKU beneath it in muted mono `text-xs`. Two facts, one column.
212
+ 1. **Identity** — the item name, with the SKU beneath it in muted `code-sm`. Two facts, one column.
213
213
  2. **The record's own numbers** — quantity, unit of measure, unit price. Every document has these.
214
214
  3. **Numbers from related records**, where they exist and the reader needs them (see below).
215
215
  4. **Subtotal**, derived, last.
@@ -535,7 +535,7 @@ says "clickable" without a hover, and the resting underline is noise. Add
535
535
  - **Internal** (another route in the app) → the `Link` exported by app-shell, with `to`. Never a bare `<a href>` for an in-app route.
536
536
  - **External** (another system) → `<a target="_blank" rel="noopener noreferrer">` with a lucide `ExternalLink` at `size-3` after the label, so the new tab is signposted. There is no dedicated external-link component; this is the whole recipe.
537
537
  - **Identifiers** keep their `font-mono` on top of the link style.
538
- - **A `render` field owns its typography.** `DescriptionCard` applies none of its value styling to custom output, so a bare string from `render` reads a size larger than the values around it. Wrap it in `text-sm font-medium text-foreground` — the reference implementation's `Value` helper.
538
+ - **A `render` field owns its typography.** `DescriptionCard` applies none of its value styling to custom output, so a bare string from `render` does not match the values around it. Wrap it in `Text` with `variant="body-md"` and `text-foreground`, the same role the built-in values use — the reference implementation's `Value` helper.
539
539
 
540
540
  ## Patterns for the parts
541
541
 
@@ -571,6 +571,7 @@ import {
571
571
  Link,
572
572
  Table,
573
573
  Tabs,
574
+ Text,
574
575
  } from "@tailor-platform/app-shell";
575
576
  import type { ActionPanelProps } from "@tailor-platform/app-shell";
576
577
  import {
@@ -614,15 +615,17 @@ const formatDay = (value: string) =>
614
615
 
615
616
  /**
616
617
  * A `render` field owns its own presentation — DescriptionCard applies none of
617
- * the built-in value typography to it — so match that style here or the two
618
- * dates read a size larger than every other value on the card.
618
+ * the built-in value typography to it — so use the same role here (`body-md`)
619
+ * or the two dates do not match every other value on the card.
619
620
  */
620
621
  const Value = ({ children }: { children: React.ReactNode }) => (
621
- <span className="text-sm font-medium text-foreground">{children}</span>
622
+ <Text variant="body-md" className="text-foreground" render={<span />}>
623
+ {children}
624
+ </Text>
622
625
  );
623
626
 
624
627
  /** House link: primary colour at rest, underline only on hover. */
625
- const DOC_LINK = "font-mono text-xs text-primary underline-offset-4 hover:underline";
628
+ const DOC_LINK = "font-mono text-code-sm text-primary underline-offset-4 hover:underline";
626
629
 
627
630
  export default function PurchaseOrderDetailPage({
628
631
  order,
@@ -676,16 +679,18 @@ export default function PurchaseOrderDetailPage({
676
679
  destructive: it's a problem to clear, not information to read.
677
680
  Each hold is released on its own, beside its own reason. */}
678
681
  {order.holds.length > 0 && (
679
- <Card.Root className="border-destructive">
682
+ <Card.Root className="border-danger-solid">
680
683
  <Card.Header title="On hold" />
681
684
  <Card.Content className="flex flex-col gap-3">
682
685
  {order.holds.map((hold) => (
683
686
  <div key={hold.id} className="flex items-start justify-between gap-4">
684
687
  <div>
685
- <p className="text-sm font-medium text-foreground">{hold.reason}</p>
686
- <p className="text-xs text-muted-foreground">
688
+ <Text variant="label-md" className="text-foreground" render={<p />}>
689
+ {hold.reason}
690
+ </Text>
691
+ <Text variant="body-sm" className="text-muted-foreground">
687
692
  Placed by {hold.placedBy} on {formatDay(hold.placedAt)}
688
- </p>
693
+ </Text>
689
694
  </div>
690
695
  <Button
691
696
  size="sm"
@@ -810,7 +815,9 @@ export default function PurchaseOrderDetailPage({
810
815
  <Card.Header title="Line items" />
811
816
  <Card.Content padding="none">
812
817
  {order.lineItems.length === 0 ? (
813
- <p className="px-6 text-sm text-muted-foreground">No line items on this order.</p>
818
+ <Text variant="body-md" className="px-6 text-muted-foreground">
819
+ No line items on this order.
820
+ </Text>
814
821
  ) : (
815
822
  <Table.Root>
816
823
  <Table.Header>
@@ -829,9 +836,9 @@ export default function PurchaseOrderDetailPage({
829
836
  {/* Two facts, one column. */}
830
837
  <Table.Cell>
831
838
  <span className="block">{line.itemName}</span>
832
- <span className="block font-mono text-xs text-muted-foreground">
839
+ <Text variant="code-sm" className="block text-muted-foreground">
833
840
  {line.sku}
834
- </span>
841
+ </Text>
835
842
  </Table.Cell>
836
843
  <Table.Cell align="right" className="tabular-nums">
837
844
  {line.qty}
@@ -844,9 +851,13 @@ export default function PurchaseOrderDetailPage({
844
851
  <Table.Cell align="right" className="tabular-nums">
845
852
  {line.received}
846
853
  {line.received !== line.qty && (
847
- <span className="ml-1 text-xs text-muted-foreground">
854
+ <Text
855
+ variant="body-sm"
856
+ className="ml-1 text-muted-foreground"
857
+ render={<span />}
858
+ >
848
859
  ({line.qty - line.received})
849
- </span>
860
+ </Text>
850
861
  )}
851
862
  </Table.Cell>
852
863
  <Table.Cell align="right" className="tabular-nums">
@@ -877,9 +888,9 @@ export default function PurchaseOrderDetailPage({
877
888
  <Card.Header title="Goods receipts" />
878
889
  <Card.Content padding="none">
879
890
  {order.goodsReceipts.length === 0 ? (
880
- <p className="px-6 text-sm text-muted-foreground">
891
+ <Text variant="body-md" className="px-6 text-muted-foreground">
881
892
  No goods receipts linked to this order.
882
- </p>
893
+ </Text>
883
894
  ) : (
884
895
  <Table.Root>
885
896
  <Table.Header>
@@ -922,15 +933,15 @@ export default function PurchaseOrderDetailPage({
922
933
  href={file.href}
923
934
  target="_blank"
924
935
  rel="noopener noreferrer"
925
- className="inline-flex items-center gap-2 text-sm text-primary underline-offset-4 hover:underline"
936
+ className="inline-flex items-center gap-2 text-body-md text-primary underline-offset-4 hover:underline"
926
937
  >
927
938
  <FileText className="size-4 shrink-0" />
928
939
  {file.name}
929
940
  <ExternalLink className="size-3 shrink-0" />
930
941
  </a>
931
- <span className="shrink-0 text-xs text-muted-foreground">
942
+ <Text variant="body-sm" className="shrink-0 text-muted-foreground" render={<span />}>
932
943
  {file.sizeLabel} · {formatDay(file.uploadedAt)}
933
- </span>
944
+ </Text>
934
945
  </div>
935
946
  ))}
936
947
  </Card.Content>
@@ -1011,9 +1022,9 @@ export default function PurchaseOrderDetailPage({
1011
1022
  <Table.Row key={line.id}>
1012
1023
  <Table.Cell>
1013
1024
  <span className="block">{line.account}</span>
1014
- <span className="block font-mono text-xs text-muted-foreground">
1025
+ <Text variant="code-sm" className="block text-muted-foreground">
1015
1026
  {line.accountCode}
1016
- </span>
1027
+ </Text>
1017
1028
  </Table.Cell>
1018
1029
  <Table.Cell align="right" className="tabular-nums">
1019
1030
  {line.debit === null ? "—" : `${line.debit.toLocaleString()}`}
@@ -1047,14 +1058,14 @@ export default function PurchaseOrderDetailPage({
1047
1058
  href={externalSource.href}
1048
1059
  target="_blank"
1049
1060
  rel="noopener noreferrer"
1050
- className="inline-flex items-center gap-1 text-sm text-primary underline-offset-4 hover:underline"
1061
+ className="inline-flex items-center gap-1 text-body-md text-primary underline-offset-4 hover:underline"
1051
1062
  >
1052
1063
  {externalSource.recordLabel}
1053
1064
  <ExternalLink className="size-3" />
1054
1065
  </a>
1055
- <p className="mt-1 text-xs text-muted-foreground">
1066
+ <Text variant="body-sm" className="mt-1 text-muted-foreground">
1056
1067
  Synced {externalSource.syncedAt.toLocaleDateString()}
1057
- </p>
1068
+ </Text>
1058
1069
  </Card.Content>
1059
1070
  </Card.Root>
1060
1071
  )}
@@ -147,7 +147,9 @@ placeholder.
147
147
  render_ — a submit gate, a dependent placeholder, a live character count. That state is
148
148
  load-bearing, not mirrored; see `form/composer`.
149
149
  - **`Field.Control` is already a styled input.** Write `<Field.Control />`, not
150
- `<Field.Control render={<Input />} />`.
150
+ `<Field.Control render={<Input />} />`. Every other control — `Select`, `Combobox`,
151
+ `Autocomplete`, `Checkbox`, `Textarea`, `DateField`, `DatePicker` — goes directly under
152
+ `Field.Root`. Passing one to `Field.Control`'s `render` draws a second frame around it.
151
153
  - **Object items need `itemToStringValue`.** Items shaped `{ value, label }` submit `value`
152
154
  automatically; any other object submits as a JSON string unless you supply it.
153
155
  - **Server errors go through `Form`'s `errors` prop**, keyed by field `name` — not a toast or a
@@ -165,4 +167,5 @@ placeholder.
165
167
  - Registering the create path as a separate top-level route — that unmounts the parent list
166
168
  - Reaching for React Hook Form on a form this size — `onFormSubmit` already covers it
167
169
  - Holding a `Select`/`Combobox` value in `useState` just to submit it — `Field.Root` already does
170
+ - Wrapping a `Select`/`Combobox` in `Field.Control` (`render={<Select />}`) — a double frame
168
171
  - Surfacing API validation failures in a toast or banner instead of routing them via `errors`
@@ -1,227 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import { describe, expect, it } from "vitest";
5
-
6
- /**
7
- * WCAG 1.4.3 contrast checks for the semantic colour roles, computed from
8
- * `themes/default.css` itself. Nothing is copied into the test, so a change to
9
- * a hue step or an alias that breaks a pair fails here.
10
- *
11
- * Scope of the guarantee: the default state of each pair, on the default
12
- * palette's `--card` and `--background`. Not covered on purpose:
13
- * - hover states (`surface-hover`) against 4.5:1: subtle-badge hover text is
14
- * below 4.5:1 for some roles (danger and success in light mode). They are held
15
- * at a documented floor (MIN_HOVER_TEXT) so they cannot get worse unnoticed,
16
- * see decisions/semantic-color-roles.md;
17
- * - branded palettes (cream, bloom) and their tinted shell backgrounds;
18
- * - the neutral intent, whose values are the pre-existing system tokens;
19
- * - the warning `solid` fill against the card (a fill, not a text pair).
20
- */
21
- const here = dirname(fileURLToPath(import.meta.url));
22
- const css = readFileSync(join(here, "themes/default.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
23
-
24
- type Rgba = { r: number; g: number; b: number; a: number };
25
- type Mode = "light" | "dark";
26
-
27
- /** Custom properties in effect on <html> for a mode, applying rules in source order. */
28
- function variablesFor(mode: Mode): Map<string, string> {
29
- const vars = new Map<string, string>();
30
- for (const rule of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
31
- const selectors = rule[1].split(",").map((s) => s.trim());
32
- // <html class="dark"> matches both `:root` and `.dark`; light matches `:root` only.
33
- const applies = selectors.some((s) => s === ":root" || (mode === "dark" && s === ".dark"));
34
- if (!applies) continue;
35
- for (const decl of rule[2].matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
36
- vars.set(decl[1], decl[2].replace(/\s+/g, " ").trim());
37
- }
38
- }
39
- return vars;
40
- }
41
-
42
- function parseColor(value: string, vars: Map<string, string>): Rgba {
43
- const v = value.trim();
44
- const ref = /^var\((--[a-z0-9-]+)\)$/.exec(v);
45
- if (ref) {
46
- const target = vars.get(ref[1]);
47
- if (target === undefined) throw new Error(`Undefined variable ${ref[1]}`);
48
- return parseColor(target, vars);
49
- }
50
- const hex = /^#([0-9a-f]{3,8})$/i.exec(v);
51
- if (hex) {
52
- let h = hex[1];
53
- if (h.length === 3 || h.length === 4) h = [...h].map((c) => c + c).join("");
54
- if (h.length !== 6 && h.length !== 8) throw new Error(`Bad hex colour ${v}`);
55
- return {
56
- r: parseInt(h.slice(0, 2), 16),
57
- g: parseInt(h.slice(2, 4), 16),
58
- b: parseInt(h.slice(4, 6), 16),
59
- a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
60
- };
61
- }
62
- const fn = /^rgba?\(([^)]+)\)$/.exec(v);
63
- if (fn) {
64
- const [r, g, b, a = "1"] = fn[1].split(",").map((p) => p.trim());
65
- return { r: Number(r), g: Number(g), b: Number(b), a: Number(a) };
66
- }
67
- throw new Error(`Unsupported colour syntax: ${v}`);
68
- }
69
-
70
- function over(top: Rgba, base: Rgba): Rgba {
71
- const a = top.a + base.a * (1 - top.a);
72
- const mix = (t: number, b: number) => (t * top.a + b * base.a * (1 - top.a)) / a;
73
- return { r: mix(top.r, base.r), g: mix(top.g, base.g), b: mix(top.b, base.b), a };
74
- }
75
-
76
- function linearize(channel: number): number {
77
- const s = channel / 255;
78
- return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
79
- }
80
-
81
- function luminance({ r, g, b }: Rgba): number {
82
- return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
83
- }
84
-
85
- function contrast(a: Rgba, b: Rgba): number {
86
- const [hi, lo] = [luminance(a), luminance(b)].toSorted((x, y) => y - x);
87
- return (hi + 0.05) / (lo + 0.05);
88
- }
89
-
90
- const MIN_TEXT = 4.5;
91
- /** WCAG 1.4.11 non-text contrast, for the invalid-input border. */
92
- const MIN_NON_TEXT = 3;
93
- /** Known and accepted: subtle hover text is below AA for some roles. A floor, not a pass. */
94
- const MIN_HOVER_TEXT = 4.2;
95
- const modes: Mode[] = ["light", "dark"];
96
- const intents = ["info", "success", "warning", "danger"] as const;
97
- const alertVariantFor = { info: "info", success: "success", warning: "warning", danger: "error" };
98
- const pages = ["--card", "--background"] as const;
99
-
100
- describe.each(modes)("semantic colour contrast (%s)", (mode) => {
101
- const vars = variablesFor(mode);
102
- const color = (name: string) => parseColor(`var(${name})`, vars);
103
- /** Foreground drawn on a translucent surface that sits on a page colour. */
104
- const pair = (fg: string, surface: string, page: string) => {
105
- const base = color(page);
106
- const bg = over(color(surface), base);
107
- return contrast(over(color(fg), bg), bg);
108
- };
109
-
110
- it("resolves the page colours to opaque values", () => {
111
- for (const page of pages) expect(color(page).a).toBe(1);
112
- });
113
-
114
- describe.each(intents)("%s", (intent) => {
115
- const role = (name: string) => `--${intent}-${name}`;
116
-
117
- it("the contrast role meets 4.5:1 on solid", () => {
118
- expect(contrast(color(role("contrast")), color(role("solid")))).toBeGreaterThanOrEqual(
119
- MIN_TEXT,
120
- );
121
- });
122
-
123
- it("the contrast role meets 4.5:1 on solid-hover", () => {
124
- expect(contrast(color(role("contrast")), color(role("solid-hover")))).toBeGreaterThanOrEqual(
125
- MIN_TEXT,
126
- );
127
- });
128
-
129
- it.each(pages)("text meets 4.5:1 on surface over %s", (page) => {
130
- expect(pair(role("text"), role("surface"), page)).toBeGreaterThanOrEqual(MIN_TEXT);
131
- });
132
-
133
- it.each(pages)(
134
- "text stays above the documented hover floor on surface-hover over %s",
135
- (page) => {
136
- expect(pair(role("text"), role("surface-hover"), page)).toBeGreaterThanOrEqual(
137
- MIN_HOVER_TEXT,
138
- );
139
- },
140
- );
141
-
142
- const variant = alertVariantFor[intent];
143
-
144
- it.each(pages)("alert foreground meets 4.5:1 on alert background over %s", (page) => {
145
- expect(
146
- pair(`--alert-${variant}-foreground`, `--alert-${variant}-background`, page),
147
- ).toBeGreaterThanOrEqual(MIN_TEXT);
148
- });
149
-
150
- it.each(pages)("alert description meets 4.5:1 on alert background over %s", (page) => {
151
- expect(
152
- pair(`--alert-${variant}-foreground-muted`, `--alert-${variant}-background`, page),
153
- ).toBeGreaterThanOrEqual(MIN_TEXT);
154
- });
155
- });
156
-
157
- describe("destructive aliases", () => {
158
- it.each([
159
- ["--destructive", "--danger-solid"],
160
- ["--destructive-foreground", "--danger-contrast"],
161
- ])("%s resolves to %s", (alias, role) => {
162
- expect(vars.get(alias)).toBe(`var(${role})`);
163
- expect(color(alias)).toEqual(color(role));
164
- });
165
-
166
- it.each(pages)("danger solid meets 3:1 against %s (invalid-input border)", (page) => {
167
- expect(contrast(color("--danger-solid"), color(page))).toBeGreaterThanOrEqual(MIN_NON_TEXT);
168
- });
169
- });
170
- });
171
-
172
- describe("default palette neutral tokens", () => {
173
- // Pinned when the BRAND and SYSTEM tokens moved from literals onto the gray,
174
- // white and alpha scales. That move was meant to keep every value, so a step
175
- // change that alters one of these colours fails here.
176
- const expected: Record<Mode, Record<string, string>> = {
177
- light: {
178
- "--foreground": "#0a0a0a",
179
- "--muted-foreground": "#737373",
180
- "--primary": "#171717",
181
- "--primary-foreground": "#fafafa",
182
- "--secondary": "#f5f5f5",
183
- "--secondary-foreground": "#171717",
184
- "--accent-foreground": "#0a0a0a",
185
- "--background": "#fafafa",
186
- "--card": "#ffffff",
187
- "--card-foreground": "#0a0a0a",
188
- "--popover": "#ffffff",
189
- "--popover-foreground": "#0a0a0a",
190
- "--muted": "#f5f5f5",
191
- "--border": "#e5e5e5",
192
- "--input": "#e5e5e5",
193
- "--sidebar": "#fafafa",
194
- "--sidebar-foreground": "#0a0a0a",
195
- "--sidebar-border": "#e5e5e5",
196
- "--backdrop": "rgba(0, 0, 0, 0.5)",
197
- },
198
- dark: {
199
- "--foreground": "#fafafa",
200
- "--muted-foreground": "#a3a3a3",
201
- "--primary": "#e5e5e5",
202
- "--primary-foreground": "#171717",
203
- "--secondary": "#262626",
204
- "--secondary-foreground": "#fafafa",
205
- "--accent-foreground": "#fafafa",
206
- "--background": "#0a0a0a",
207
- "--card": "#171717",
208
- "--card-foreground": "#fafafa",
209
- "--popover": "#262626",
210
- "--popover-foreground": "#fafafa",
211
- "--muted": "#262626",
212
- "--border": "rgba(255, 255, 255, 0.1)",
213
- "--input": "rgba(255, 255, 255, 0.15)",
214
- "--sidebar": "#0a0a0a",
215
- "--sidebar-foreground": "#fafafa",
216
- "--sidebar-border": "rgba(255, 255, 255, 0.1)",
217
- "--backdrop": "rgba(0, 0, 0, 0.5)",
218
- },
219
- };
220
-
221
- describe.each(["light", "dark"] as const)("%s mode", (mode) => {
222
- const vars = variablesFor(mode);
223
- it.each(Object.entries(expected[mode]))("%s resolves to %s", (token, value) => {
224
- expect(parseColor(`var(${token})`, vars)).toEqual(parseColor(value, vars));
225
- });
226
- });
227
- });
@@ -1,182 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import { describe, expect, it } from "vitest";
5
-
6
- /**
7
- * The bridge is what makes semantic tokens reachable as Tailwind utilities in
8
- * consumer apps (`bg-alert-info-background`). Nothing else in the suite covers
9
- * it: component snapshots record *class names*, not resolved CSS, so dropping a
10
- * bridge entry would leave every other test green while the utility silently
11
- * stops producing colour.
12
- */
13
- const here = dirname(fileURLToPath(import.meta.url));
14
- const bridge = readFileSync(join(here, "theme.bridge.css"), "utf8");
15
- const defaultTheme = readFileSync(join(here, "themes/default.css"), "utf8");
16
-
17
- /** Semantic colour roles: 5 intents x 8 roles, named `--{intent}-{role}`. */
18
- const SEMANTIC_PREFIX = "(?:info|success|warning|danger|neutral)";
19
-
20
- /** Token names defined in the default palette for a given prefix, e.g. `alert`. */
21
- function definedTokens(prefix: string): string[] {
22
- const matches = defaultTheme.matchAll(new RegExp(`^\\s*--(${prefix}-[a-z-]+):`, "gm"));
23
- return [...new Set([...matches].map((m) => m[1]))].toSorted();
24
- }
25
-
26
- /** Token names bridged as `--color-<name>`, mapped to the var() they resolve to. */
27
- function bridgedTokens(prefix: string): Map<string, string> {
28
- const matches = bridge.matchAll(
29
- new RegExp(`^\\s*--color-(${prefix}-[a-z-]+):\\s*var\\(--([a-z-]+)\\);`, "gm"),
30
- );
31
- return new Map([...matches].map((m) => [m[1], m[2]]));
32
- }
33
-
34
- describe("theme bridge", () => {
35
- describe.each([
36
- ["alert", "alert"],
37
- ["status", "status"],
38
- ["semantic colour role", SEMANTIC_PREFIX],
39
- ])("--%s-* tokens", (_label, prefix) => {
40
- const defined = definedTokens(prefix);
41
- const bridged = bridgedTokens(prefix);
42
-
43
- it("defines at least one token in the default palette", () => {
44
- expect(defined.length).toBeGreaterThan(0);
45
- });
46
-
47
- it("bridges every token defined in the default palette", () => {
48
- expect([...bridged.keys()].toSorted()).toEqual(defined);
49
- });
50
-
51
- it("maps each bridged token to its own source variable", () => {
52
- // Guards against cross-wiring, e.g. --color-alert-info-foreground
53
- // accidentally resolving to var(--alert-info-foreground-muted).
54
- for (const [name, target] of bridged) {
55
- expect(target).toBe(name);
56
- }
57
- });
58
- });
59
-
60
- it("bridges all 20 alert slots", () => {
61
- // 5 variants x 4 slots. Pinned so that dropping a whole variant — which
62
- // would still satisfy the symmetry checks above if it were removed from
63
- // both files — fails loudly.
64
- expect(bridgedTokens("alert").size).toBe(20);
65
- });
66
-
67
- it("bridges all 40 semantic colour roles", () => {
68
- // 5 intents x 8 roles, pinned for the same reason as the alert count.
69
- expect(bridgedTokens(SEMANTIC_PREFIX).size).toBe(40);
70
- });
71
-
72
- describe("typography roles", () => {
73
- const slots = ["size", "line-height", "weight", "letter-spacing"] as const;
74
- /** `--type-<role>-<slot>` variables in the default palette, grouped by role. */
75
- const defined = new Map<string, Map<string, string>>();
76
- for (const m of defaultTheme.matchAll(
77
- /^\s*--type-([a-z-]+)-(size|line-height|weight|letter-spacing):\s*([^;]+);/gm,
78
- )) {
79
- const role = m[1];
80
- defined.set(role, (defined.get(role) ?? new Map()).set(m[2], m[3].trim()));
81
- }
82
- /**
83
- * Bridge entries `--text-<role>[--<suffix>]: var(--app-shell-type-X, var(--type-X))`,
84
- * keyed by property name. The value is the `--type-*` name, or a marker when the
85
- * entry does not read the same deprecated `--app-shell-type-*` name first.
86
- */
87
- const bridgedText = new Map(
88
- [
89
- ...bridge.matchAll(
90
- /^\s*(--text-[a-z-]+):\s*var\(\s*--app-shell-type-([a-z-]+),\s*var\(\s*--type-([a-z-]+)\s*\)\s*\);/gm,
91
- ),
92
- ].map((m) => [m[1], m[2] === m[3] ? `--type-${m[3]}` : `mismatched old name ${m[2]}`]),
93
- );
94
-
95
- it("pins the role count at 10", () => {
96
- // Dropping a whole role from both files would still pass the symmetry checks.
97
- expect(defined.size).toBe(10);
98
- });
99
-
100
- it("defines exactly four variables per role", () => {
101
- for (const [role, values] of defined) {
102
- expect([...values.keys()].toSorted(), role).toEqual([...slots].toSorted());
103
- }
104
- });
105
-
106
- it("bridges every role slot to its own variable", () => {
107
- const suffix = {
108
- size: "",
109
- "line-height": "--line-height",
110
- weight: "--font-weight",
111
- "letter-spacing": "--letter-spacing",
112
- } as const;
113
- const expected = new Map<string, string>();
114
- for (const role of defined.keys()) {
115
- for (const slot of slots) {
116
- expected.set(`--text-${role}${suffix[slot]}`, `--type-${role}-${slot}`);
117
- }
118
- }
119
- expect(bridgedText).toEqual(expected);
120
- });
121
-
122
- it("reads the deprecated --app-shell-type-* name first in every bridge entry", () => {
123
- // 1.17.0 shipped the roles under --app-shell-type-*. The bridge resolves the
124
- // fallback on the element, so an old-name override keeps working at any scope.
125
- const typeEntries = [...bridge.matchAll(/^\s*(--text-[a-z-]+):\s*([^;]+);/gm)].filter((m) =>
126
- m[2].includes("--type-"),
127
- );
128
- expect(typeEntries.length).toBe(defined.size * slots.length);
129
- expect(typeEntries.filter((m) => !bridgedText.has(m[1])).map((m) => m[1])).toEqual([]);
130
- });
131
-
132
- it("does not collide with a --color-* token that Tailwind resolves first", () => {
133
- // `text-<name>` resolves against --color-<name> before --text-<name>.
134
- const colors = [...bridge.matchAll(/^\s*--color-([a-z0-9-]+):/gm)].map((m) => m[1]);
135
- for (const role of defined.keys()) {
136
- expect(colors, role).not.toContain(role);
137
- }
138
- for (const name of colors) {
139
- expect(name).not.toMatch(/^(heading|body|label|code)(-|$)/);
140
- }
141
- });
142
-
143
- it("keeps the line boxes on the spec values", () => {
144
- // size (px) x line-height ratio = line box (px); weight and letter spacing are pinned too. Values from decisions/typography-roles.md.
145
- const spec: Record<
146
- string,
147
- [size: number, lineBox: number, weight: number, letterSpacing: string]
148
- > = {
149
- "heading-lg": [24, 32, 700, "-0.025em"],
150
- "heading-md": [18, 28, 600, "0em"],
151
- "heading-sm": [14, 20, 600, "0em"],
152
- "body-md": [14, 20, 400, "0em"],
153
- "body-sm": [12, 16, 400, "0em"],
154
- "label-md": [14, 20, 500, "0em"],
155
- "label-sm": [12, 16, 500, "0em"],
156
- "code-sm": [12, 18, 400, "0em"],
157
- "body-md-relaxed": [14, 24, 400, "0em"],
158
- "body-sm-relaxed": [12, 20, 400, "0em"],
159
- };
160
- expect([...defined.keys()].toSorted()).toEqual(Object.keys(spec).toSorted());
161
- for (const [role, [size, lineBox, weight, letterSpacing]] of Object.entries(spec)) {
162
- const values = defined.get(role)!;
163
- const rem = /^([0-9.]+)rem$/.exec(values.get("size") ?? "");
164
- expect(rem, `${role} size`).not.toBeNull();
165
- expect(Number(rem![1]) * 16, `${role} size`).toBe(size);
166
- const ratio = /^calc\((\d+) \/ (\d+)\)$/.exec(values.get("line-height") ?? "");
167
- expect(ratio, `${role} line-height`).not.toBeNull();
168
- expect(Number(ratio![2]), `${role} ratio base`).toBe(size);
169
- expect(Number(ratio![1]), `${role} line box`).toBe(lineBox);
170
- expect(Number(values.get("weight")), `${role} weight`).toBe(weight);
171
- expect(values.get("letter-spacing"), `${role} letter-spacing`).toBe(letterSpacing);
172
- }
173
- });
174
- });
175
-
176
- it("defines --backdrop in the default palette and bridges it as bg-backdrop", () => {
177
- // Dialog, Sheet and the CsvImporter drawer use `bg-backdrop`; without both
178
- // lines the backdrop compiles to an undefined variable and turns transparent.
179
- expect(defaultTheme).toMatch(/^\s*--backdrop:\s*var\(--primitive-black-a7\);/m);
180
- expect(bridge).toMatch(/^\s*--color-backdrop:\s*var\(--backdrop\);/m);
181
- });
182
- });