@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.
- package/dist/app-shell-core.css +2 -2
- package/dist/app-shell.d.ts +1108 -190
- package/dist/app-shell.js +13090 -10783
- package/dist/{appshell-Y1AYCuVJ.js → appshell-B4QkQvXF.js} +519 -392
- package/dist/fonts/inter.css +7 -7
- package/dist/fonts/noto-sans-jp.css +116 -116
- package/dist/style-entry.d.ts +1 -656
- package/dist/testing.d.ts +132 -97
- package/dist/testing.js +1 -1
- package/dist/vite-plugin.d.ts +132 -97
- package/package.json +15 -10
- package/skills/app-shell-patterns/SKILL.md +38 -27
- package/skills/app-shell-patterns/references/api/aggregations.md +131 -0
- package/skills/app-shell-patterns/references/api/create-ai-gateway-client.md +32 -2
- package/skills/app-shell-patterns/references/api/define-module.md +5 -5
- package/skills/app-shell-patterns/references/api/router/use-params.md +2 -2
- package/skills/app-shell-patterns/references/api/use-ai-chat.md +46 -8
- package/skills/app-shell-patterns/references/api/use-app-shell-config.md +4 -4
- package/skills/app-shell-patterns/references/api/use-app-shell-data.md +5 -5
- package/skills/app-shell-patterns/references/api/use-app-shell.md +2 -2
- package/skills/app-shell-patterns/references/api/use-auth.md +3 -3
- package/skills/app-shell-patterns/references/api/use-route-error.md +12 -12
- package/skills/app-shell-patterns/references/components/action-panel.md +57 -23
- package/skills/app-shell-patterns/references/components/activity-card.md +3 -3
- package/skills/app-shell-patterns/references/components/ai-chat.md +37 -3
- package/skills/app-shell-patterns/references/components/app-shell.md +6 -5
- package/skills/app-shell-patterns/references/components/badge.md +7 -3
- package/skills/app-shell-patterns/references/components/card.md +2 -2
- package/skills/app-shell-patterns/references/components/data-table.md +514 -77
- package/skills/app-shell-patterns/references/components/date-picker.md +2 -2
- package/skills/app-shell-patterns/references/components/default-sidebar.md +2 -2
- package/skills/app-shell-patterns/references/components/dialog.md +8 -0
- package/skills/app-shell-patterns/references/components/filter-rail.md +10 -3
- package/skills/app-shell-patterns/references/components/form.md +19 -9
- package/skills/app-shell-patterns/references/components/global-header-layout.md +31 -8
- package/skills/app-shell-patterns/references/components/item-list.md +191 -0
- package/skills/app-shell-patterns/references/components/layout.md +24 -17
- package/skills/app-shell-patterns/references/components/select.md +18 -0
- package/skills/app-shell-patterns/references/components/sidebar-group.md +9 -7
- package/skills/app-shell-patterns/references/components/sidebar-layout.md +74 -11
- package/skills/app-shell-patterns/references/components/sidebar-section.md +8 -8
- package/skills/app-shell-patterns/references/components/tabs.md +32 -2
- package/skills/app-shell-patterns/references/components/text.md +75 -38
- package/skills/app-shell-patterns/references/components/tree.md +342 -0
- package/skills/app-shell-patterns/references/components/with-guard.md +5 -5
- package/skills/app-shell-patterns/references/concepts/authentication.md +1 -1
- package/skills/app-shell-patterns/references/concepts/routing-navigation.md +3 -3
- package/skills/app-shell-patterns/references/concepts/sidebar-navigation.md +16 -14
- package/skills/app-shell-patterns/references/concepts/styling-theming.md +28 -20
- package/skills/app-shell-patterns/references/migrations.md +22 -2
- package/skills/app-shell-patterns/references/pages/document-detail.md +32 -25
- package/skills/app-shell-patterns/references/patterns/form-modal.md +4 -1
- package/skills/app-shell-patterns/references/patterns/interaction-multi-select.md +1 -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
|
-
<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-
|
|
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
|
-
<p
|
|
686
|
-
|
|
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
|
-
</
|
|
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,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-
|
|
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
|
-
<
|
|
887
|
+
<Text variant="body-md" className="px-6 text-muted-foreground">
|
|
881
888
|
No goods receipts linked to this order.
|
|
882
|
-
</
|
|
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-
|
|
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-
|
|
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
|
-
<
|
|
1021
|
+
<Text variant="code-sm" className="block text-muted-foreground">
|
|
1015
1022
|
{line.accountCode}
|
|
1016
|
-
</
|
|
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-
|
|
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
|
-
<
|
|
1062
|
+
<Text variant="body-sm" className="mt-1 text-muted-foreground">
|
|
1056
1063
|
Synced {externalSource.syncedAt.toLocaleDateString()}
|
|
1057
|
-
</
|
|
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'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
|
-
});
|