stylegallery 0.1.9 → 0.1.10
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/README.md +12 -0
- package/consumer-reference/agent-native/v2/material-registry.json +1 -1
- package/log.md +30 -0
- package/package.json +12 -2
- package/patterns/media-fit/icon-frame.md +1 -1
- package/patterns/viewport-shell/holy-grail.md +7 -0
- package/quality/claim-records/samples.md +1453 -9
- package/quality/claims.md +14 -7
- package/scripts/compiler/cli.mjs +20 -0
- package/scripts/conservator-exhibits.mjs +768 -0
- package/scripts/conservator.mjs +326 -0
- package/scripts/markdown-structure.mjs +45 -0
- package/scripts/pattern-data.mjs +325 -0
- package/scripts/registrar.mjs +323 -0
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
// Conservator exhibits: one browser-measurable claim per Layout pattern. Each exhibit renders the
|
|
2
|
+
// pattern doc's CSS block verbatim (the Conservator reads it from patterns/**.md at run time) around
|
|
3
|
+
// stress content that uses the doc's class hooks, then measures the spatial behavior the doc claims.
|
|
4
|
+
// The negative control removes one declaration the doc lists under "Properties That Break The Layout
|
|
5
|
+
// If Removed" and must make the named behavior fail.
|
|
6
|
+
//
|
|
7
|
+
// `measure(c, width)` runs inside the page and must be self-contained: it may use only its arguments.
|
|
8
|
+
// `c.expect(condition, behavior, message)` records a failure tagged with the behavior id.
|
|
9
|
+
// This module is plain data so the Registrar can import the exhibit list without a browser.
|
|
10
|
+
|
|
11
|
+
import { samples } from "./pattern-data.mjs";
|
|
12
|
+
|
|
13
|
+
const sentence = (topic, index) => `${topic} note ${index + 1}: layout contracts keep reading order, focus order, and spatial order aligned while content grows.`;
|
|
14
|
+
const paragraphs = (topic, count) => Array.from({ length: count }, (_, index) => `<p>${sentence(topic, index)}</p>`).join("\n");
|
|
15
|
+
const links = (labels) => labels.map((label) => `<a href="#${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}">${label}</a>`).join("\n");
|
|
16
|
+
const items = (tag, className, labels) => labels.map((label) => `<${tag}${className ? ` class="${className}"` : ""}>${label}</${tag}>`).join("\n");
|
|
17
|
+
const squareImage = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Crect width='120' height='120' fill='%23345'/%3E%3C/svg%3E";
|
|
18
|
+
const twelveLabels = ["Backlog", "Milestones", "Roadmap", "Releases", "Discussions", "Insights", "Security", "Settings", "Projects", "Wiki", "Packages", "Environments"];
|
|
19
|
+
const cardLabels = (noun) => Array.from({ length: 8 }, (_, index) => `${noun} ${index + 1}`);
|
|
20
|
+
|
|
21
|
+
// Shared in-page checks, defined as functions so their source is serialized into the page.
|
|
22
|
+
function gridColumns(c, root, child, min, gap) {
|
|
23
|
+
const container = c.r(root);
|
|
24
|
+
const cells = c.rs(child);
|
|
25
|
+
const expected = Math.max(1, Math.min(cells.length, Math.floor((container.width + gap) / (min + gap))));
|
|
26
|
+
const firstRow = cells.filter((cell) => Math.abs(cell.top - cells[0].top) < 2).length;
|
|
27
|
+
c.expect(firstRow === expected, "track-count", `${firstRow} items share the first row; ${expected} tracks fit ${Math.round(container.width)}px at ${min}px minimum`);
|
|
28
|
+
c.expect(cells.every((cell) => cell.right <= container.right + 1 && cell.left >= container.left - 1), "track-containment", "an item overflows its grid container");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function wrapRow(c, root, child, gap, mustWrapAtNarrow = true) {
|
|
32
|
+
const container = c.r(root);
|
|
33
|
+
const cells = c.rs(child);
|
|
34
|
+
c.expect(cells.every((cell) => cell.right <= container.right + 1), "wrap-containment", "an item overflows the row instead of wrapping");
|
|
35
|
+
const rows = c.rowsOf(cells);
|
|
36
|
+
if (mustWrapAtNarrow && c.W <= 400) c.expect(rows.length > 1, "wrap-containment", `items stay on ${rows.length} row at ${c.W}px`);
|
|
37
|
+
for (const row of rows) {
|
|
38
|
+
for (let index = 1; index < row.length; index += 1) {
|
|
39
|
+
const space = row[index].left - row[index - 1].right;
|
|
40
|
+
c.expect(c.near(space, gap, 1.5), "wrap-gap", `inline gap ${space.toFixed(1)}px differs from ${gap}px`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function twoPane(c, root, first, second, min, gap) {
|
|
46
|
+
const container = c.r(root);
|
|
47
|
+
const a = c.r(first);
|
|
48
|
+
const b = c.r(second);
|
|
49
|
+
const sideBySide = container.width >= 2 * min + gap;
|
|
50
|
+
if (sideBySide) {
|
|
51
|
+
c.expect(c.near(a.top, b.top, 1.5) && b.left >= a.right + gap - 1.5, "pane-reflow", `panes should sit side by side at ${Math.round(container.width)}px`);
|
|
52
|
+
c.expect(c.near(a.width, b.width, 1.5), "pane-reflow", `side-by-side panes should share the row equally (${a.width.toFixed(1)} vs ${b.width.toFixed(1)})`);
|
|
53
|
+
} else {
|
|
54
|
+
c.expect(b.top >= a.bottom + gap - 1.5 && c.near(a.left, b.left, 1.5), "pane-reflow", `panes should stack below ${2 * min + gap}px, container is ${Math.round(container.width)}px`);
|
|
55
|
+
}
|
|
56
|
+
c.expect(a.right <= container.right + 1 && b.right <= container.right + 1, "pane-containment", "a pane overflows its container");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function blockRhythm(c, root, gap) {
|
|
60
|
+
const children = [...c.q(root).children].map((element) => element.getBoundingClientRect());
|
|
61
|
+
for (let index = 1; index < children.length; index += 1) {
|
|
62
|
+
const space = children[index].top - children[index - 1].bottom;
|
|
63
|
+
c.expect(c.near(space, gap, 1), "block-rhythm", `block gap ${space.toFixed(1)}px differs from ${gap}px`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function multiColumn(c, root, child, width, gap) {
|
|
68
|
+
const container = c.r(root);
|
|
69
|
+
const expected = Math.max(1, Math.floor((container.width + gap) / (width + gap)));
|
|
70
|
+
const lefts = new Set(c.rs(child).map((cell) => Math.round(cell.left / 4)));
|
|
71
|
+
if (expected > 1) c.expect(lefts.size >= 2, "column-flow", `content stays in ${lefts.size} column where ${expected} columns of ${width}px fit`);
|
|
72
|
+
else c.expect(lefts.size === 1, "column-flow", `content splits into ${lefts.size} columns where only one ${width}px column fits`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const sharedMeasures = { blockRhythm, gridColumns, multiColumn, twoPane, wrapRow };
|
|
76
|
+
|
|
77
|
+
export const exhibits = [
|
|
78
|
+
{
|
|
79
|
+
slug: "stack",
|
|
80
|
+
claim: "Direct children of `stack` are separated by one 1rem block gap.",
|
|
81
|
+
html: `<section class="stack" aria-labelledby="stack-heading">
|
|
82
|
+
<h2 id="stack-heading">Release checklist</h2>
|
|
83
|
+
${paragraphs("Checklist", 2)}
|
|
84
|
+
<button type="button">Start review</button>
|
|
85
|
+
</section>`,
|
|
86
|
+
measure: (c) => c.blockRhythm(c, ".stack", 16),
|
|
87
|
+
negative: { selector: ".stack", property: "display", behavior: "block-rhythm" },
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
slug: "box",
|
|
91
|
+
claim: "Content inside `box` is inset 1rem from every edge of the box.",
|
|
92
|
+
html: `<aside class="box" aria-labelledby="box-heading">
|
|
93
|
+
<h2 id="box-heading">Support window</h2>
|
|
94
|
+
<p>Responses resume Monday at 09:00.</p>
|
|
95
|
+
</aside>`,
|
|
96
|
+
measure: (c) => {
|
|
97
|
+
const box = c.r(".box");
|
|
98
|
+
const first = c.r(".box > h2");
|
|
99
|
+
const last = c.r(".box > p");
|
|
100
|
+
c.expect(c.near(first.left - box.left, 16, 1) && c.near(first.top - box.top, 16, 1), "box-inset", `start inset is ${(first.left - box.left).toFixed(1)}px by ${(first.top - box.top).toFixed(1)}px, expected 16px`);
|
|
101
|
+
c.expect(c.near(box.right - last.right, 16, 1) && c.near(box.bottom - last.bottom, 16, 1), "box-inset", `end inset is ${(box.right - last.right).toFixed(1)}px by ${(box.bottom - last.bottom).toFixed(1)}px, expected 16px`);
|
|
102
|
+
},
|
|
103
|
+
negative: { selector: ".box", property: "padding", behavior: "box-inset" },
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
slug: "center",
|
|
107
|
+
claim: "`center` caps its content box at 64rem, centers itself in wider space, and keeps 1rem inline gutters.",
|
|
108
|
+
html: `<article class="center" aria-labelledby="center-heading">
|
|
109
|
+
<h2 id="center-heading">Editorial policy</h2>
|
|
110
|
+
${paragraphs("Policy", 3)}
|
|
111
|
+
</article>`,
|
|
112
|
+
measure: (c) => {
|
|
113
|
+
const box = c.r(".center");
|
|
114
|
+
const text = c.r(".center > p");
|
|
115
|
+
c.expect(c.near(box.width, Math.min(c.W, 1024 + 32), 1), "center-measure", `width ${box.width.toFixed(1)}px, expected min(viewport, 64rem + gutters)`);
|
|
116
|
+
c.expect(c.near(box.left, c.W - box.right, 1), "center-centered", `left space ${box.left.toFixed(1)}px differs from right space ${(c.W - box.right).toFixed(1)}px`);
|
|
117
|
+
c.expect(c.near(text.left - box.left, 16, 1), "center-gutter", `inline gutter ${(text.left - box.left).toFixed(1)}px, expected 16px`);
|
|
118
|
+
},
|
|
119
|
+
negative: { selector: ".center", property: "margin-inline", behavior: "center-centered" },
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
slug: "cluster",
|
|
123
|
+
claim: "`cluster` items keep a 0.75rem gap and wrap onto new rows instead of overflowing.",
|
|
124
|
+
html: `<nav class="cluster" aria-label="Project actions">
|
|
125
|
+
${links(twelveLabels)}
|
|
126
|
+
</nav>`,
|
|
127
|
+
measure: (c) => c.wrapRow(c, ".cluster", ".cluster > a", 12),
|
|
128
|
+
negative: { selector: ".cluster", property: "flex-wrap", behavior: "wrap-containment", allow: ["overflow"] },
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
slug: "content-limiter",
|
|
132
|
+
claim: "`content-limiter` caps prose at a 70ch measure plus 1rem gutters and centers it.",
|
|
133
|
+
html: `<article class="content_limiter" aria-labelledby="limiter-heading">
|
|
134
|
+
<h2 id="limiter-heading">Incident summary</h2>
|
|
135
|
+
${paragraphs("Incident", 3)}
|
|
136
|
+
</article>`,
|
|
137
|
+
measure: (c) => {
|
|
138
|
+
const box = c.r(".content_limiter");
|
|
139
|
+
const measure = c.length("70ch", ".content_limiter");
|
|
140
|
+
c.expect(c.near(box.width, Math.min(c.W, measure + 32), 1), "limiter-measure", `width ${box.width.toFixed(1)}px, expected min(viewport, 70ch + gutters = ${(measure + 32).toFixed(1)}px)`);
|
|
141
|
+
c.expect(c.near(box.left, c.W - box.right, 1), "limiter-centered", "the limited region is not centered");
|
|
142
|
+
},
|
|
143
|
+
negative: { selector: ".content_limiter", property: "max-inline-size", behavior: "limiter-measure" },
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
slug: "super-center",
|
|
147
|
+
claim: "`super-center` places its child at the center of a viewport-tall region on both axes.",
|
|
148
|
+
html: `<section class="super_center" aria-labelledby="centered-dialog-title">
|
|
149
|
+
<form aria-labelledby="centered-dialog-title">
|
|
150
|
+
<h2 id="centered-dialog-title">Confirm archive</h2>
|
|
151
|
+
<button type="button">Archive project</button>
|
|
152
|
+
</form>
|
|
153
|
+
</section>`,
|
|
154
|
+
measure: (c) => {
|
|
155
|
+
const region = c.r(".super_center");
|
|
156
|
+
const child = c.r(".super_center > form");
|
|
157
|
+
c.expect(region.height >= c.H - 1, "super-center-fill", `region is ${region.height.toFixed(1)}px tall in a ${c.H}px viewport`);
|
|
158
|
+
c.expect(child.width < region.width - 2 && child.height < region.height - 2, "super-center-both", "the child stretches across the region instead of being centered");
|
|
159
|
+
c.expect(c.near(child.left - region.left, region.right - child.right, 1.5) && c.near(child.top - region.top, region.bottom - child.bottom, 1.5), "super-center-both", "the child is off center");
|
|
160
|
+
},
|
|
161
|
+
negative: { selector: ".super_center", property: "place-items", behavior: "super-center-both" },
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
slug: "icon-frame",
|
|
165
|
+
claim: "`icon-frame` is a 2.5rem square slot with its icon centered.",
|
|
166
|
+
// The doc sample verbatim; the harness gives its empty svg a 24px intrinsic size to center.
|
|
167
|
+
html: `<p>Next event ${samples["icon-frame"].trim()} on Monday.</p>`,
|
|
168
|
+
harness: ".icon_frame > svg { block-size: 24px; inline-size: 24px; }",
|
|
169
|
+
measure: (c) => {
|
|
170
|
+
const frame = c.r(".icon_frame");
|
|
171
|
+
const icon = c.r(".icon_frame > svg");
|
|
172
|
+
c.expect(c.near(frame.width, 40, 0.5) && c.near(frame.height, 40, 0.5), "icon-frame-square", `slot is ${frame.width.toFixed(1)}x${frame.height.toFixed(1)}px, expected 40x40`);
|
|
173
|
+
c.expect(c.near(icon.left - frame.left, frame.right - icon.right, 1) && c.near(icon.top - frame.top, frame.bottom - icon.bottom, 1), "icon-frame-center", "icon is not centered in its slot");
|
|
174
|
+
},
|
|
175
|
+
negative: { selector: ".icon_frame", property: "place-items", behavior: "icon-frame-center" },
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
slug: "frame",
|
|
179
|
+
claim: "`frame` holds a 16:9 slot at full inline size and its media fills the slot regardless of the media's own ratio.",
|
|
180
|
+
html: `<figure class="frame">
|
|
181
|
+
<img alt="Product walkthrough still" class="frame_media" src="${squareImage}">
|
|
182
|
+
</figure>`,
|
|
183
|
+
measure: (c) => {
|
|
184
|
+
const frame = c.r(".frame");
|
|
185
|
+
const media = c.r(".frame_media");
|
|
186
|
+
c.expect(c.near(frame.width / frame.height, 16 / 9, 0.02), "frame-ratio", `slot ratio ${(frame.width / frame.height).toFixed(3)}, expected 1.778`);
|
|
187
|
+
c.expect(c.near(media.width, frame.width, 1) && c.near(media.height, frame.height, 1), "frame-fill", "media does not fill the slot");
|
|
188
|
+
c.expect(c.near(frame.width, c.W, 1), "frame-fill", "slot does not take the full inline size");
|
|
189
|
+
},
|
|
190
|
+
negative: { selector: ".frame", property: "aspect-ratio", behavior: "frame-ratio" },
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
slug: "cover",
|
|
194
|
+
claim: "`cover` is at least viewport tall and gives its central region all space between the header and footer rows.",
|
|
195
|
+
html: `<section class="cover" aria-labelledby="cover-title">
|
|
196
|
+
<header><p>Step 2 of 4</p></header>
|
|
197
|
+
<main><h1 id="cover-title">Choose a billing profile</h1></main>
|
|
198
|
+
<footer><button type="button">Continue</button></footer>
|
|
199
|
+
</section>`,
|
|
200
|
+
measure: (c) => {
|
|
201
|
+
const cover = c.r(".cover");
|
|
202
|
+
const header = c.r(".cover > header");
|
|
203
|
+
const main = c.r(".cover > main");
|
|
204
|
+
const footer = c.r(".cover > footer");
|
|
205
|
+
c.expect(cover.height >= c.H - 1, "cover-fill", `cover is ${cover.height.toFixed(1)}px tall in a ${c.H}px viewport`);
|
|
206
|
+
c.expect(c.near(footer.bottom, cover.bottom - 16, 1), "cover-fill", "footer does not rest on the cover's end padding");
|
|
207
|
+
c.expect(c.near(main.top, header.bottom + 16, 1) && c.near(main.bottom, footer.top - 16, 1), "cover-center-region", "the central region does not span the space between header and footer");
|
|
208
|
+
},
|
|
209
|
+
negative: { selector: ".cover", property: "min-block-size", behavior: "cover-fill" },
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
slug: "sidebar",
|
|
213
|
+
claim: "`sidebar` keeps an 18rem sidebar beside a wider primary region and wraps them when the primary would fall below 16rem.",
|
|
214
|
+
html: `<section class="sidebar" aria-labelledby="sidebar-title">
|
|
215
|
+
<article class="sidebar_primary">
|
|
216
|
+
<h2 id="sidebar-title">Quarterly planning notes</h2>
|
|
217
|
+
${paragraphs("Planning", 2)}
|
|
218
|
+
</article>
|
|
219
|
+
<aside class="sidebar_secondary">Meeting agenda and owners</aside>
|
|
220
|
+
</section>`,
|
|
221
|
+
measure: (c) => {
|
|
222
|
+
const root = c.r(".sidebar");
|
|
223
|
+
const primary = c.r(".sidebar_primary");
|
|
224
|
+
const secondary = c.r(".sidebar_secondary");
|
|
225
|
+
const fits = root.width >= 256 + 16 + 288;
|
|
226
|
+
if (fits) {
|
|
227
|
+
c.expect(c.near(primary.top, secondary.top, 1) && secondary.left >= primary.right + 15, "sidebar-beside", "sidebar does not sit beside the primary region");
|
|
228
|
+
c.expect(primary.width > secondary.width && primary.width >= 256 - 1, "sidebar-beside", "primary region does not keep priority");
|
|
229
|
+
} else {
|
|
230
|
+
c.expect(secondary.top >= primary.bottom + 15, "sidebar-wrap", `regions should wrap at ${Math.round(root.width)}px`);
|
|
231
|
+
}
|
|
232
|
+
c.expect(primary.right <= root.right + 1 && secondary.right <= root.right + 1, "sidebar-wrap", "a region overflows the container");
|
|
233
|
+
},
|
|
234
|
+
negative: { selector: ".sidebar", property: "flex-wrap", behavior: "sidebar-wrap", allow: ["overflow"] },
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
slug: "switcher",
|
|
238
|
+
claim: "`switcher` lays all items in one row when the container is at least 40rem and stacks all of them otherwise, never a partial row.",
|
|
239
|
+
html: `<section class="switcher" aria-label="Pricing tiers">
|
|
240
|
+
<article class="switcher_item">Starter plan</article>
|
|
241
|
+
<article class="switcher_item">Team plan</article>
|
|
242
|
+
<article class="switcher_item">Enterprise plan</article>
|
|
243
|
+
</section>`,
|
|
244
|
+
measure: (c) => {
|
|
245
|
+
const root = c.r(".switcher");
|
|
246
|
+
const rows = c.rowsOf(c.rs(".switcher_item"));
|
|
247
|
+
const expected = root.width >= 640 ? 1 : 3;
|
|
248
|
+
c.expect(rows.length === expected, "switcher-all-or-none", `${rows.length} rows at ${Math.round(root.width)}px, expected ${expected}`);
|
|
249
|
+
},
|
|
250
|
+
negative: { selector: ".switcher_item", property: "flex-basis", behavior: "switcher-all-or-none" },
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
slug: "media-object",
|
|
254
|
+
claim: "`media-object` keeps a 6rem media slot top-aligned beside its content when both fit and wraps the content below otherwise.",
|
|
255
|
+
html: `<article class="media_object">
|
|
256
|
+
<figure class="media_object_media"><img alt="Speaker portrait" src="${squareImage}" width="96" height="96"></figure>
|
|
257
|
+
<div class="media_object_content">
|
|
258
|
+
<h2>Design systems briefing</h2>
|
|
259
|
+
${paragraphs("Speaker", 2)}
|
|
260
|
+
</div>
|
|
261
|
+
</article>`,
|
|
262
|
+
harness: ".media_object_media img { display: block; }",
|
|
263
|
+
measure: (c) => {
|
|
264
|
+
const root = c.r(".media_object");
|
|
265
|
+
const media = c.r(".media_object_media");
|
|
266
|
+
const content = c.r(".media_object_content");
|
|
267
|
+
c.expect(c.near(media.width, 96, 1), "media-slot", `media slot is ${media.width.toFixed(1)}px, expected 96px`);
|
|
268
|
+
if (root.width >= 96 + 16 + 320) {
|
|
269
|
+
c.expect(c.near(media.top, content.top, 1) && content.left >= media.right + 15, "media-beside", "content does not sit top-aligned beside the media");
|
|
270
|
+
} else {
|
|
271
|
+
c.expect(content.top >= media.bottom + 15, "media-wrap", `content should wrap below the media at ${Math.round(root.width)}px`);
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
negative: { selector: ".media_object", property: "flex-wrap", behavior: "media-wrap" },
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
slug: "split-nav",
|
|
278
|
+
claim: "`split-nav` holds primary links at the inline start and pushes secondary links to the inline end.",
|
|
279
|
+
html: `<nav class="split_nav" aria-label="Documentation">
|
|
280
|
+
<section class="split_nav_primary"><a href="#guides">Guides</a><a href="#api">API</a></section>
|
|
281
|
+
<section class="split_nav_secondary"><a href="#sign-in">Sign in</a></section>
|
|
282
|
+
</nav>`,
|
|
283
|
+
measure: (c) => {
|
|
284
|
+
const nav = c.r(".split_nav");
|
|
285
|
+
const primary = c.r(".split_nav_primary");
|
|
286
|
+
const secondary = c.r(".split_nav_secondary");
|
|
287
|
+
c.expect(c.near(primary.left, nav.left, 1), "split-nav-ends", "primary links do not start at the inline start");
|
|
288
|
+
c.expect(c.near(secondary.right, nav.right, 1), "split-nav-ends", `secondary links end ${(nav.right - secondary.right).toFixed(1)}px before the inline end`);
|
|
289
|
+
},
|
|
290
|
+
negative: { selector: ".split_nav_secondary", property: "margin-inline-start", behavior: "split-nav-ends" },
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
slug: "holy-grail",
|
|
294
|
+
claim: "`holy-grail` spans header and footer across three columns and keeps left, main, and right side by side from 48rem, stacks all five regions in one column in DOM order below 48rem without inline overflow, and fills the viewport block size.",
|
|
295
|
+
html: `<section class="holy_grail" aria-label="News page">
|
|
296
|
+
<header class="holy_grail_header">Daily briefing</header>
|
|
297
|
+
<aside class="holy_grail_left">Topic navigation</aside>
|
|
298
|
+
<main class="holy_grail_main">${paragraphs("Lead story", 2)}</main>
|
|
299
|
+
<aside class="holy_grail_right">Market snapshot</aside>
|
|
300
|
+
<footer class="holy_grail_footer">Edition links</footer>
|
|
301
|
+
</section>`,
|
|
302
|
+
measure: (c) => {
|
|
303
|
+
const root = c.r(".holy_grail");
|
|
304
|
+
const regions = ["header", "left", "main", "right", "footer"].map((area) => c.r(`.holy_grail_${area}`));
|
|
305
|
+
const [header, left, main, right, footer] = regions;
|
|
306
|
+
if (c.W < 768) {
|
|
307
|
+
c.expect(regions.every((region) => c.near(region.left, root.left, 1) && c.near(region.width, root.width, 1)), "holy-grail-stack", "a region does not take the full single-column width");
|
|
308
|
+
c.expect(regions.every((region, index) => index === 0 || region.top >= regions[index - 1].bottom + 15), "holy-grail-stack", "regions do not stack in DOM order with the 1rem gap");
|
|
309
|
+
} else {
|
|
310
|
+
c.expect(c.near(left.top, main.top, 1) && c.near(main.top, right.top, 1) && left.right < main.left && main.right < right.left, "holy-grail-columns", "left, main, and right are not side by side");
|
|
311
|
+
c.expect(c.near(header.width, root.width, 1) && c.near(footer.width, root.width, 1), "holy-grail-span", "header or footer does not span all columns");
|
|
312
|
+
}
|
|
313
|
+
c.expect(root.height >= c.H - 1 && c.near(footer.bottom, root.bottom, 1), "holy-grail-fill", "shell does not fill the viewport block size");
|
|
314
|
+
},
|
|
315
|
+
negative: { selector: ".holy_grail", property: "grid-template", behavior: "holy-grail-columns", allow: ["holy-grail-span"] },
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
slug: "sticky-footer",
|
|
319
|
+
claim: "`sticky-footer` keeps the footer at the viewport's block end when content is short.",
|
|
320
|
+
html: `<section class="sticky_footer" aria-label="Account setup">
|
|
321
|
+
<header>Account setup</header>
|
|
322
|
+
<main class="sticky_footer_main">Profile fields and preferences</main>
|
|
323
|
+
<footer><button type="button">Save profile</button></footer>
|
|
324
|
+
</section>`,
|
|
325
|
+
measure: (c) => {
|
|
326
|
+
const footer = c.r(".sticky_footer > footer");
|
|
327
|
+
c.expect(c.near(footer.bottom, c.H, 1), "sticky-footer-end", `footer ends at ${footer.bottom.toFixed(1)}px in a ${c.H}px viewport`);
|
|
328
|
+
},
|
|
329
|
+
negative: { selector: ".sticky_footer", property: "min-block-size", behavior: "sticky-footer-end" },
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
slug: "sticky-header",
|
|
333
|
+
claim: "`sticky-header` stays pinned at the viewport's block start while the document scrolls.",
|
|
334
|
+
html: `<header class="sticky_header">
|
|
335
|
+
<nav aria-label="Repository"><a href="#code">Code</a> <a href="#issues">Issues</a> <a href="#pulls">Pull requests</a></nav>
|
|
336
|
+
</header>
|
|
337
|
+
<main>${paragraphs("Repository", 30)}</main>`,
|
|
338
|
+
harness: ".sticky_header { background: #fff; }",
|
|
339
|
+
measure: (c) => {
|
|
340
|
+
window.scrollTo(0, 600);
|
|
341
|
+
const header = c.r(".sticky_header");
|
|
342
|
+
c.expect(scrollY > 0 && c.near(header.top, 0, 1), "sticky-header-pinned", `after scrolling ${scrollY}px the header sits at ${header.top.toFixed(1)}px`);
|
|
343
|
+
window.scrollTo(0, 0);
|
|
344
|
+
},
|
|
345
|
+
negative: { selector: ".sticky_header", property: "position", behavior: "sticky-header-pinned" },
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
slug: "scroll-body-shell",
|
|
349
|
+
claim: "`scroll-body-shell` keeps header and footer inside the viewport while only the named body scrolls; the document root never scrolls.",
|
|
350
|
+
html: `<section class="scroll_body_shell" aria-label="Inbox">
|
|
351
|
+
<header><nav aria-label="Inbox filters">${links(["All", "Unread", "Flagged"])}</nav></header>
|
|
352
|
+
<main class="scroll_body_shell_body" data-scroll-owner tabindex="-1">
|
|
353
|
+
<h1>Message list owns vertical scrolling</h1>
|
|
354
|
+
${paragraphs("Message", 40)}
|
|
355
|
+
<p><button type="button">Load older messages</button></p>
|
|
356
|
+
</main>
|
|
357
|
+
<footer><a href="#selected">Selected message actions</a></footer>
|
|
358
|
+
</section>`,
|
|
359
|
+
measure: (c) => {
|
|
360
|
+
const body = c.q(".scroll_body_shell_body");
|
|
361
|
+
const footer = c.r(".scroll_body_shell > footer");
|
|
362
|
+
const header = c.r(".scroll_body_shell > header");
|
|
363
|
+
body.scrollTop = 120;
|
|
364
|
+
const moved = body.scrollTop > 0;
|
|
365
|
+
body.scrollTop = 0;
|
|
366
|
+
c.expect(body.scrollHeight > body.clientHeight + 1 && moved, "scroll-owner", "the body region does not scroll its overflowing content");
|
|
367
|
+
c.expect(c.root.scrollHeight <= c.root.clientHeight + 1, "scroll-owner", `document root overflows by ${c.root.scrollHeight - c.root.clientHeight}px`);
|
|
368
|
+
c.expect(header.top >= -1 && footer.bottom <= c.H + 1, "shell-regions-visible", "header or footer leaves the viewport");
|
|
369
|
+
},
|
|
370
|
+
negative: { selector: ".scroll_body_shell_body", property: "overflow", behavior: "scroll-owner" },
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
slug: "fixed-sidenav-shell",
|
|
374
|
+
claim: "`fixed-sidenav-shell` keeps a 16rem navigation column in place while only the main region scrolls.",
|
|
375
|
+
html: `<section class="fixed_sidenav_shell" aria-label="Settings">
|
|
376
|
+
<nav class="fixed_sidenav_shell_list" aria-label="Settings sections">${links(["Profile", "Billing", "Security"])}</nav>
|
|
377
|
+
<main class="fixed_sidenav_shell_main" tabindex="0">${paragraphs("Setting", 50)}</main>
|
|
378
|
+
</section>`,
|
|
379
|
+
harness: ".fixed_sidenav_shell_list a { display: block; }",
|
|
380
|
+
measure: (c) => {
|
|
381
|
+
const nav = c.r(".fixed_sidenav_shell_list");
|
|
382
|
+
const main = c.q(".fixed_sidenav_shell_main");
|
|
383
|
+
c.expect(c.near(nav.width, 256, 1), "sidenav-column", `navigation column is ${nav.width.toFixed(1)}px, expected 256px`);
|
|
384
|
+
main.scrollTop = 200;
|
|
385
|
+
const moved = main.scrollTop > 0;
|
|
386
|
+
const navAfter = c.r(".fixed_sidenav_shell_list");
|
|
387
|
+
main.scrollTop = 0;
|
|
388
|
+
c.expect(moved && main.scrollHeight > main.clientHeight + 1, "sidenav-scroll-owner", "main region does not scroll its overflowing content");
|
|
389
|
+
c.expect(c.root.scrollHeight <= c.root.clientHeight + 1, "sidenav-scroll-owner", `document root overflows by ${c.root.scrollHeight - c.root.clientHeight}px`);
|
|
390
|
+
c.expect(c.near(navAfter.top, nav.top, 0.5), "sidenav-column", "navigation moved while main scrolled");
|
|
391
|
+
},
|
|
392
|
+
negative: { selector: ".fixed_sidenav_shell_main", property: "overflow", behavior: "sidenav-scroll-owner", allow: ["overflow"] },
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
slug: "sticky-aside",
|
|
396
|
+
claim: "`sticky-aside` keeps a 16rem aside visible 1rem from the viewport's block start during long reads.",
|
|
397
|
+
html: `<section class="sticky_aside" aria-label="Long article">
|
|
398
|
+
<main class="sticky_aside_main">${paragraphs("Article section", 80)}</main>
|
|
399
|
+
<aside class="sticky_aside_side">Table of contents</aside>
|
|
400
|
+
</section>`,
|
|
401
|
+
measure: (c) => {
|
|
402
|
+
const aside = c.r(".sticky_aside_side");
|
|
403
|
+
c.expect(c.near(aside.width, 256, 1), "sticky-aside-column", `aside is ${aside.width.toFixed(1)}px, expected 256px`);
|
|
404
|
+
window.scrollTo(0, 800);
|
|
405
|
+
const pinned = c.r(".sticky_aside_side");
|
|
406
|
+
c.expect(scrollY > 0 && c.near(pinned.top, 16, 1), "sticky-aside-pinned", `after scrolling ${scrollY}px the aside sits at ${pinned.top.toFixed(1)}px`);
|
|
407
|
+
window.scrollTo(0, 0);
|
|
408
|
+
},
|
|
409
|
+
negative: { selector: ".sticky_aside_side", property: "position", behavior: "sticky-aside-pinned" },
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
slug: "ram-grid",
|
|
413
|
+
claim: "`ram-grid` repeats as many equal columns of at least 16rem as fit and collapses to one full-width column below that.",
|
|
414
|
+
html: `<section class="ram_grid" aria-label="Photo albums">
|
|
415
|
+
${items("article", "", cardLabels("Album"))}
|
|
416
|
+
</section>`,
|
|
417
|
+
measure: (c) => c.gridColumns(c, ".ram_grid", ".ram_grid > article", 256, 16),
|
|
418
|
+
negative: { selector: ".ram_grid", property: "display", behavior: "track-count" },
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
slug: "card-grid",
|
|
422
|
+
claim: "`card-grid` repeats as many equal columns of at least 18rem as fit and collapses to one full-width column below that.",
|
|
423
|
+
html: `<section class="card_grid" aria-label="Project cards">
|
|
424
|
+
${items("article", "", cardLabels("Project"))}
|
|
425
|
+
</section>`,
|
|
426
|
+
measure: (c) => c.gridColumns(c, ".card_grid", ".card_grid > article", 288, 16),
|
|
427
|
+
negative: { selector: ".card_grid", property: "grid-template-columns", behavior: "track-count" },
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
slug: "twelve-span-grid",
|
|
431
|
+
claim: "`twelve-span-grid` items spanning four of twelve tracks sit three to a row at equal widths.",
|
|
432
|
+
html: `<section class="twelve_span_grid" aria-label="Dashboard modules">
|
|
433
|
+
${items("article", "twelve_span_grid_item", ["Revenue module", "Retention module", "Pipeline module", "Churn module", "Forecast module", "Support module"])}
|
|
434
|
+
</section>`,
|
|
435
|
+
measure: (c) => {
|
|
436
|
+
const root = c.r(".twelve_span_grid");
|
|
437
|
+
const cells = c.rs(".twelve_span_grid_item");
|
|
438
|
+
const rows = c.rowsOf(cells);
|
|
439
|
+
const track = (root.width - 11 * 16) / 12;
|
|
440
|
+
c.expect(rows.every((row) => row.length === 3), "twelve-span-thirds", `rows hold ${rows.map((row) => row.length).join("/")} items, expected 3 each`);
|
|
441
|
+
c.expect(cells.every((cell) => c.near(cell.width, 4 * track + 3 * 16, 1)), "twelve-span-thirds", "an item does not span four tracks");
|
|
442
|
+
},
|
|
443
|
+
negative: { selector: ".twelve_span_grid_item", property: "grid-column", behavior: "twelve-span-thirds" },
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
slug: "page-grid",
|
|
447
|
+
claim: "`page-grid` centers content in a track of at most 72rem with outer gutters of at least 1rem plus the gap.",
|
|
448
|
+
html: `<section class="page_grid" aria-label="Article page">
|
|
449
|
+
<main class="page_grid_content">${paragraphs("Article", 2)}</main>
|
|
450
|
+
</section>`,
|
|
451
|
+
measure: (c) => {
|
|
452
|
+
const content = c.r(".page_grid_content");
|
|
453
|
+
c.expect(content.width <= 1152 + 1 && c.near(content.width, Math.min(1152, c.W - 64), 1), "page-grid-track", `content track is ${content.width.toFixed(1)}px`);
|
|
454
|
+
c.expect(c.near(content.left, c.W - content.right, 1) && content.left >= 32 - 1, "page-grid-centered", "content track is not centered between its gutters");
|
|
455
|
+
},
|
|
456
|
+
negative: { selector: ".page_grid_content", property: "grid-column", behavior: "page-grid-centered", allow: ["page-grid-track"] },
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
slug: "grid-wrapper",
|
|
460
|
+
claim: "`grid-wrapper` centers main content in a track of at most 64rem and lets it take the full width when space is short.",
|
|
461
|
+
html: `<section class="grid_wrapper" aria-label="Marketing page">
|
|
462
|
+
<main class="grid_wrapper_main">${paragraphs("Campaign", 2)}</main>
|
|
463
|
+
</section>`,
|
|
464
|
+
measure: (c) => {
|
|
465
|
+
const main = c.r(".grid_wrapper_main");
|
|
466
|
+
c.expect(c.near(main.width, Math.min(1024, c.W), 1), "grid-wrapper-track", `main track is ${main.width.toFixed(1)}px`);
|
|
467
|
+
c.expect(c.near(main.left, c.W - main.right, 1), "grid-wrapper-centered", "main track is not centered");
|
|
468
|
+
},
|
|
469
|
+
negative: { selector: ".grid_wrapper_main", property: "grid-column", behavior: "grid-wrapper-centered", allow: ["grid-wrapper-track"] },
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
slug: "columns",
|
|
473
|
+
claim: "`columns` flows long content into as many 18rem columns as fit and returns to one column when narrow.",
|
|
474
|
+
html: `<article class="columns" aria-labelledby="columns-heading">
|
|
475
|
+
<h2 id="columns-heading">Release notes</h2>
|
|
476
|
+
${paragraphs("Release", 12)}
|
|
477
|
+
</article>`,
|
|
478
|
+
measure: (c) => c.multiColumn(c, ".columns", ".columns > p", 288, 32),
|
|
479
|
+
negative: { selector: ".columns", property: "columns", behavior: "column-flow" },
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
slug: "deconstructed-pancake",
|
|
483
|
+
claim: "`deconstructed-pancake` items stretch to fill each row and wrap when a 16rem item no longer fits.",
|
|
484
|
+
html: `<section class="deconstructed_pancake" aria-label="Plan comparison">
|
|
485
|
+
${items("article", "deconstructed_pancake_item", ["Solo plan", "Studio plan", "Agency plan"])}
|
|
486
|
+
</section>`,
|
|
487
|
+
measure: (c) => {
|
|
488
|
+
const root = c.r(".deconstructed_pancake");
|
|
489
|
+
const rows = c.rowsOf(c.rs(".deconstructed_pancake_item"));
|
|
490
|
+
const perRow = Math.max(1, Math.min(3, Math.floor((root.width + 16) / (256 + 16))));
|
|
491
|
+
c.expect(rows[0].length === perRow, "pancake-wrap", `first row holds ${rows[0].length} items, expected ${perRow}`);
|
|
492
|
+
for (const row of rows) {
|
|
493
|
+
const filled = row.at(-1).right - row[0].left;
|
|
494
|
+
c.expect(c.near(filled, root.width, 1), "pancake-stretch", `a row fills ${filled.toFixed(1)}px of ${root.width.toFixed(1)}px`);
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
negative: { selector: ".deconstructed_pancake_item", property: "flex", behavior: "pancake-stretch", allow: ["pancake-wrap"] },
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
slug: "line-up",
|
|
501
|
+
claim: "`line-up` pushes each card's footer to the card's block end so actions align across cards of different lengths.",
|
|
502
|
+
html: `<div class="exhibit_row">
|
|
503
|
+
${["Short summary.", "A medium summary that runs across a couple of lines in narrow cards.", "A long summary that keeps going so this card body is clearly taller than its neighbours, which is the case the pattern exists for."].map((summary, index) => `<article class="line_up" aria-labelledby="line-up-${index}">
|
|
504
|
+
<section class="line_up_body"><h2 id="line-up-${index}">Plan ${index + 1}</h2><p>${summary}</p></section>
|
|
505
|
+
<footer class="line_up_footer"><button type="button">Open ${index + 1}</button></footer>
|
|
506
|
+
</article>`).join("\n")}
|
|
507
|
+
</div>`,
|
|
508
|
+
harness: ".exhibit_row { display: grid; gap: 1rem; grid-template-columns: repeat(3, minmax(0, 1fr)); }",
|
|
509
|
+
measure: (c) => {
|
|
510
|
+
const footers = c.rs(".line_up_footer");
|
|
511
|
+
const cards = c.rs(".line_up");
|
|
512
|
+
c.expect(footers.every((footer) => c.near(footer.bottom, footers[0].bottom, 1)), "line-up-footers", "card footers do not share one block-end line");
|
|
513
|
+
c.expect(footers.every((footer, index) => c.near(footer.bottom, cards[index].bottom, 1)), "line-up-footers", "a footer does not rest on its card's block end");
|
|
514
|
+
},
|
|
515
|
+
negative: { selector: ".line_up_footer", property: "margin-block-start", behavior: "line-up-footers" },
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
slug: "clamped-card",
|
|
519
|
+
claim: "`clamped-card` sizes to half the viewport, clamped between 16rem and 28rem, and centers itself.",
|
|
520
|
+
html: `<article class="clamped_card" aria-labelledby="clamped-title">
|
|
521
|
+
<h2 id="clamped-title">Invite teammate</h2>
|
|
522
|
+
<p>The card grows with the viewport but stops at its intended width.</p>
|
|
523
|
+
</article>`,
|
|
524
|
+
measure: (c) => {
|
|
525
|
+
const card = c.r(".clamped_card");
|
|
526
|
+
const expected = Math.min(448, Math.max(256, c.W / 2));
|
|
527
|
+
c.expect(c.near(card.width, expected, 1), "clamped-width", `card is ${card.width.toFixed(1)}px, expected ${expected}px`);
|
|
528
|
+
c.expect(c.near(card.left, c.W - card.right, 1), "clamped-centered", "card is not centered");
|
|
529
|
+
},
|
|
530
|
+
negative: { selector: ".clamped_card", property: "inline-size", behavior: "clamped-width" },
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
slug: "fluid-styles",
|
|
534
|
+
claim: "`fluid-styles` fills its parent up to 72rem and centers itself beyond that.",
|
|
535
|
+
html: `<section class="fluid_styles" aria-labelledby="fluid-title">
|
|
536
|
+
<h2 id="fluid-title">Operations overview</h2>
|
|
537
|
+
${paragraphs("Operations", 2)}
|
|
538
|
+
</section>`,
|
|
539
|
+
measure: (c) => {
|
|
540
|
+
const region = c.r(".fluid_styles");
|
|
541
|
+
c.expect(c.near(region.width, Math.min(c.W, 1152), 1), "fluid-width", `region is ${region.width.toFixed(1)}px`);
|
|
542
|
+
c.expect(c.near(region.left, c.W - region.right, 1), "fluid-centered", "region is not centered");
|
|
543
|
+
},
|
|
544
|
+
negative: { selector: ".fluid_styles", property: "inline-size", behavior: "fluid-width" },
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
slug: "split-screen",
|
|
548
|
+
claim: "`split-screen` shows two equal panes side by side when two 20rem panes fit, stacks them otherwise, and is at least viewport tall.",
|
|
549
|
+
html: `<section class="split_screen" aria-label="Compare documents">
|
|
550
|
+
<section class="split_screen_pane" aria-label="Draft contract">${paragraphs("Draft clause", 2)}</section>
|
|
551
|
+
<section class="split_screen_pane" aria-label="Reviewed contract">${paragraphs("Reviewed clause", 2)}</section>
|
|
552
|
+
</section>`,
|
|
553
|
+
measure: (c) => {
|
|
554
|
+
c.twoPane(c, ".split_screen", ".split_screen_pane:nth-child(1)", ".split_screen_pane:nth-child(2)", 320, 16);
|
|
555
|
+
c.expect(c.r(".split_screen").height >= c.H - 1, "split-screen-fill", "split screen is shorter than the viewport");
|
|
556
|
+
},
|
|
557
|
+
negative: { selector: ".split_screen", property: "display", behavior: "pane-reflow" },
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
slug: "list-detail",
|
|
561
|
+
claim: "`list-detail` places list and detail side by side when two 18rem panes fit and stacks them otherwise.",
|
|
562
|
+
html: `<section class="list_detail" aria-label="Customer records">
|
|
563
|
+
<nav class="list_detail_list" aria-label="Customers">${links(["Acme Co.", "Northwind", "Globex"])}</nav>
|
|
564
|
+
<section class="list_detail_detail" aria-label="Selected customer">${paragraphs("Timeline", 2)}</section>
|
|
565
|
+
</section>`,
|
|
566
|
+
harness: ".list_detail_list a { display: block; }",
|
|
567
|
+
measure: (c) => c.twoPane(c, ".list_detail", ".list_detail_list", ".list_detail_detail", 288, 16),
|
|
568
|
+
negative: { selector: ".list_detail", property: "grid-template-columns", behavior: "pane-reflow" },
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
slug: "supporting-pane",
|
|
572
|
+
claim: "`supporting-pane` keeps the summary beside the main task when two 20rem panes fit and stacks it below otherwise.",
|
|
573
|
+
html: `<section class="supporting_pane" aria-label="Invoice editor">
|
|
574
|
+
<main class="supporting_pane_main">${paragraphs("Line item", 2)}</main>
|
|
575
|
+
<aside class="supporting_pane_summary">Payment summary</aside>
|
|
576
|
+
</section>`,
|
|
577
|
+
measure: (c) => c.twoPane(c, ".supporting_pane", ".supporting_pane_main", ".supporting_pane_summary", 320, 16),
|
|
578
|
+
negative: { selector: ".supporting_pane", property: "grid-template-columns", behavior: "pane-reflow" },
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
slug: "feed",
|
|
582
|
+
claim: "`feed` stacks repeated items with one 1rem block gap.",
|
|
583
|
+
html: `<section class="feed" aria-label="Activity feed">
|
|
584
|
+
${items("article", "", ["Build completed", "Review requested", "Deployment approved", "Release tagged"])}
|
|
585
|
+
</section>`,
|
|
586
|
+
measure: (c) => c.blockRhythm(c, ".feed", 16),
|
|
587
|
+
negative: { selector: ".feed", property: "display", behavior: "block-rhythm" },
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
slug: "breadcrumb",
|
|
591
|
+
claim: "`breadcrumb` keeps a 0.5rem gap between trail items and wraps the trail instead of overflowing.",
|
|
592
|
+
html: `<nav class="breadcrumb" aria-label="Breadcrumb">
|
|
593
|
+
${links(["Workspace", "Projects", "Design operations", "Quarterly reviews", "Layout audits"])}
|
|
594
|
+
<span>Layout gallery</span>
|
|
595
|
+
</nav>`,
|
|
596
|
+
measure: (c) => c.wrapRow(c, ".breadcrumb", ".breadcrumb > *", 8),
|
|
597
|
+
negative: { selector: ".breadcrumb", property: "flex-wrap", behavior: "wrap-containment", allow: ["overflow"] },
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
slug: "pagination",
|
|
601
|
+
claim: "`pagination` centers its page controls in the row with a 0.5rem gap.",
|
|
602
|
+
html: `<nav class="pagination" aria-label="Search results pages">
|
|
603
|
+
${links(["Previous", "Page 3", "Page 4", "Page 5", "Next"])}
|
|
604
|
+
</nav>`,
|
|
605
|
+
measure: (c) => {
|
|
606
|
+
const nav = c.r(".pagination");
|
|
607
|
+
const rows = c.rowsOf(c.rs(".pagination > a"));
|
|
608
|
+
for (const row of rows) c.expect(c.near(row[0].left - nav.left, nav.right - row.at(-1).right, 1.5), "pagination-centered", "a row of page controls is not centered");
|
|
609
|
+
c.wrapRow(c, ".pagination", ".pagination > a", 8, false);
|
|
610
|
+
},
|
|
611
|
+
negative: { selector: ".pagination", property: "justify-content", behavior: "pagination-centered" },
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
slug: "badge-list",
|
|
615
|
+
claim: "`badge-list` starts each label at the item's inline start and ends each count at the item's inline end.",
|
|
616
|
+
html: `<section class="badge_list" aria-label="Repository counts">
|
|
617
|
+
<div class="badge_list_item"><span>Open issues</span><span>12</span></div>
|
|
618
|
+
<div class="badge_list_item"><span>Pull requests</span><span>4</span></div>
|
|
619
|
+
<div class="badge_list_item"><span>Reviews waiting</span><span>7</span></div>
|
|
620
|
+
</section>`,
|
|
621
|
+
measure: (c) => {
|
|
622
|
+
for (const item of c.q(".badge_list").children) {
|
|
623
|
+
const box = item.getBoundingClientRect();
|
|
624
|
+
const [label, count] = [...item.children].map((child) => child.getBoundingClientRect());
|
|
625
|
+
c.expect(c.near(label.left, box.left, 1) && c.near(count.right, box.right, 1), "badge-ends", "label and count do not sit at opposite ends of the item");
|
|
626
|
+
}
|
|
627
|
+
c.blockRhythm(c, ".badge_list", 8);
|
|
628
|
+
},
|
|
629
|
+
negative: { selector: ".badge_list_item", property: "justify-content", behavior: "badge-ends" },
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
slug: "step-nav",
|
|
633
|
+
claim: "`step-nav` stacks steps with one 0.75rem block gap.",
|
|
634
|
+
html: `<nav class="step_nav" aria-label="Checkout steps">
|
|
635
|
+
${links(["Shipping address", "Payment method", "Order review"])}
|
|
636
|
+
</nav>`,
|
|
637
|
+
measure: (c) => c.blockRhythm(c, ".step_nav", 12),
|
|
638
|
+
negative: { selector: ".step_nav", property: "gap", behavior: "block-rhythm" },
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
slug: "tab-strip",
|
|
642
|
+
claim: "`tab-strip` keeps peer tabs in a row with a 0.75rem gap and wraps them instead of overflowing.",
|
|
643
|
+
html: `<nav class="tab_strip" aria-label="Report views">
|
|
644
|
+
${links(["Summary", "Cohorts", "Exports", "Retention", "Funnels", "Segments", "Revenue", "Settings"])}
|
|
645
|
+
</nav>`,
|
|
646
|
+
measure: (c) => c.wrapRow(c, ".tab_strip", ".tab_strip > a", 12),
|
|
647
|
+
negative: { selector: ".tab_strip", property: "flex-wrap", behavior: "wrap-containment", allow: ["overflow"] },
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
slug: "reel",
|
|
651
|
+
claim: "`reel` keeps items in one row of columns at least 14rem wide and scrolls that row itself, so the document root never overflows inline.",
|
|
652
|
+
html: `<section class="reel" aria-label="Upcoming events" tabindex="0">
|
|
653
|
+
${items("article", "", ["Design critique", "Planning workshop", "Launch review", "Accessibility clinic", "Roadmap review", "Retrospective", "Content audit", "Usability study", "Release party", "Quarterly review"])}
|
|
654
|
+
</section>`,
|
|
655
|
+
measure: (c) => {
|
|
656
|
+
const reel = c.q(".reel");
|
|
657
|
+
const cells = c.rs(".reel > article");
|
|
658
|
+
c.expect(c.rowsOf(cells).length === 1, "reel-row", "items wrap onto more than one row");
|
|
659
|
+
c.expect(cells.every((cell) => cell.width >= 224 - 1), "reel-row", "an item is narrower than 14rem");
|
|
660
|
+
reel.scrollLeft = 100;
|
|
661
|
+
const moved = reel.scrollLeft > 0;
|
|
662
|
+
reel.scrollLeft = 0;
|
|
663
|
+
c.expect(moved && reel.scrollWidth > reel.clientWidth + 1, "reel-scroll-owner", "the reel does not scroll its overflowing row");
|
|
664
|
+
c.expect(c.root.scrollWidth <= c.root.clientWidth + 1, "reel-scroll-owner", `document root overflows inline by ${c.root.scrollWidth - c.root.clientWidth}px`);
|
|
665
|
+
},
|
|
666
|
+
negative: { selector: ".reel", property: "overflow-x", behavior: "reel-scroll-owner", allow: ["overflow"] },
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
slug: "imposter",
|
|
670
|
+
claim: "`imposter` covers exactly its anchor, not the page, and centers its content over the anchor without leaving document order.",
|
|
671
|
+
html: `<p>Content before the editor.</p>
|
|
672
|
+
<section class="imposter_anchor" aria-label="Draft editor">
|
|
673
|
+
${paragraphs("Draft body", 4)}
|
|
674
|
+
<section class="imposter" aria-label="Modal placement">
|
|
675
|
+
<div class="exhibit_overlay">Unsaved changes confirmation</div>
|
|
676
|
+
</section>
|
|
677
|
+
</section>
|
|
678
|
+
<p>Content after the editor.</p>`,
|
|
679
|
+
harness: ".exhibit_overlay { background: #fff; padding: 1rem; }",
|
|
680
|
+
measure: (c) => {
|
|
681
|
+
const anchor = c.r(".imposter_anchor");
|
|
682
|
+
const overlay = c.r(".imposter");
|
|
683
|
+
const content = c.r(".exhibit_overlay");
|
|
684
|
+
c.expect(c.near(overlay.top, anchor.top, 1) && c.near(overlay.left, anchor.left, 1) && c.near(overlay.width, anchor.width, 1) && c.near(overlay.height, anchor.height, 1), "imposter-cover", "overlay does not cover exactly its anchor");
|
|
685
|
+
c.expect(c.near(content.left - anchor.left, anchor.right - content.right, 1.5) && c.near(content.top - anchor.top, anchor.bottom - content.bottom, 1.5), "imposter-center", "overlay content is not centered over the anchor");
|
|
686
|
+
},
|
|
687
|
+
negative: { selector: ".imposter_anchor", property: "position", behavior: "imposter-cover", allow: ["imposter-center"] },
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
slug: "panel-layout",
|
|
691
|
+
claim: "`panel-layout` places main and utility panels side by side when two 22rem panels fit and stacks them otherwise.",
|
|
692
|
+
html: `<section class="panel_layout" aria-label="Console workspace">
|
|
693
|
+
<main class="panel_layout_main">${paragraphs("Query", 2)}</main>
|
|
694
|
+
<aside class="panel_layout_side">Execution history</aside>
|
|
695
|
+
</section>`,
|
|
696
|
+
measure: (c) => c.twoPane(c, ".panel_layout", ".panel_layout_main", ".panel_layout_side", 352, 16),
|
|
697
|
+
negative: { selector: ".panel_layout", property: "display", behavior: "pane-reflow" },
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
slug: "overlay-stack",
|
|
701
|
+
claim: "`overlay-stack` places every item in the same grid cell so they overlap from one origin.",
|
|
702
|
+
html: `<section class="overlay_stack" aria-label="Map with controls">
|
|
703
|
+
<figure class="overlay_stack_item"><img alt="Transit map" src="${squareImage}" width="240" height="240"></figure>
|
|
704
|
+
<form class="overlay_stack_item" aria-label="Route search"><label>Route <input type="search"></label></form>
|
|
705
|
+
</section>`,
|
|
706
|
+
measure: (c) => {
|
|
707
|
+
const [first, second] = c.rs(".overlay_stack_item");
|
|
708
|
+
c.expect(c.near(first.top, second.top, 1) && c.near(first.left, second.left, 1), "overlay-same-cell", "items do not share one grid cell");
|
|
709
|
+
},
|
|
710
|
+
negative: { selector: ".overlay_stack_item", property: "grid-area", behavior: "overlay-same-cell" },
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
slug: "wrap-row",
|
|
714
|
+
claim: "`wrap-row` keeps controls in rows with a 0.75rem gap and wraps them instead of overflowing.",
|
|
715
|
+
html: `<form class="wrap_row" aria-label="Filter tickets">
|
|
716
|
+
<label>Status <select><option>Open</option><option>Closed</option></select></label>
|
|
717
|
+
<label>Owner <select><option>Anyone</option><option>Me</option></select></label>
|
|
718
|
+
<label>Priority <select><option>Any</option><option>High</option></select></label>
|
|
719
|
+
<label>Label <select><option>Any</option><option>Bug</option></select></label>
|
|
720
|
+
<button type="button">Apply filters</button>
|
|
721
|
+
</form>`,
|
|
722
|
+
measure: (c) => c.wrapRow(c, ".wrap_row", ".wrap_row > *", 12),
|
|
723
|
+
negative: { selector: ".wrap_row", property: "flex-wrap", behavior: "wrap-containment", allow: ["overflow"] },
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
slug: "dense-grid",
|
|
727
|
+
claim: "`dense-grid` fills as many 8rem columns as fit, even when items run out.",
|
|
728
|
+
html: `<section class="dense_grid" aria-label="Keyboard shortcuts">
|
|
729
|
+
${items("kbd", "", ["Cmd K", "Cmd Shift P", "Esc", "Cmd S", "Cmd Z", "Cmd F", "Cmd B", "Cmd J", "Tab", "Enter", "Cmd W", "Cmd T"])}
|
|
730
|
+
</section>`,
|
|
731
|
+
measure: (c) => {
|
|
732
|
+
const root = c.r(".dense_grid");
|
|
733
|
+
const cells = c.rs(".dense_grid > kbd");
|
|
734
|
+
const expected = Math.max(1, Math.floor((root.width + 8) / (128 + 8)));
|
|
735
|
+
const firstRow = cells.filter((cell) => Math.abs(cell.top - cells[0].top) < 2).length;
|
|
736
|
+
c.expect(firstRow === Math.min(expected, cells.length), "track-count", `${firstRow} items share the first row; ${expected} tracks fit`);
|
|
737
|
+
c.expect(cells.every((cell) => cell.width >= 128 - 1), "track-count", "an item is narrower than 8rem");
|
|
738
|
+
},
|
|
739
|
+
negative: { selector: ".dense_grid", property: "display", behavior: "track-count" },
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
slug: "masonry-approx",
|
|
743
|
+
claim: "`masonry-approx` flows items into as many 14rem columns as fit and returns to one column when narrow.",
|
|
744
|
+
html: `<section class="masonry_approx" aria-label="Research notes">
|
|
745
|
+
${items("article", "", Array.from({ length: 12 }, (_, index) => sentence("Research", index)))}
|
|
746
|
+
</section>`,
|
|
747
|
+
measure: (c) => c.multiColumn(c, ".masonry_approx", ".masonry_approx > article", 224, 16),
|
|
748
|
+
negative: { selector: ".masonry_approx", property: "column-width", behavior: "column-flow" },
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
slug: "main-with-rail",
|
|
752
|
+
claim: "`main-with-rail` keeps the rail beside main content when two 18rem panes fit and stacks it below otherwise.",
|
|
753
|
+
html: `<section class="main_with_rail" aria-label="Documentation page">
|
|
754
|
+
<main class="main_with_rail_main">${paragraphs("Reference", 2)}</main>
|
|
755
|
+
<aside class="main_with_rail_side">On this page links</aside>
|
|
756
|
+
</section>`,
|
|
757
|
+
measure: (c) => c.twoPane(c, ".main_with_rail", ".main_with_rail_main", ".main_with_rail_side", 288, 16),
|
|
758
|
+
negative: { selector: ".main_with_rail", property: "grid-template-columns", behavior: "pane-reflow" },
|
|
759
|
+
},
|
|
760
|
+
];
|
|
761
|
+
|
|
762
|
+
export const exhibitSlugs = Object.freeze(exhibits.map(({ slug }) => slug));
|
|
763
|
+
|
|
764
|
+
// Doc claims the Conservator does not measure, with the reason. Reported with every run.
|
|
765
|
+
export const notMeasured = Object.freeze([
|
|
766
|
+
{ pattern: "frame", scope: "object-fit: cover", reason: "Cropping is a paint result; element geometry cannot tell cover from fill once the media box equals the slot." },
|
|
767
|
+
{ pattern: "all patterns", scope: "Properties That Break The Layout If Removed", reason: "Each doc lists every core declaration as breaking; a negative control removes one declaration per pattern, so the remaining declarations are not individually proven." },
|
|
768
|
+
]);
|