Skip to content

Commit 5c5cc48

Browse files
Add rendered design checks to required browser CI
1 parent 4f55e2e commit 5c5cc48

82 files changed

Lines changed: 3235 additions & 21 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,18 @@ jobs:
108108
# Chromium's shared libraries, so do not make E2E depend on its apt mirror.
109109
- run: bun node_modules/@playwright/test/cli.js install chromium
110110

111+
- name: Rendered design quality (Linux Chromium)
112+
run: bun run design:browser
113+
111114
# Builds the client, then runs it. The build is deliberately here rather
112115
# than inside the Playwright config: web servers start before any global
113116
# setup, so a build racing them would leave the suite on the previous
114117
# bundle — green, about code nobody changed.
115118
- run: bun run e2e
116119

120+
- name: Verify design audit stays out of production
121+
run: bun scripts/check-design-production.ts
122+
117123
# Only on failure, and only the parts worth reading. `test-results`
118124
# carries the traces; the report is the thing that indexes them.
119125
- if: failure()

‎apps/web/.impeccable/design.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@
394394
"rules": [
395395
{
396396
"name": "The Contrast Hierarchy Rule",
397-
"body": "Prefer AA contrast for primary content and controls, but do not treat it as a universal requirement for every text role. Preserve the approved visual hierarchy: timestamps and secondary conversation metadata use their original quiet roles; queued messages and loading/tool status keep their original muted roles and opacity; code and diff previews retain the original pierre-light palette and selection treatment. These are deliberate visual exceptions, alongside the original destructive button red. Do not darken these roles automatically to satisfy a contrast audit. Record measured findings honestly and keep exceptions scoped to the approved roles and states; new exceptions require a deliberate design decision. Token use, the fluid type scale, keyboard access, focus, and layout checks remain enforced.",
397+
"body": "Prefer AA contrast for primary content and controls, but do not treat it as a universal requirement for every text role. Preserve the approved visual hierarchy: timestamps and secondary conversation metadata use their original quiet roles; supplementary audit-page labels (source paths, specimen state, usage notes, type-scale metadata, icon names and section summaries) keep their original quaternary role; queued messages and loading/tool status keep their original muted roles and opacity; code and diff previews retain the original pierre-light palette and selection treatment. These are deliberate visual exceptions, alongside the original destructive button red. Do not darken these roles automatically to satisfy a contrast audit. Record measured findings honestly and keep exceptions scoped to the approved roles and states; new exceptions require a deliberate design decision. Token use, the fluid type scale, keyboard access, focus, and layout checks remain enforced.",
398398
"section": "colors"
399399
},
400400
{

‎apps/web/DESIGN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ Success, warning, and destructive colors communicate state. Their full surface,
125125

126126
The original destructive button red is an intentional visual exception: white text on its default state measures about 4.20:1, below the usual 4.5:1 AA target. Keep this red for its aesthetic character. The hover and pressed colors remain distinct and above 4.5:1.
127127

128-
**The Contrast Hierarchy Rule.** Prefer AA contrast for primary content and controls, but do not treat it as a universal requirement for every text role. Preserve the approved visual hierarchy: timestamps and secondary conversation metadata use their original quiet roles; queued messages and loading/tool status keep their original muted roles and opacity; code and diff previews retain the original pierre-light palette and selection treatment. These are deliberate visual exceptions, alongside the original destructive button red. Do not darken these roles automatically to satisfy a contrast audit. Record measured findings honestly and keep exceptions scoped to the approved roles and states; new exceptions require a deliberate design decision. Token use, the fluid type scale, keyboard access, focus, and layout checks remain enforced.
128+
**The Contrast Hierarchy Rule.** Prefer AA contrast for primary content and controls, but do not treat it as a universal requirement for every text role. Preserve the approved visual hierarchy: timestamps and secondary conversation metadata use their original quiet roles; supplementary audit-page labels (source paths, specimen state, usage notes, type-scale metadata, icon names and section summaries) keep their original quaternary role; queued messages and loading/tool status keep their original muted roles and opacity; code and diff previews retain the original pierre-light palette and selection treatment. These are deliberate visual exceptions, alongside the original destructive button red. Do not darken these roles automatically to satisfy a contrast audit. Record measured findings honestly and keep exceptions scoped to the approved roles and states; new exceptions require a deliberate design decision. Token use, the fluid type scale, keyboard access, focus, and layout checks remain enforced.
129129

130130
**The One Accent Rule.** New action and focus treatments use the established petrol roles. New raw color values require a change to the shared theme, not a local copy.
131131

‎apps/web/src/design-audit/authored-content.css‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@
8686
text-align: left;
8787
}
8888

89-
.design-audit-table-states td[aria-selected="true"] {
89+
.design-audit-table-states td[data-selected="true"] {
9090
background: var(--color-selected);
9191
outline: var(--focus-ring-width) solid var(--focus-ring-color);
9292
outline-offset: calc(-1 * var(--focus-ring-width));

‎apps/web/src/design-audit/authored-content.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ function TableSpecimens() {
216216
<th>State</th>
217217
</tr>
218218
<tr>
219-
<td aria-selected="true">Button</td>
219+
<td data-selected="true">Button</td>
220220
<td>Selected</td>
221221
</tr>
222222
</tbody>

‎apps/web/src/design-audit/controls.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,23 +46,23 @@ export function Controls() {
4646
title="Buttons"
4747
>
4848
<div className="design-audit-button-families">
49-
<div>
49+
<div data-audit-sample="primary">
5050
<h4>Primary</h4>
5151
<ButtonRow className="btn-primary" label="Create document" />
5252
</div>
53-
<div>
53+
<div data-audit-sample="secondary">
5454
<h4>Secondary</h4>
5555
<ButtonRow className="btn-secondary" label="Cancel" />
5656
</div>
57-
<div>
57+
<div data-audit-sample="ghost">
5858
<h4>Ghost</h4>
5959
<ButtonRow className="btn-ghost" label="Learn more" />
6060
</div>
61-
<div>
61+
<div data-audit-sample="destructive">
6262
<h4>Destructive</h4>
6363
<ButtonRow className="btn-destructive" label="Delete document" />
6464
</div>
65-
<div className="design-audit-button-sizes">
65+
<div className="design-audit-button-sizes" data-audit-sample="sizes">
6666
<StateSample state="Medium · 32px high · 12px sides">
6767
<button className="btn btn-md btn-primary" type="button">Medium</button>
6868
</StateSample>
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { useState } from "react";
2+
import { useTransitionPresence } from "@chopin/editor/transition-presence";
3+
4+
import { DocumentActionsMenu } from "../document-actions-menu";
5+
import { motionImmediately } from "../motion-input";
6+
import { NavigationDialog } from "../navigation-dialog";
7+
import { TerminalAlert } from "../terminal-alert";
8+
import { AuditPlate } from "./frame";
9+
10+
import type { FormEvent } from "react";
11+
import type { DocumentAction } from "../document-actions-menu";
12+
13+
export function InteractiveSpecimens() {
14+
let [title, setTitle] = useState("Design system audit");
15+
let [draft, setDraft] = useState(title);
16+
let [archived, setArchived] = useState(false);
17+
let [deleted, setDeleted] = useState(false);
18+
let [dialog, setDialog] = useState<"rename" | "delete" | undefined>();
19+
let [failure, setFailure] = useState(false);
20+
let [error, setError] = useState(false);
21+
let presence = useTransitionPresence(dialog, 220, motionImmediately());
22+
23+
function action(value: DocumentAction) {
24+
if (value === "archive" || value === "restore") {
25+
setArchived(value === "archive");
26+
return;
27+
}
28+
setDraft(title);
29+
setError(false);
30+
setDialog(value);
31+
}
32+
33+
function save(event: FormEvent<HTMLFormElement>) {
34+
event.preventDefault();
35+
if (!draft.trim()) return;
36+
if (failure) {
37+
setError(true);
38+
return;
39+
}
40+
setTitle(draft.trim());
41+
setDialog(undefined);
42+
}
43+
44+
return (
45+
<AuditPlate
46+
description="Open the production menu and modal shell. Changes stay in this local specimen."
47+
item="interactive-document-actions"
48+
title="Interactive document actions"
49+
>
50+
<div className="flex flex-wrap items-center gap-3">
51+
<span className="text-sm font-medium">{deleted ? "Document deleted" : title}</span>
52+
{deleted
53+
? (
54+
<button
55+
className="btn btn-md btn-secondary"
56+
onClick={() => {
57+
setTitle("Design system audit");
58+
setArchived(false);
59+
setDeleted(false);
60+
}}
61+
type="button"
62+
>
63+
Reset specimen
64+
</button>
65+
)
66+
: (
67+
<DocumentActionsMenu
68+
channel={{
69+
title,
70+
archivedAt: archived ? "2026-09-28T00:00:00.000Z" : undefined,
71+
}}
72+
className="btn btn-md btn-secondary"
73+
onAction={action}
74+
trigger="Document actions"
75+
/>
76+
)}
77+
<label className="flex items-center gap-2 text-sm text-text-secondary">
78+
<input
79+
checked={failure}
80+
className="choice-control"
81+
onChange={event => setFailure(event.target.checked)}
82+
type="checkbox"
83+
/>
84+
Simulate save error
85+
</label>
86+
</div>
87+
{presence.phase !== "closed" && presence.value === "rename" && (
88+
<NavigationDialog
89+
motion={presence}
90+
onDismiss={() => setDialog(undefined)}
91+
title="Rename document"
92+
>
93+
<form className="mt-4 flex min-w-0 flex-col gap-2" onSubmit={save}>
94+
<label className="sr-only" htmlFor="audit-interactive-title">Document title</label>
95+
<input
96+
aria-invalid={error || undefined}
97+
className="field h-8 min-w-0 w-full px-2 text-sm"
98+
id="audit-interactive-title"
99+
maxLength={120}
100+
onChange={event => {
101+
setDraft(event.target.value);
102+
setError(false);
103+
}}
104+
value={draft}
105+
/>
106+
{error && (
107+
<TerminalAlert className="text-sm text-destructive-ink">
108+
Could not rename document.
109+
</TerminalAlert>
110+
)}
111+
<div className="flex justify-end gap-2">
112+
<button
113+
className="btn btn-md btn-ghost"
114+
onClick={() => setDialog(undefined)}
115+
type="button"
116+
>
117+
Cancel
118+
</button>
119+
<button className="btn btn-md btn-primary" disabled={!draft.trim()} type="submit">
120+
Save
121+
</button>
122+
</div>
123+
</form>
124+
</NavigationDialog>
125+
)}
126+
{presence.phase !== "closed" && presence.value === "delete" && (
127+
<NavigationDialog
128+
motion={presence}
129+
onDismiss={() => setDialog(undefined)}
130+
title="Delete document permanently?"
131+
>
132+
<p className="mt-3 text-sm text-text-secondary">
133+
<strong className="font-semibold text-text-primary">{title}</strong>{" "}
134+
will be permanently deleted. This cannot be undone.
135+
</p>
136+
<div className="mt-5 flex justify-end gap-2">
137+
<button
138+
className="btn btn-md btn-secondary"
139+
onClick={() => setDialog(undefined)}
140+
type="button"
141+
>
142+
Cancel
143+
</button>
144+
<button
145+
className="btn btn-md btn-destructive"
146+
onClick={() => {
147+
setDeleted(true);
148+
setDialog(undefined);
149+
}}
150+
type="button"
151+
>
152+
Delete permanently
153+
</button>
154+
</div>
155+
</NavigationDialog>
156+
)}
157+
</AuditPlate>
158+
);
159+
}

‎apps/web/src/design-audit/inventory.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ export const AUDIT_INVENTORY: readonly AuditGroup[] = [
130130
id: "surfaces",
131131
label: "Application surfaces",
132132
items: [
133+
{
134+
id: "interactive-document-actions",
135+
label: "Interactive document actions",
136+
source: "apps/web/src/design-audit/interactive.tsx",
137+
states: ["closed", "menu-open", "dialog-open", "error", "archived"],
138+
},
133139
{
134140
id: "dialogs",
135141
label: "Dialogs",

‎apps/web/src/design-audit/page.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { Adoption } from "./adoption";
33
import { AuthoredContent } from "./authored-content";
44
import { Controls } from "./controls";
55
import { Foundations } from "./foundations";
6+
import { InteractiveSpecimens } from "./interactive";
67
import { AUDIT_INVENTORY } from "./inventory";
78
import { Surfaces } from "./surfaces";
9+
import { useMotionInput } from "../motion-input";
810

911
import "@chopin/visuals/styles.css";
1012
import "./controls.css";
@@ -14,6 +16,7 @@ import "./surfaces.css";
1416
import "./styles.css";
1517

1618
export function DesignAuditPage() {
19+
useMotionInput();
1720
return (
1821
<AuditFrame groups={AUDIT_INVENTORY}>
1922
<AuditSection id="foundations" title="Foundations">
@@ -25,6 +28,7 @@ export function DesignAuditPage() {
2528
</AuditSection>
2629
<AuditSection id="surfaces" title="Application surfaces">
2730
<Surfaces />
31+
<InteractiveSpecimens />
2832
</AuditSection>
2933
<AuditSection id="authored-content" title="Authored content">
3034
<AuthoredContent />

‎apps/web/src/design-audit/specimens.test.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
66
import { Controls } from "./controls";
77
import { AuthoredContent, callouts } from "./authored-content";
88
import { Foundations } from "./foundations";
9+
import { InteractiveSpecimens } from "./interactive";
910
import { AUDIT_INVENTORY } from "./inventory";
1011
import { Surfaces } from "./surfaces";
1112

@@ -237,12 +238,15 @@ describe("design audit specimens", () => {
237238
});
238239

239240
it("renders every application surface and its meaningful states", () => {
240-
let markup = renderToStaticMarkup(createElement(Surfaces));
241+
let markup = renderToStaticMarkup(
242+
createElement("div", null, createElement(Surfaces), createElement(InteractiveSpecimens)),
243+
);
241244

242245
for (let item of AUDIT_INVENTORY.find(group => group.id === "surfaces")!.items) {
243246
expect(markup).toContain(`data-audit-item="${item.id}"`);
244247
}
245-
expect(markup).toContain('role="dialog"');
248+
expect(plate(markup, "dialogs")).toContain('role="group"');
249+
expect(plate(markup, "dialogs")).not.toContain('aria-modal="true"');
246250
expect(markup).toContain('aria-current="page"');
247251
expect(markup).toContain('aria-label="Compact workspace view"');
248252
expect(markup).toContain('data-chat-entry="true"');

0 commit comments

Comments
 (0)