stitch2 0.8.0 → 0.9.1

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 CHANGED
@@ -40,7 +40,8 @@ file that already exists. From there the agent spends its effort on the design.
40
40
  Pick mode (`P`) highlights any element of a screen and copies a reference with its source line, such as
41
41
  `home mobile v1 (design/screens/home/mobile-v1.html:36) <gg-button> “Start Push”`; Shift-click collects
42
42
  several to copy at once or add to the version's note.
43
- - **Checks** (`stitch2 check`): content past the frame edge, labels that wrap, content under fixed bars, fixed bars that overlap each other, tap
43
+ - **Checks** (`stitch2 check`): content past the frame edge or out of its card, labels that wrap, text running into
44
+ other text, labels cut short with an ellipsis, content under fixed bars, fixed bars that overlap each other, tap
44
45
  targets (WCAG 2.2), text contrast in every theme, text that is not one of DESIGN.md's type levels, capitals without tracking,
45
46
  large numbers in monospace, components built by hand, components that differ between screens, and colours
46
47
  written as values instead of DESIGN.md tokens.
@@ -164,6 +165,24 @@ put a page into the state the design shows (open a sheet, start a workout). The
164
165
  an error on that version and the run goes on. When it opens a modal, wait for something inside it (a marked
165
166
  component): dialog roots are often zero-sized wrappers, which Playwright reports as hidden.
166
167
 
168
+ ### In your own tests: every language, every width
169
+
170
+ The layout rules are a plain function you can run on any page, so an app's own Playwright tests can check what
171
+ mock-ups never show: long translations, right-to-left layouts, narrow phones.
172
+
173
+ ```ts
174
+ import { audit } from 'stitch2/audit'
175
+
176
+ for (const locale of ['de', 'ru', 'ar']) {
177
+ // …open the page in that language…
178
+ const issues = await page.evaluate(audit, { comfortable: 32 })
179
+ const errors = issues.filter((i) => i.severity === 'error') // overflow, text-overlap, label-wraps, …
180
+ }
181
+ ```
182
+
183
+ Warnings worth reading for translations: `truncated` (a label cut short with an ellipsis), `text-crowded` (two
184
+ controls' labels nearly touching) and `clipped`.
185
+
167
186
  ## Commands
168
187
 
169
188
  | Command | What it does |
package/dist/audit.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The layout audit, shared by check (the design screens) and compare (the live app). Runs inside the page.
2
+ * The layout audit, shared by check (the design screens) and compare (the live app), and exported as
3
+ * stitch2/audit for projects' own tests. Runs inside the page.
3
4
  */
4
5
  import type { Issue } from './typecheck.ts';
5
6
  /** Layout errors that mean the same bug wherever they appear; compare reports these for the app too. */
package/dist/audit.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Layout errors that mean the same bug wherever they appear; compare reports these for the app too. */
2
- export const LAYOUT_ERRORS = ['overflow', 'fixed-overlap', 'control-overlap'];
2
+ export const LAYOUT_ERRORS = ['overflow', 'fixed-overlap', 'control-overlap', 'text-overlap'];
3
3
  /** Runs inside the page. Plain JS on purpose: Playwright sends its source to the browser. */
4
4
  export async function audit(opts) {
5
5
  const W = document.documentElement.clientWidth;
@@ -101,8 +101,15 @@ export async function audit(opts) {
101
101
  const r = el.getBoundingClientRect();
102
102
  return (p === 'fixed' || p === 'sticky') && r.height <= 240 && r.width >= W * 0.6;
103
103
  });
104
- const outer = bars.filter((b) => !bars.some((o) => o !== b && o.contains(b)));
104
+ // Behind an open modal the page is inert and covered, so only the modal's own bars count.
105
+ // An open modal: the element may be 0px itself when everything in it is fixed (Headless UI's Dialog).
106
+ const modal = [...document.querySelectorAll('[aria-modal="true"]')].find((m) => {
107
+ const cs = getComputedStyle(m);
108
+ return cs.display !== 'none' && cs.visibility !== 'hidden' && [m, ...m.querySelectorAll('*')].some(shown);
109
+ });
110
+ const outer = bars.filter((b) => !bars.some((o) => o !== b && o.contains(b)) && (!modal || modal.contains(b)));
105
111
  const content = all.filter((el) => !outer.some((b) => b.contains(el)) &&
112
+ (!modal || modal.contains(el)) &&
106
113
  (ownText(el) || el.matches('button, input, select, textarea, img, svg, canvas, video')) &&
107
114
  opacity(el) > 0.5);
108
115
  const covered = async (edge) => {
@@ -121,7 +128,7 @@ export async function audit(opts) {
121
128
  const overlapY = Math.min(r.bottom, b.bottom) - Math.max(r.top, b.top);
122
129
  const overlapX = Math.min(r.right, b.right) - Math.max(r.left, b.left);
123
130
  if (overlapY > 4 && overlapX > 4)
124
- push('error', `under-${edge}-bar`, el, `Hidden under the fixed ${edge} bar when scrolled to the ${edge} (add padding)`);
131
+ push('error', `under-${edge}-bar`, el, `Hidden under the fixed ${edge} bar (“${text(bar).slice(0, 30) || bar.tagName.toLowerCase()}”) when scrolled to the ${edge} (add padding)`);
125
132
  }
126
133
  }
127
134
  };
@@ -142,6 +149,17 @@ export async function audit(opts) {
142
149
  if (hit)
143
150
  push('error', 'fixed-overlap', b, `Overlaps another fixed bar (“${text(a).slice(0, 30)}”): move one clear of the other`);
144
151
  }
152
+ // 3c. A fixed bar shows all of itself: a row of it pushed past the bottom (or top) of the screen is unreachable.
153
+ for (const bar of fixedBars)
154
+ for (const e of [bar, ...bar.querySelectorAll('*')]) {
155
+ if (!shown(e) || inSvg(e) || !(ownText(e) || e.matches('svg, img, button, a, input')))
156
+ continue;
157
+ const r = e.getBoundingClientRect();
158
+ if (r.bottom > window.innerHeight + 1 || r.top < -1) {
159
+ push('error', 'overflow', e, 'Part of a fixed bar is cut off by the screen edge: it lays out on more rows than the bar shows');
160
+ break;
161
+ }
162
+ }
145
163
  await covered('bottom');
146
164
  await covered('top');
147
165
  window.scrollTo(0, 0);
@@ -185,8 +203,7 @@ export async function audit(opts) {
185
203
  return e;
186
204
  return null;
187
205
  };
188
- // Behind an open modal the page is inert and the modal covers it, so only the modal's own controls count.
189
- const modal = [...document.querySelectorAll('[aria-modal="true"]')].find(shown);
206
+ // Behind an open modal (found above) only the modal's own controls count.
190
207
  const solid = interactive.filter((el) => opacity(el) > 0.05 && (!modal || modal.contains(el)));
191
208
  for (const [i, a] of solid.entries())
192
209
  for (const b of solid.slice(i + 1)) {
@@ -197,6 +214,95 @@ export async function audit(opts) {
197
214
  if (Math.min(r.right, q.right) - Math.max(r.left, q.left) > 2 && Math.min(r.bottom, q.bottom) - Math.max(r.top, q.top) > 2)
198
215
  push('error', 'control-overlap', b, `Overlaps another control (“${text(a).slice(0, 30) || a.getAttribute('aria-label') || a.tagName.toLowerCase()}”): give them room`);
199
216
  }
217
+ // 4c. Text never collides with other text: labels of two controls overlapping (a long translation spilling into
218
+ // its neighbour in a tab bar) are errors; labels of two controls closer than 4px are warnings.
219
+ const owner = (el) => el.closest('button, a, label, [role=tab], [role=button], [role=switch], [role=checkbox]') ?? el;
220
+ const runs = [];
221
+ {
222
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
223
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
224
+ const el = n.parentElement;
225
+ if (!el || !n.textContent.trim() || !shown(el) || inSvg(el) || opacity(el) < 0.5)
226
+ continue;
227
+ if (modal && !modal.contains(el))
228
+ continue;
229
+ const range = document.createRange();
230
+ range.selectNodeContents(n);
231
+ // Only what shows: text cut off by a clipping parent (an ellipsis) still measures full width.
232
+ const clips = [];
233
+ for (let p = el; p && p !== document.body; p = p.parentElement)
234
+ if (getComputedStyle(p).overflowX !== 'visible')
235
+ clips.push(p.getBoundingClientRect());
236
+ for (const raw of range.getClientRects()) {
237
+ let [l, t, r, b] = [raw.left, raw.top, raw.right, raw.bottom];
238
+ for (const c of clips)
239
+ [l, t, r, b] = [Math.max(l, c.left), Math.max(t, c.top), Math.min(r, c.right), Math.min(b, c.bottom)];
240
+ if (r - l > 1 && b - t > 1)
241
+ runs.push({ el, own: owner(el), r: new DOMRect(l, t, r - l, b - t) });
242
+ }
243
+ }
244
+ }
245
+ const collided = new Set();
246
+ for (const [i, a] of runs.entries())
247
+ for (const b of runs.slice(i + 1)) {
248
+ if (a.own === b.own || a.own.contains(b.own) || b.own.contains(a.own) || layer(a.el) !== layer(b.el))
249
+ continue;
250
+ if (collided.has(a.own) || collided.has(b.own))
251
+ continue;
252
+ const x = Math.min(a.r.right, b.r.right) - Math.max(a.r.left, b.r.left);
253
+ const y = Math.min(a.r.bottom, b.r.bottom) - Math.max(a.r.top, b.r.top);
254
+ // Same row: stacked lines of a tight heading touch at their glyph boxes and are not a collision.
255
+ const sameRow = y > Math.min(a.r.height, b.r.height) * 0.5;
256
+ if (x > 1 && sameRow) {
257
+ collided.add(b.own);
258
+ push('error', 'text-overlap', b.el, 'Text runs into “' + text(a.el).slice(0, 30) + '”: shorten it or give it room');
259
+ }
260
+ else if (sameRow && x > -4 && a.own !== a.el && b.own !== b.el) {
261
+ collided.add(b.own);
262
+ push('warning', 'text-crowded', b.el, 'Touches “' + text(a.el).slice(0, 30) + '” with no gap: a longer word will collide');
263
+ }
264
+ }
265
+ // 4c'. Labels cut short with an ellipsis: fine for a long exercise name in a row, a translation problem in a tab,
266
+ // a button or a short label. Reported as warnings, for the eye to judge.
267
+ for (const el of all) {
268
+ if (!ownText(el) || getComputedStyle(el).textOverflow !== 'ellipsis')
269
+ continue;
270
+ const h = el;
271
+ if (h.scrollWidth > h.clientWidth + 1)
272
+ push('warning', 'truncated', el, 'Cut short with an ellipsis (' + h.scrollWidth + 'px of text in ' + h.clientWidth + 'px)');
273
+ }
274
+ // 4d. Nothing sticks out sideways of the card or bar that holds it (a button pushed past its card's edge by a
275
+ // long label), even while still on screen. Clipping and scrolling containers end the search: what they hide
276
+ // is deliberate.
277
+ const paints = (el) => {
278
+ const cs = getComputedStyle(el);
279
+ return !/rgba\(0, 0, 0, 0\)|transparent/.test(cs.backgroundColor) || Number.parseFloat(cs.borderLeftWidth) > 0;
280
+ };
281
+ const outside = new Set();
282
+ for (const el of all) {
283
+ if (overflowing.has(el) || outside.has(el.parentElement))
284
+ continue;
285
+ if (['absolute', 'fixed'].includes(getComputedStyle(el).position))
286
+ continue;
287
+ let box = null;
288
+ for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
289
+ if (getComputedStyle(p).overflowX !== 'visible')
290
+ break;
291
+ if (paints(p)) {
292
+ box = p;
293
+ break;
294
+ }
295
+ }
296
+ if (!box)
297
+ continue;
298
+ const r = el.getBoundingClientRect();
299
+ const b = box.getBoundingClientRect();
300
+ const out = Math.max(r.right - b.right, b.left - r.left);
301
+ if (out > 2) {
302
+ outside.add(el);
303
+ push('error', 'overflow', el, 'Sticks out of its container by ' + Math.round(out) + 'px');
304
+ }
305
+ }
200
306
  // 5. Text contrast (WCAG AA): 4.5:1, or 3:1 for large text. Skips text over images and dimmed layers.
201
307
  const rgba = (c) => {
202
308
  const m = /rgba?\(([^)]+)\)/.exec(c);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "A design canvas, layout and type checks, component consistency and screen versioning for agent-built UI, on Google's DESIGN.md format.",
5
5
  "license": "MIT",
6
6
  "author": "ifokeev",
@@ -17,6 +17,10 @@
17
17
  "types": "./dist/elements.d.ts",
18
18
  "default": "./dist/elements.js"
19
19
  },
20
+ "./audit": {
21
+ "types": "./dist/audit.d.ts",
22
+ "default": "./dist/audit.js"
23
+ },
20
24
  "./package.json": "./package.json"
21
25
  },
22
26
  "files": [
@@ -47,7 +47,9 @@ References, read before the first screen:
47
47
  5. **Check** `stitch2 check <path> --shots --strict`. Fix every error (overflow, wrapped labels, content under
48
48
  fixed bars, fixed bars overlapping each other, tap targets, contrast, cramped text) and every warning about type (`type-*`), hand-built
49
49
  components (`reuse`), components that differ from the catalog (`consistency`) and colour values (`color`).
50
- When DESIGN.md has a light and a dark theme, every screen is checked in both: an issue marked with a theme
50
+ Text running into other text (`text-overlap`) or out of its card (`overflow`) is an error; a short label cut
51
+ short with an ellipsis (`truncated`) is a warning to fix with room, not a smaller size. When DESIGN.md has a
52
+ light and a dark theme, every screen is checked in both: an issue marked with a theme
51
53
  ("light theme: Low contrast…") is fixed in DESIGN.md's colours for that theme when it shows on many
52
54
  screens, or in the screen when it is one element. Look at both renders (`--light.png`).
53
55
  6. **Look and critique.** View the render in `<design>/renders/` (crop the first screenful). Score it with the