@tailor-platform/app-shell 1.18.0 → 1.20.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.
Files changed (55) hide show
  1. package/dist/app-shell-core.css +2 -2
  2. package/dist/app-shell.d.ts +1108 -190
  3. package/dist/app-shell.js +13090 -10783
  4. package/dist/{appshell-Y1AYCuVJ.js → appshell-B4QkQvXF.js} +519 -392
  5. package/dist/fonts/inter.css +7 -7
  6. package/dist/fonts/noto-sans-jp.css +116 -116
  7. package/dist/style-entry.d.ts +1 -656
  8. package/dist/testing.d.ts +132 -97
  9. package/dist/testing.js +1 -1
  10. package/dist/vite-plugin.d.ts +132 -97
  11. package/package.json +15 -10
  12. package/skills/app-shell-patterns/SKILL.md +38 -27
  13. package/skills/app-shell-patterns/references/api/aggregations.md +131 -0
  14. package/skills/app-shell-patterns/references/api/create-ai-gateway-client.md +32 -2
  15. package/skills/app-shell-patterns/references/api/define-module.md +5 -5
  16. package/skills/app-shell-patterns/references/api/router/use-params.md +2 -2
  17. package/skills/app-shell-patterns/references/api/use-ai-chat.md +46 -8
  18. package/skills/app-shell-patterns/references/api/use-app-shell-config.md +4 -4
  19. package/skills/app-shell-patterns/references/api/use-app-shell-data.md +5 -5
  20. package/skills/app-shell-patterns/references/api/use-app-shell.md +2 -2
  21. package/skills/app-shell-patterns/references/api/use-auth.md +3 -3
  22. package/skills/app-shell-patterns/references/api/use-route-error.md +12 -12
  23. package/skills/app-shell-patterns/references/components/action-panel.md +57 -23
  24. package/skills/app-shell-patterns/references/components/activity-card.md +3 -3
  25. package/skills/app-shell-patterns/references/components/ai-chat.md +37 -3
  26. package/skills/app-shell-patterns/references/components/app-shell.md +6 -5
  27. package/skills/app-shell-patterns/references/components/badge.md +7 -3
  28. package/skills/app-shell-patterns/references/components/card.md +2 -2
  29. package/skills/app-shell-patterns/references/components/data-table.md +514 -77
  30. package/skills/app-shell-patterns/references/components/date-picker.md +2 -2
  31. package/skills/app-shell-patterns/references/components/default-sidebar.md +2 -2
  32. package/skills/app-shell-patterns/references/components/dialog.md +8 -0
  33. package/skills/app-shell-patterns/references/components/filter-rail.md +10 -3
  34. package/skills/app-shell-patterns/references/components/form.md +19 -9
  35. package/skills/app-shell-patterns/references/components/global-header-layout.md +31 -8
  36. package/skills/app-shell-patterns/references/components/item-list.md +191 -0
  37. package/skills/app-shell-patterns/references/components/layout.md +24 -17
  38. package/skills/app-shell-patterns/references/components/select.md +18 -0
  39. package/skills/app-shell-patterns/references/components/sidebar-group.md +9 -7
  40. package/skills/app-shell-patterns/references/components/sidebar-layout.md +74 -11
  41. package/skills/app-shell-patterns/references/components/sidebar-section.md +8 -8
  42. package/skills/app-shell-patterns/references/components/tabs.md +32 -2
  43. package/skills/app-shell-patterns/references/components/text.md +75 -38
  44. package/skills/app-shell-patterns/references/components/tree.md +342 -0
  45. package/skills/app-shell-patterns/references/components/with-guard.md +5 -5
  46. package/skills/app-shell-patterns/references/concepts/authentication.md +1 -1
  47. package/skills/app-shell-patterns/references/concepts/routing-navigation.md +3 -3
  48. package/skills/app-shell-patterns/references/concepts/sidebar-navigation.md +16 -14
  49. package/skills/app-shell-patterns/references/concepts/styling-theming.md +28 -20
  50. package/skills/app-shell-patterns/references/migrations.md +22 -2
  51. package/skills/app-shell-patterns/references/pages/document-detail.md +32 -25
  52. package/skills/app-shell-patterns/references/patterns/form-modal.md +4 -1
  53. package/skills/app-shell-patterns/references/patterns/interaction-multi-select.md +1 -1
  54. package/dist/semantic-contrast.test.ts +0 -227
  55. package/dist/theme-bridge.test.ts +0 -182
@@ -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.span variant="body-md" className="text-foreground">
623
+ {children}
624
+ </Text.span>
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.p variant="label-md" className="text-foreground">
689
+ {hold.reason}
690
+ </Text.p>
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,9 @@ 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.span variant="body-sm" className="ml-1 text-muted-foreground">
848
855
  ({line.qty - line.received})
849
- </span>
856
+ </Text.span>
850
857
  )}
851
858
  </Table.Cell>
852
859
  <Table.Cell align="right" className="tabular-nums">
@@ -877,9 +884,9 @@ export default function PurchaseOrderDetailPage({
877
884
  <Card.Header title="Goods receipts" />
878
885
  <Card.Content padding="none">
879
886
  {order.goodsReceipts.length === 0 ? (
880
- <p className="px-6 text-sm text-muted-foreground">
887
+ <Text variant="body-md" className="px-6 text-muted-foreground">
881
888
  No goods receipts linked to this order.
882
- </p>
889
+ </Text>
883
890
  ) : (
884
891
  <Table.Root>
885
892
  <Table.Header>
@@ -922,15 +929,15 @@ export default function PurchaseOrderDetailPage({
922
929
  href={file.href}
923
930
  target="_blank"
924
931
  rel="noopener noreferrer"
925
- className="inline-flex items-center gap-2 text-sm text-primary underline-offset-4 hover:underline"
932
+ className="inline-flex items-center gap-2 text-body-md text-primary underline-offset-4 hover:underline"
926
933
  >
927
934
  <FileText className="size-4 shrink-0" />
928
935
  {file.name}
929
936
  <ExternalLink className="size-3 shrink-0" />
930
937
  </a>
931
- <span className="shrink-0 text-xs text-muted-foreground">
938
+ <Text.span variant="body-sm" className="shrink-0 text-muted-foreground">
932
939
  {file.sizeLabel} · {formatDay(file.uploadedAt)}
933
- </span>
940
+ </Text.span>
934
941
  </div>
935
942
  ))}
936
943
  </Card.Content>
@@ -1011,9 +1018,9 @@ export default function PurchaseOrderDetailPage({
1011
1018
  <Table.Row key={line.id}>
1012
1019
  <Table.Cell>
1013
1020
  <span className="block">{line.account}</span>
1014
- <span className="block font-mono text-xs text-muted-foreground">
1021
+ <Text variant="code-sm" className="block text-muted-foreground">
1015
1022
  {line.accountCode}
1016
- </span>
1023
+ </Text>
1017
1024
  </Table.Cell>
1018
1025
  <Table.Cell align="right" className="tabular-nums">
1019
1026
  {line.debit === null ? "—" : `${line.debit.toLocaleString()}`}
@@ -1047,14 +1054,14 @@ export default function PurchaseOrderDetailPage({
1047
1054
  href={externalSource.href}
1048
1055
  target="_blank"
1049
1056
  rel="noopener noreferrer"
1050
- className="inline-flex items-center gap-1 text-sm text-primary underline-offset-4 hover:underline"
1057
+ className="inline-flex items-center gap-1 text-body-md text-primary underline-offset-4 hover:underline"
1051
1058
  >
1052
1059
  {externalSource.recordLabel}
1053
1060
  <ExternalLink className="size-3" />
1054
1061
  </a>
1055
- <p className="mt-1 text-xs text-muted-foreground">
1062
+ <Text variant="body-sm" className="mt-1 text-muted-foreground">
1056
1063
  Synced {externalSource.syncedAt.toLocaleDateString()}
1057
- </p>
1064
+ </Text>
1058
1065
  </Card.Content>
1059
1066
  </Card.Root>
1060
1067
  )}
@@ -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`
@@ -112,7 +112,7 @@ function InteractionMultiSelect() {
112
112
  cancelled; shipped orders are excluded. Cancelled orders can&apos;t be reopened.
113
113
  </Dialog.Description>
114
114
  </Dialog.Header>
115
- {cancelError && <p role="alert">{cancelError}</p>}
115
+ {cancelError && <Text.p role="alert">{cancelError}</Text.p>}
116
116
  <Dialog.Footer>
117
117
  <Dialog.Close render={<Button variant="outline" disabled={cancelling} />}>
118
118
  Keep orders
@@ -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
- });