@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.
- package/dist/app-shell-core.css +1 -1
- package/dist/app-shell.d.ts +330 -89
- package/dist/app-shell.js +8451 -7633
- package/dist/{appshell-Y1AYCuVJ.js → appshell-DQTz-QwK.js} +484 -374
- package/dist/style-entry.d.ts +1 -656
- package/dist/testing.d.ts +61 -56
- package/dist/testing.js +1 -1
- package/dist/vite-plugin.d.ts +61 -56
- package/package.json +8 -5
- package/skills/app-shell-patterns/SKILL.md +37 -27
- package/skills/app-shell-patterns/references/api/aggregations.md +131 -0
- package/skills/app-shell-patterns/references/components/action-panel.md +55 -23
- package/skills/app-shell-patterns/references/components/badge.md +7 -3
- package/skills/app-shell-patterns/references/components/data-table.md +430 -49
- package/skills/app-shell-patterns/references/components/form.md +19 -9
- package/skills/app-shell-patterns/references/components/item-list.md +191 -0
- package/skills/app-shell-patterns/references/components/select.md +10 -0
- package/skills/app-shell-patterns/references/components/text.md +75 -38
- package/skills/app-shell-patterns/references/concepts/sidebar-navigation.md +16 -14
- package/skills/app-shell-patterns/references/concepts/styling-theming.md +19 -11
- package/skills/app-shell-patterns/references/migrations.md +2 -2
- package/skills/app-shell-patterns/references/pages/document-detail.md +36 -25
- package/skills/app-shell-patterns/references/patterns/form-modal.md +4 -1
- package/dist/semantic-contrast.test.ts +0 -227
- 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
|
|
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`
|
|
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
|
|
618
|
-
*
|
|
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
|
-
<
|
|
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-
|
|
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-
|
|
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
|
-
<
|
|
686
|
-
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
<
|
|
839
|
+
<Text variant="code-sm" className="block text-muted-foreground">
|
|
833
840
|
{line.sku}
|
|
834
|
-
</
|
|
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
|
-
<
|
|
854
|
+
<Text
|
|
855
|
+
variant="body-sm"
|
|
856
|
+
className="ml-1 text-muted-foreground"
|
|
857
|
+
render={<span />}
|
|
858
|
+
>
|
|
848
859
|
({line.qty - line.received})
|
|
849
|
-
</
|
|
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
|
-
<
|
|
891
|
+
<Text variant="body-md" className="px-6 text-muted-foreground">
|
|
881
892
|
No goods receipts linked to this order.
|
|
882
|
-
</
|
|
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-
|
|
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
|
-
<
|
|
942
|
+
<Text variant="body-sm" className="shrink-0 text-muted-foreground" render={<span />}>
|
|
932
943
|
{file.sizeLabel} · {formatDay(file.uploadedAt)}
|
|
933
|
-
</
|
|
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
|
-
<
|
|
1025
|
+
<Text variant="code-sm" className="block text-muted-foreground">
|
|
1015
1026
|
{line.accountCode}
|
|
1016
|
-
</
|
|
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-
|
|
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
|
-
<
|
|
1066
|
+
<Text variant="body-sm" className="mt-1 text-muted-foreground">
|
|
1056
1067
|
Synced {externalSource.syncedAt.toLocaleDateString()}
|
|
1057
|
-
</
|
|
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
|
-
});
|