@tailor-platform/app-shell 1.17.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.
Files changed (36) hide show
  1. package/dist/app-shell-core.css +1 -1
  2. package/dist/app-shell.d.ts +503 -137
  3. package/dist/app-shell.js +8847 -7378
  4. package/dist/{appshell-CahhZy7v.js → appshell-DQTz-QwK.js} +489 -379
  5. package/dist/style-entry.d.ts +1 -656
  6. package/dist/testing.d.ts +118 -113
  7. package/dist/testing.js +1 -1
  8. package/dist/theme.bridge.css +127 -40
  9. package/dist/theme.css +4 -3
  10. package/dist/themes/_template.css +5 -6
  11. package/dist/themes/bloom.css +0 -2
  12. package/dist/themes/cream.css +0 -2
  13. package/dist/themes/default.css +112 -77
  14. package/dist/vite-plugin.d.ts +118 -113
  15. package/package.json +8 -5
  16. package/skills/app-shell-patterns/SKILL.md +51 -32
  17. package/skills/app-shell-patterns/references/api/aggregations.md +131 -0
  18. package/skills/app-shell-patterns/references/api/define-i18n-labels.md +123 -91
  19. package/skills/app-shell-patterns/references/api/define-module.md +2 -4
  20. package/skills/app-shell-patterns/references/components/action-panel.md +55 -23
  21. package/skills/app-shell-patterns/references/components/badge.md +7 -3
  22. package/skills/app-shell-patterns/references/components/data-table.md +724 -36
  23. package/skills/app-shell-patterns/references/components/form.md +19 -9
  24. package/skills/app-shell-patterns/references/components/item-list.md +191 -0
  25. package/skills/app-shell-patterns/references/components/select.md +10 -0
  26. package/skills/app-shell-patterns/references/components/sidebar-group.md +6 -6
  27. package/skills/app-shell-patterns/references/components/text.md +235 -0
  28. package/skills/app-shell-patterns/references/concepts/sidebar-navigation.md +16 -14
  29. package/skills/app-shell-patterns/references/concepts/styling-theming.md +46 -22
  30. package/skills/app-shell-patterns/references/migrations.md +23 -2
  31. package/skills/app-shell-patterns/references/pages/document-detail.md +36 -25
  32. package/skills/app-shell-patterns/references/patterns/form-modal.md +4 -1
  33. package/skills/app-shell-patterns/references/patterns/i18n-labels.md +139 -0
  34. package/skills/app-shell-patterns/references/patterns/list-dense-scan.md +3 -2
  35. package/dist/semantic-contrast.test.ts +0 -154
  36. package/dist/theme-bridge.test.ts +0 -159
@@ -1,154 +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
- /** Known and accepted: subtle hover text is below AA for some roles. A floor, not a pass. */
92
- const MIN_HOVER_TEXT = 4.2;
93
- const modes: Mode[] = ["light", "dark"];
94
- const intents = ["info", "success", "warning", "danger"] as const;
95
- const alertVariantFor = { info: "info", success: "success", warning: "warning", danger: "error" };
96
- const pages = ["--card", "--background"] as const;
97
-
98
- describe.each(modes)("semantic colour contrast (%s)", (mode) => {
99
- const vars = variablesFor(mode);
100
- const color = (name: string) => parseColor(`var(${name})`, vars);
101
- /** Foreground drawn on a translucent surface that sits on a page colour. */
102
- const pair = (fg: string, surface: string, page: string) => {
103
- const base = color(page);
104
- const bg = over(color(surface), base);
105
- return contrast(over(color(fg), bg), bg);
106
- };
107
-
108
- it("resolves the page colours to opaque values", () => {
109
- for (const page of pages) expect(color(page).a).toBe(1);
110
- });
111
-
112
- describe.each(intents)("%s", (intent) => {
113
- const role = (name: string) => `--${intent}-${name}`;
114
-
115
- it("the contrast role meets 4.5:1 on solid", () => {
116
- expect(contrast(color(role("contrast")), color(role("solid")))).toBeGreaterThanOrEqual(
117
- MIN_TEXT,
118
- );
119
- });
120
-
121
- it("the contrast role meets 4.5:1 on solid-hover", () => {
122
- expect(contrast(color(role("contrast")), color(role("solid-hover")))).toBeGreaterThanOrEqual(
123
- MIN_TEXT,
124
- );
125
- });
126
-
127
- it.each(pages)("text meets 4.5:1 on surface over %s", (page) => {
128
- expect(pair(role("text"), role("surface"), page)).toBeGreaterThanOrEqual(MIN_TEXT);
129
- });
130
-
131
- it.each(pages)(
132
- "text stays above the documented hover floor on surface-hover over %s",
133
- (page) => {
134
- expect(pair(role("text"), role("surface-hover"), page)).toBeGreaterThanOrEqual(
135
- MIN_HOVER_TEXT,
136
- );
137
- },
138
- );
139
-
140
- const variant = alertVariantFor[intent];
141
-
142
- it.each(pages)("alert foreground meets 4.5:1 on alert background over %s", (page) => {
143
- expect(
144
- pair(`--alert-${variant}-foreground`, `--alert-${variant}-background`, page),
145
- ).toBeGreaterThanOrEqual(MIN_TEXT);
146
- });
147
-
148
- it.each(pages)("alert description meets 4.5:1 on alert background over %s", (page) => {
149
- expect(
150
- pair(`--alert-${variant}-foreground-muted`, `--alert-${variant}-background`, page),
151
- ).toBeGreaterThanOrEqual(MIN_TEXT);
152
- });
153
- });
154
- });
@@ -1,159 +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
- /** `--app-shell-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*--app-shell-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
- /** Bridge entries `--text-<role>[--<suffix>]: var(--app-shell-type-...)`, keyed by property name. */
83
- const bridgedText = new Map(
84
- [...bridge.matchAll(/^\s*(--text-[a-z-]+):\s*var\((--app-shell-type-[a-z-]+)\);/gm)].map(
85
- (m) => [m[1], m[2]],
86
- ),
87
- );
88
-
89
- it("pins the role count at 10", () => {
90
- // Dropping a whole role from both files would still pass the symmetry checks.
91
- expect(defined.size).toBe(10);
92
- });
93
-
94
- it("defines exactly four variables per role", () => {
95
- for (const [role, values] of defined) {
96
- expect([...values.keys()].toSorted(), role).toEqual([...slots].toSorted());
97
- }
98
- });
99
-
100
- it("bridges every role slot to its own variable", () => {
101
- const suffix = {
102
- size: "",
103
- "line-height": "--line-height",
104
- weight: "--font-weight",
105
- "letter-spacing": "--letter-spacing",
106
- } as const;
107
- const expected = new Map<string, string>();
108
- for (const role of defined.keys()) {
109
- for (const slot of slots) {
110
- expected.set(`--text-${role}${suffix[slot]}`, `--app-shell-type-${role}-${slot}`);
111
- }
112
- }
113
- expect(bridgedText).toEqual(expected);
114
- });
115
-
116
- it("does not collide with a --color-* token that Tailwind resolves first", () => {
117
- // `text-<name>` resolves against --color-<name> before --text-<name>.
118
- const colors = [...bridge.matchAll(/^\s*--color-([a-z0-9-]+):/gm)].map((m) => m[1]);
119
- for (const role of defined.keys()) {
120
- expect(colors, role).not.toContain(role);
121
- }
122
- for (const name of colors) {
123
- expect(name).not.toMatch(/^(heading|body|label|code)(-|$)/);
124
- }
125
- });
126
-
127
- it("keeps the line boxes on the spec values", () => {
128
- // size (px) x line-height ratio = line box (px); weight and letter spacing are pinned too. Values from decisions/typography-roles.md.
129
- const spec: Record<
130
- string,
131
- [size: number, lineBox: number, weight: number, letterSpacing: string]
132
- > = {
133
- "heading-lg": [24, 32, 700, "-0.025em"],
134
- "heading-md": [18, 28, 600, "0em"],
135
- "heading-sm": [14, 20, 600, "0em"],
136
- "body-md": [14, 20, 400, "0em"],
137
- "body-sm": [12, 16, 400, "0em"],
138
- "label-md": [14, 20, 500, "0em"],
139
- "label-sm": [12, 16, 500, "0em"],
140
- "code-sm": [12, 18, 400, "0em"],
141
- "body-md-relaxed": [14, 24, 400, "0em"],
142
- "body-sm-relaxed": [12, 20, 400, "0em"],
143
- };
144
- expect([...defined.keys()].toSorted()).toEqual(Object.keys(spec).toSorted());
145
- for (const [role, [size, lineBox, weight, letterSpacing]] of Object.entries(spec)) {
146
- const values = defined.get(role)!;
147
- const rem = /^([0-9.]+)rem$/.exec(values.get("size") ?? "");
148
- expect(rem, `${role} size`).not.toBeNull();
149
- expect(Number(rem![1]) * 16, `${role} size`).toBe(size);
150
- const ratio = /^calc\((\d+) \/ (\d+)\)$/.exec(values.get("line-height") ?? "");
151
- expect(ratio, `${role} line-height`).not.toBeNull();
152
- expect(Number(ratio![2]), `${role} ratio base`).toBe(size);
153
- expect(Number(ratio![1]), `${role} line box`).toBe(lineBox);
154
- expect(Number(values.get("weight")), `${role} weight`).toBe(weight);
155
- expect(values.get("letter-spacing"), `${role} letter-spacing`).toBe(letterSpacing);
156
- }
157
- });
158
- });
159
- });