stitch2 0.24.3 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/dist/boxes.d.ts +52 -0
- package/dist/boxes.js +195 -0
- package/dist/canvas.html +33 -1
- package/dist/cli.js +2 -0
- package/dist/compare.js +24 -74
- package/dist/geometry.d.ts +1 -0
- package/dist/geometry.js +55 -0
- package/dist/live.d.ts +55 -0
- package/dist/live.js +147 -0
- package/dist/look.d.ts +1 -0
- package/dist/look.js +55 -0
- package/dist/looks.d.ts +44 -0
- package/dist/looks.js +147 -0
- package/package.json +1 -1
- package/skills/stitch2/SKILL.md +2 -0
- package/skills/stitch2-app-sync/SKILL.md +14 -1
package/README.md
CHANGED
|
@@ -191,6 +191,27 @@ put a page into the state the design shows (open a sheet, start a workout). The
|
|
|
191
191
|
an error on that version and the run goes on. When it opens a modal, wait for something inside it (a marked
|
|
192
192
|
component): dialog roots are often zero-sized wrappers, which Playwright reports as hidden.
|
|
193
193
|
|
|
194
|
+
### Does it look the same, and sit in the same place?
|
|
195
|
+
|
|
196
|
+
Compare says which components are there. Two more commands take the same routes and ask what that cannot see: the
|
|
197
|
+
words inside them, and where they land.
|
|
198
|
+
|
|
199
|
+
- `stitch2 look [screen…]` pairs the texts of a screen and its app page by their words and reports the ones drawn
|
|
200
|
+
differently: font, size, weight, letter spacing, line height, colour, case, alignment, decoration. The same
|
|
201
|
+
difference on many texts is one line with a count and the app's tag and classes, so it can be found in the source.
|
|
202
|
+
Text on one side only (data differs between a mock-up and the app) is counted, not reported.
|
|
203
|
+
- `stitch2 geometry [screen…]` pairs the boxes of shared components and texts the same way and reports a box that
|
|
204
|
+
has another size or left edge, two neighbours with another space between them, and a pair arranged differently
|
|
205
|
+
(a title beside its icon in the design, under it in the app). Vertical position alone is not compared, because
|
|
206
|
+
data of another length moves everything below it. A box in a row that holds text only one page has is not blamed
|
|
207
|
+
for being pushed sideways, and boxes of a list that shows the same items in another order are left out.
|
|
208
|
+
|
|
209
|
+
Both run each version at the width it is drawn at, at both ends of its variant's width range and at every
|
|
210
|
+
breakpoint inside it (the widths `check` renders): a title that fits at 1440px and wraps under its icon at 1024px
|
|
211
|
+
shows up. `--width 360,1280` picks the widths, `--drawn` uses only the drawn width (a quick pass over everything),
|
|
212
|
+
and `--strict` exits with an error when anything differs. Results are also written to `look.json` and
|
|
213
|
+
`geometry.json` in the design folder.
|
|
214
|
+
|
|
194
215
|
### In your own tests: every language, every width
|
|
195
216
|
|
|
196
217
|
The layout rules are a plain function you can run on any page, so an app's own Playwright tests can check what
|
|
@@ -270,6 +291,8 @@ layout first, then a shorter natural word, never a smaller type size.
|
|
|
270
291
|
| `stitch2 approve <path or filter…>` | Approve variants (the user's decision); every earlier version is archived |
|
|
271
292
|
| `stitch2 tokens` | Tokens from DESIGN.md |
|
|
272
293
|
| `stitch2 compare [screen…]` | The live app against the approved screens (`app` in the config) |
|
|
294
|
+
| `stitch2 look [screen…] [--width N,N \| --drawn]` | Text in the live app against the approved screens: font, size, weight, colour |
|
|
295
|
+
| `stitch2 geometry [screen…] [--width N,N \| --drawn]` | Where things sit in the live app against the approved screens, at every width |
|
|
273
296
|
| `stitch2 elements <entry>` | Bundle your components (`stitch2/elements`) for the screens |
|
|
274
297
|
| `stitch2 lint` | Google's DESIGN.md linter |
|
|
275
298
|
| `stitch2 localize [filter]` | Copies a screen's remote images into its `assets/` folder |
|
package/dist/boxes.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where things sit on a page, for stitch2 geometry: the box of every shared component (by its data-<prefix> marker)
|
|
3
|
+
* and of every piece of text, and the comparison of two pages' boxes. Boxes are paired by what they hold, not by
|
|
4
|
+
* where they are in the markup, so a wrapper the app adds does not matter and data that differs is skipped.
|
|
5
|
+
*/
|
|
6
|
+
/** A piece of a page that can be found again on another page with the same words in it. */
|
|
7
|
+
export interface Box {
|
|
8
|
+
/** What it is, without its place: a component's marker and its first words, or a text's own words. */
|
|
9
|
+
sig: string;
|
|
10
|
+
/** "component" or "text". */
|
|
11
|
+
kind: 'component' | 'text';
|
|
12
|
+
/** For the report: “Save” or button “Save”. */
|
|
13
|
+
label: string;
|
|
14
|
+
/** A figure or a symbol on its own (3, 93.1%, ←): data, which the other page may hold in another place or not at all. */
|
|
15
|
+
data?: boolean;
|
|
16
|
+
/** Position on the page (scroll included) and size, in px. */
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
w: number;
|
|
20
|
+
h: number;
|
|
21
|
+
}
|
|
22
|
+
/** Runs in the page: the box of every visible component and every element's own text, in document order. Self-contained. */
|
|
23
|
+
export declare function boxes(prefix: string): Box[];
|
|
24
|
+
export interface Mismatch {
|
|
25
|
+
/** What differs: the size of a box, its left edge, the space to its neighbour, or how the two are arranged. */
|
|
26
|
+
type: 'size' | 'position' | 'gap' | 'arrangement';
|
|
27
|
+
message: string;
|
|
28
|
+
}
|
|
29
|
+
export interface Compared {
|
|
30
|
+
mismatches: Mismatch[];
|
|
31
|
+
/** How many boxes were found on both pages. */
|
|
32
|
+
matched: number;
|
|
33
|
+
onlyDesign: number;
|
|
34
|
+
onlyApp: number;
|
|
35
|
+
}
|
|
36
|
+
/** How far apart two measurements may be before they differ: sub-pixel layout and rounding. */
|
|
37
|
+
/** A text's width also takes in the space before or after it in its text node, which differs with how the markup is written. */
|
|
38
|
+
export declare const TOLERANCE: {
|
|
39
|
+
place: number;
|
|
40
|
+
size: number;
|
|
41
|
+
textWidth: number;
|
|
42
|
+
height: number;
|
|
43
|
+
gap: number;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Pairs the boxes of two pages (the nth box with a given content on one is the nth on the other) and lists where
|
|
47
|
+
* the pairs differ: size, left edge, and the space between neighbours. Vertical position alone is not compared,
|
|
48
|
+
* because data of another length moves everything below it; the space between two neighbours is.
|
|
49
|
+
* Across a row, text that only one page has (a name, a balance) pushes its row-mates sideways for good reason, so
|
|
50
|
+
* left edges and sideways gaps are compared only where no such text shares the row.
|
|
51
|
+
*/
|
|
52
|
+
export declare function compareBoxes(design: Box[], app: Box[]): Compared;
|
package/dist/boxes.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where things sit on a page, for stitch2 geometry: the box of every shared component (by its data-<prefix> marker)
|
|
3
|
+
* and of every piece of text, and the comparison of two pages' boxes. Boxes are paired by what they hold, not by
|
|
4
|
+
* where they are in the markup, so a wrapper the app adds does not matter and data that differs is skipped.
|
|
5
|
+
*/
|
|
6
|
+
/** Runs in the page: the box of every visible component and every element's own text, in document order. Self-contained. */
|
|
7
|
+
export function boxes(prefix) {
|
|
8
|
+
const marker = `data-${prefix}`;
|
|
9
|
+
const out = [];
|
|
10
|
+
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
|
11
|
+
// Text kept for screen readers sits in a box a pixel wide with its overflow cut away: it is not drawn.
|
|
12
|
+
const clipped = (el) => {
|
|
13
|
+
for (let e = el; e && e !== document.body; e = e.parentElement) {
|
|
14
|
+
if (getComputedStyle(e).overflow === 'visible')
|
|
15
|
+
continue;
|
|
16
|
+
const r = e.getBoundingClientRect();
|
|
17
|
+
if (r.width < 2 || r.height < 2)
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
};
|
|
22
|
+
for (const el of document.body.querySelectorAll('*')) {
|
|
23
|
+
if (el.closest('script, style, noscript, template, option, svg'))
|
|
24
|
+
continue;
|
|
25
|
+
if (!el.checkVisibility({ opacityProperty: true, visibilityProperty: true }) || clipped(el))
|
|
26
|
+
continue;
|
|
27
|
+
const kind = el.getAttribute(marker);
|
|
28
|
+
if (kind) {
|
|
29
|
+
const r = el.getBoundingClientRect();
|
|
30
|
+
if (r.width >= 1 && r.height >= 1) {
|
|
31
|
+
const words = flat(el.textContent ?? '').slice(0, 24);
|
|
32
|
+
out.push({
|
|
33
|
+
sig: `${kind}|${words}`,
|
|
34
|
+
kind: 'component',
|
|
35
|
+
label: words ? `${kind} “${words}”` : kind,
|
|
36
|
+
x: r.left + window.scrollX,
|
|
37
|
+
y: r.top + window.scrollY,
|
|
38
|
+
w: r.width,
|
|
39
|
+
h: r.height,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
let words = '';
|
|
44
|
+
let left = Number.POSITIVE_INFINITY;
|
|
45
|
+
let top = Number.POSITIVE_INFINITY;
|
|
46
|
+
let right = Number.NEGATIVE_INFINITY;
|
|
47
|
+
let bottom = Number.NEGATIVE_INFINITY;
|
|
48
|
+
for (const n of el.childNodes) {
|
|
49
|
+
if (n.nodeType !== Node.TEXT_NODE || !flat(n.textContent ?? ''))
|
|
50
|
+
continue;
|
|
51
|
+
words += `${n.textContent ?? ''} `;
|
|
52
|
+
const range = document.createRange();
|
|
53
|
+
range.selectNodeContents(n);
|
|
54
|
+
const r = range.getBoundingClientRect();
|
|
55
|
+
if (r.width < 1 || r.height < 1)
|
|
56
|
+
continue;
|
|
57
|
+
left = Math.min(left, r.left);
|
|
58
|
+
top = Math.min(top, r.top);
|
|
59
|
+
right = Math.max(right, r.right);
|
|
60
|
+
bottom = Math.max(bottom, r.bottom);
|
|
61
|
+
}
|
|
62
|
+
words = flat(words);
|
|
63
|
+
if (!words || !Number.isFinite(left))
|
|
64
|
+
continue;
|
|
65
|
+
out.push({
|
|
66
|
+
sig: words.slice(0, 40),
|
|
67
|
+
kind: 'text',
|
|
68
|
+
data: !/\p{L}/u.test(words),
|
|
69
|
+
label: `“${words.slice(0, 28)}”`,
|
|
70
|
+
x: left + window.scrollX,
|
|
71
|
+
y: top + window.scrollY,
|
|
72
|
+
w: right - left,
|
|
73
|
+
h: bottom - top,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
// A component with no words of its own (a picture, an icon) is known by the text that follows it: the title under it.
|
|
77
|
+
for (const [i, b] of out.entries()) {
|
|
78
|
+
if (b.kind !== 'component' || !b.sig.endsWith('|'))
|
|
79
|
+
continue;
|
|
80
|
+
const after = out.slice(i + 1).find((o) => o.kind === 'text');
|
|
81
|
+
if (!after)
|
|
82
|
+
continue;
|
|
83
|
+
b.sig = `${b.sig}→${after.sig.slice(0, 24)}`;
|
|
84
|
+
b.label = `${b.label} before ${after.label}`;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** How far apart two measurements may be before they differ: sub-pixel layout and rounding. */
|
|
89
|
+
/** A text's width also takes in the space before or after it in its text node, which differs with how the markup is written. */
|
|
90
|
+
export const TOLERANCE = { place: 2, size: 2, textWidth: 4, height: 3, gap: 3 };
|
|
91
|
+
const px = (v) => Math.round(v);
|
|
92
|
+
/** How a box sits to the one before it: inside it (a component and what it holds), beside it or below it. */
|
|
93
|
+
function relation(a, b) {
|
|
94
|
+
const inside = a.kind === 'component' && b.x >= a.x - 1 && b.y >= a.y - 1 && b.x + b.w <= a.x + a.w + 1 && b.y + b.h <= a.y + a.h + 1;
|
|
95
|
+
if (inside)
|
|
96
|
+
return { how: 'inside', dx: b.x - a.x, dy: b.y - a.y };
|
|
97
|
+
if (b.y >= a.y + a.h - 1)
|
|
98
|
+
return { how: 'below', dx: 0, dy: b.y - (a.y + a.h) };
|
|
99
|
+
if (b.x >= a.x + a.w - 1)
|
|
100
|
+
return { how: 'beside', dx: b.x - (a.x + a.w), dy: 0 };
|
|
101
|
+
return { how: 'overlap', dx: 0, dy: 0 };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Of the indexes a list of pairs points to, the ones that keep their order: the longest run that only goes up. A
|
|
105
|
+
* list that shows the same items in another order (recent runs, a feed) pairs many boxes with ones in other places;
|
|
106
|
+
* only the pairs in the same order are comparable, the rest are data.
|
|
107
|
+
*/
|
|
108
|
+
function keepOrdered(pair) {
|
|
109
|
+
const at = pair.flatMap((p, i) => (p === undefined ? [] : [i]));
|
|
110
|
+
const best = at.map(() => 1);
|
|
111
|
+
const from = at.map(() => -1);
|
|
112
|
+
let end = -1;
|
|
113
|
+
for (const [x, i] of at.entries()) {
|
|
114
|
+
for (let y = 0; y < x; y++)
|
|
115
|
+
if (pair[at[y]] < pair[i] && best[y] + 1 > best[x]) {
|
|
116
|
+
best[x] = best[y] + 1;
|
|
117
|
+
from[x] = y;
|
|
118
|
+
}
|
|
119
|
+
if (end < 0 || best[x] > best[end])
|
|
120
|
+
end = x;
|
|
121
|
+
}
|
|
122
|
+
const keep = new Set();
|
|
123
|
+
for (let x = end; x >= 0; x = from[x])
|
|
124
|
+
keep.add(at[x]);
|
|
125
|
+
return pair.map((p, i) => (keep.has(i) ? p : undefined));
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Pairs the boxes of two pages (the nth box with a given content on one is the nth on the other) and lists where
|
|
129
|
+
* the pairs differ: size, left edge, and the space between neighbours. Vertical position alone is not compared,
|
|
130
|
+
* because data of another length moves everything below it; the space between two neighbours is.
|
|
131
|
+
* Across a row, text that only one page has (a name, a balance) pushes its row-mates sideways for good reason, so
|
|
132
|
+
* left edges and sideways gaps are compared only where no such text shares the row.
|
|
133
|
+
*/
|
|
134
|
+
export function compareBoxes(design, app) {
|
|
135
|
+
const key = (list) => {
|
|
136
|
+
const seen = new Map();
|
|
137
|
+
return list.map((b) => {
|
|
138
|
+
const i = seen.get(b.sig) ?? 0;
|
|
139
|
+
seen.set(b.sig, i + 1);
|
|
140
|
+
return `${b.sig}#${i}`;
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
const [dk, ak] = [key(design), key(app)];
|
|
144
|
+
const inApp = new Map(ak.map((k, i) => [k, i]));
|
|
145
|
+
const pair = keepOrdered(dk.map((k, i) => (design[i].data ? undefined : inApp.get(k))));
|
|
146
|
+
const paired = new Set(pair);
|
|
147
|
+
const loose = (list, keep) => list.filter((b, i) => b.kind === 'text' && !keep(i));
|
|
148
|
+
const looseDesign = loose(design, (i) => pair[i] !== undefined);
|
|
149
|
+
const looseApp = loose(app, (i) => paired.has(i));
|
|
150
|
+
/** Whether text only one page has shares the row of this box, outside it: it can move the box sideways. */
|
|
151
|
+
const crowded = (list, b) => list.some((u) => u.y < b.y + b.h && u.y + u.h > b.y && !(u.x >= b.x - 1 && u.x + u.w <= b.x + b.w + 1));
|
|
152
|
+
const inside = (list, b) => list.some((u) => u.x >= b.x - 1 && u.y >= b.y - 1 && u.x + u.w <= b.x + b.w + 1 && u.y + u.h <= b.y + b.h + 1);
|
|
153
|
+
const mismatches = [];
|
|
154
|
+
let matched = 0;
|
|
155
|
+
for (const [i, d] of design.entries()) {
|
|
156
|
+
const j = pair[i];
|
|
157
|
+
if (j === undefined)
|
|
158
|
+
continue;
|
|
159
|
+
matched++;
|
|
160
|
+
const a = app[j];
|
|
161
|
+
// What a component holds sets its height: where one page has text the other lacks, only its width is compared.
|
|
162
|
+
const holds = d.kind === 'component' && (inside(looseDesign, d) || inside(looseApp, a));
|
|
163
|
+
const wide = d.kind === 'text' ? TOLERANCE.textWidth : TOLERANCE.size;
|
|
164
|
+
if (Math.abs(d.w - a.w) > wide || (!holds && Math.abs(d.h - a.h) > TOLERANCE.height))
|
|
165
|
+
mismatches.push({ type: 'size', message: `${d.label} is ${px(a.w)}×${px(a.h)} in the app, ${px(d.w)}×${px(d.h)} in the design` });
|
|
166
|
+
if (Math.abs(d.x - a.x) > TOLERANCE.place && !crowded(looseDesign, d) && !crowded(looseApp, a))
|
|
167
|
+
mismatches.push({ type: 'position', message: `${d.label} starts ${px(a.x)}px from the left in the app, ${px(d.x)}px in the design` });
|
|
168
|
+
const next = design[i + 1];
|
|
169
|
+
const k = pair[i + 1];
|
|
170
|
+
if (!next || k === undefined || k !== j + 1)
|
|
171
|
+
continue;
|
|
172
|
+
const [rd, ra] = [relation(d, next), relation(a, app[k])];
|
|
173
|
+
if (rd.how === 'overlap' || ra.how === 'overlap')
|
|
174
|
+
continue;
|
|
175
|
+
if (rd.how !== ra.how) {
|
|
176
|
+
mismatches.push({ type: 'arrangement', message: `${next.label} sits ${rd.how} ${d.label} in the design, ${ra.how} it in the app` });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (rd.how === 'inside') {
|
|
180
|
+
const sideways = !crowded(looseDesign, next) && !crowded(looseApp, app[k]) && Math.abs(rd.dx - ra.dx) > TOLERANCE.gap;
|
|
181
|
+
if (sideways || Math.abs(rd.dy - ra.dy) > TOLERANCE.gap)
|
|
182
|
+
mismatches.push({
|
|
183
|
+
type: 'gap',
|
|
184
|
+
message: `${next.label} sits ${px(ra.dx)}px across and ${px(ra.dy)}px down inside ${d.label} in the app, ${px(rd.dx)}px and ${px(rd.dy)}px in the design`,
|
|
185
|
+
});
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const [gd, ga] = rd.how === 'beside' ? [rd.dx, ra.dx] : [rd.dy, ra.dy];
|
|
189
|
+
if (rd.how === 'beside' && (crowded(looseDesign, d) || crowded(looseApp, a)))
|
|
190
|
+
continue;
|
|
191
|
+
if (Math.abs(gd - ga) > TOLERANCE.gap)
|
|
192
|
+
mismatches.push({ type: 'gap', message: `between ${d.label} and ${next.label}: ${px(ga)}px in the app, ${px(gd)}px in the design` });
|
|
193
|
+
}
|
|
194
|
+
return { mismatches, matched, onlyDesign: design.length - matched, onlyApp: app.length - matched };
|
|
195
|
+
}
|
package/dist/canvas.html
CHANGED
|
@@ -654,6 +654,33 @@ function measure(f) {
|
|
|
654
654
|
}
|
|
655
655
|
|
|
656
656
|
const minHeight = (s) => (bigFrame(s) ? 800 : 844)
|
|
657
|
+
/** Whether two addresses are the same page: the same place, whatever their query or hash say. */
|
|
658
|
+
function samePage(a, b) {
|
|
659
|
+
try { const x = new URL(a), y = new URL(b); return x.origin === y.origin && x.pathname === y.pathname } catch { return false }
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* A page keeps growing after it loads: a table fetches its rows, an image arrives, a font swaps in. The measurements at load and in the first
|
|
663
|
+
* seconds catch what is there by then; this watches the page itself, so the frame is as tall as its page whenever the page changes size. The
|
|
664
|
+
* observer belongs to the loaded document and is replaced when the page reloads.
|
|
665
|
+
*/
|
|
666
|
+
function watchSize(f, iframe) {
|
|
667
|
+
f.sizeWatch?.disconnect()
|
|
668
|
+
f.sizeWatch = null
|
|
669
|
+
try {
|
|
670
|
+
const doc = iframe.contentDocument
|
|
671
|
+
const Observer = iframe.contentWindow?.ResizeObserver
|
|
672
|
+
if (!doc || !Observer) return
|
|
673
|
+
let queued
|
|
674
|
+
const watch = new Observer(() => {
|
|
675
|
+
clearTimeout(queued)
|
|
676
|
+
// Wait for the page to settle: a table draws many rows in a few frames, and one measure after them is enough.
|
|
677
|
+
queued = setTimeout(() => { if (f.sizeWatch === watch && f.iframe === iframe) measure(f) }, 80)
|
|
678
|
+
})
|
|
679
|
+
watch.observe(doc.documentElement)
|
|
680
|
+
if (doc.body) watch.observe(doc.body)
|
|
681
|
+
f.sizeWatch = watch
|
|
682
|
+
} catch {}
|
|
683
|
+
}
|
|
657
684
|
function setHeight(f, h) {
|
|
658
685
|
h = Math.min(MAX_FRAME_HEIGHT, Math.max(minHeight(f.screen), h))
|
|
659
686
|
HEIGHTS[`${f.screen.path}@${f.screen.width}`] = h
|
|
@@ -712,7 +739,9 @@ function mount(f) {
|
|
|
712
739
|
// `load` also fires for the initial about:blank document. Compare the
|
|
713
740
|
// loaded document itself with the current iframe target (which may have
|
|
714
741
|
// changed for a language/theme reload while this mount was pending).
|
|
715
|
-
|
|
742
|
+
// The address is compared without its query: a page may rewrite its own
|
|
743
|
+
// (a table that keeps its state there), and that is still the same page.
|
|
744
|
+
return f.iframe === iframe && iframe.contentDocument === doc && doc.readyState === 'complete' && samePage(doc.location.href, iframe.src)
|
|
716
745
|
}
|
|
717
746
|
iframe.addEventListener('load', () => {
|
|
718
747
|
if (f.iframe !== iframe) return
|
|
@@ -731,6 +760,7 @@ function mount(f) {
|
|
|
731
760
|
}, 350)
|
|
732
761
|
setTimeout(() => { if (f.iframe === iframe) measure(f) }, 1200)
|
|
733
762
|
setTimeout(() => { if (f.iframe === iframe) measure(f) }, 2500)
|
|
763
|
+
watchSize(f, iframe)
|
|
734
764
|
})()
|
|
735
765
|
})
|
|
736
766
|
iframe.src = frameSrc(f.screen)
|
|
@@ -739,6 +769,8 @@ function mount(f) {
|
|
|
739
769
|
function unmount(f) {
|
|
740
770
|
if (!f.iframe) return
|
|
741
771
|
clearTimeout(f.reveal)
|
|
772
|
+
f.sizeWatch?.disconnect()
|
|
773
|
+
f.sizeWatch = null
|
|
742
774
|
// Cleared first: a page still loading can fire its load event as it's removed.
|
|
743
775
|
const iframe = f.iframe
|
|
744
776
|
f.iframe = null
|
package/dist/cli.js
CHANGED
|
@@ -36,6 +36,8 @@ const COMMANDS = {
|
|
|
36
36
|
type: ['type.ts', '[filter] [--json] Every text style a screen uses, by type level'],
|
|
37
37
|
tokens: ['tokens.ts', 'Generate tokens.css and tailwind.tokens.js from DESIGN.md, and the exports in the config'],
|
|
38
38
|
compare: ['compare.ts', '[screen…] [--strict] The live app against the approved screens ("app" in the config)'],
|
|
39
|
+
look: ['look.ts', '[screen…] [--width N,N | --drawn] [--strict] Text in the live app against the approved screens: font, size, weight, colour…'],
|
|
40
|
+
geometry: ['geometry.ts', '[screen…] [--width N,N | --drawn] [--strict] Where things sit in the live app against the approved screens, at every width'],
|
|
39
41
|
elements: ['build-elements.ts', '<entry> [--out file] Bundle the project\'s components (defineElements) for the screens'],
|
|
40
42
|
lint: ['', "Google's DESIGN.md linter on the project's DESIGN.md"],
|
|
41
43
|
i18n: [
|
package/dist/compare.js
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
-
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
-
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
-
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
-
});
|
|
6
|
-
}
|
|
7
|
-
return path;
|
|
8
|
-
};
|
|
9
1
|
/**
|
|
10
2
|
* The live app against the approved screens. For every approved version whose screen has a route in the config's
|
|
11
3
|
* "app" section, it opens the approved version and the app page at the same width and compares their structure:
|
|
@@ -29,29 +21,19 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
29
21
|
* Usage: stitch2 compare [screen…] [--strict]
|
|
30
22
|
*/
|
|
31
23
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
32
|
-
import { join
|
|
33
|
-
import { pathToFileURL } from 'node:url';
|
|
34
|
-
import { chromium } from '@playwright/test';
|
|
24
|
+
import { join } from 'node:path';
|
|
35
25
|
import { audit, LAYOUT_ERRORS } from './audit.js';
|
|
36
|
-
import {
|
|
26
|
+
import { config, widthRange } from './config.js';
|
|
37
27
|
import { collectComponents, collectOptions } from './consistency.js';
|
|
38
|
-
import {
|
|
28
|
+
import { openLive } from './live.js';
|
|
29
|
+
import { ROOT } from './screens.js';
|
|
39
30
|
import { filterSnippet } from './report-filter.js';
|
|
40
|
-
import { startServer } from './serve.js';
|
|
41
|
-
const app = config.app;
|
|
42
|
-
if (!app?.url || !app.routes) {
|
|
43
|
-
console.error('stitch2 compare: add an "app" section with "url" and "routes" to stitch2.config.json');
|
|
44
|
-
process.exit(1);
|
|
45
|
-
}
|
|
46
31
|
const args = process.argv.slice(2);
|
|
47
32
|
const only = args.filter((a) => !a.startsWith('--'));
|
|
48
33
|
const strict = args.includes('--strict');
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
};
|
|
53
|
-
const versions = listScreens().filter((s) => s.kind === 'screen' && s.status === 'approved' && routeOf(s.screen) && (!only.length || only.includes(s.screen)));
|
|
54
|
-
const unmapped = [...new Set(listScreens().filter((s) => s.status === 'approved' && !routeOf(s.screen)).map((s) => s.screen))];
|
|
34
|
+
const live = await openLive('compare', only, 2);
|
|
35
|
+
const { versions, unmapped } = live;
|
|
36
|
+
const app = { url: live.appUrl };
|
|
55
37
|
/** Runs in the page: every marked component in document order, and the top-level ones (not inside another). */
|
|
56
38
|
function structure(prefix) {
|
|
57
39
|
const marker = `data-${prefix}`;
|
|
@@ -79,61 +61,30 @@ function lcs(a, b) {
|
|
|
79
61
|
d[i][j] = a[i - 1] === b[j - 1] ? d[i - 1][j - 1] + 1 : Math.max(d[i - 1][j], d[i][j - 1]);
|
|
80
62
|
return d[a.length][b.length];
|
|
81
63
|
}
|
|
82
|
-
const load = async (file) => (await import(__rewriteRelativeImportExtension(pathToFileURL(resolve(BASE, file)).href))).default;
|
|
83
|
-
const settle = async (page) => {
|
|
84
|
-
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => { });
|
|
85
|
-
await page.waitForTimeout(800);
|
|
86
|
-
};
|
|
87
64
|
const out = join(ROOT, 'compare');
|
|
88
65
|
mkdirSync(out, { recursive: true });
|
|
89
|
-
const server = await startServer(0);
|
|
90
|
-
const browser = await chromium.launch();
|
|
91
|
-
const context = await browser.newContext({ deviceScaleFactor: 2 });
|
|
92
|
-
const signIn = await context.newPage();
|
|
93
|
-
if (app.signIn)
|
|
94
|
-
await (await load(app.signIn))({ page: signIn, url: app.url });
|
|
95
|
-
await signIn.close();
|
|
96
|
-
const design = await browser.newPage({ deviceScaleFactor: 2 });
|
|
97
|
-
const signedIn = await context.newPage();
|
|
98
|
-
// Sign-in and other public pages often redirect a signed-in visitor, so they get a context of their own.
|
|
99
|
-
const signedOut = await (await browser.newContext({ deviceScaleFactor: 2 })).newPage();
|
|
100
66
|
const results = [];
|
|
101
67
|
const opts = collectOptions();
|
|
68
|
+
/** The version that is open: its design page and its app page. */
|
|
69
|
+
let pair;
|
|
102
70
|
/**
|
|
103
71
|
* One version against its app page at one width. The first call opens both pages (and runs prepare); the edges
|
|
104
72
|
* then only resize them, which reflows the layout, keeps the prepared state (a sheet open) and saves the reloads.
|
|
105
73
|
*/
|
|
106
74
|
async function compareAt(s, width, open) {
|
|
107
|
-
const route = routeOf(s.screen);
|
|
108
|
-
const live = route.signedOut ? signedOut : signedIn;
|
|
109
|
-
const url = new URL(route.path, app.url).href;
|
|
110
|
-
const height = width > 700 ? 800 : 844;
|
|
111
|
-
for (const p of [design, live])
|
|
112
|
-
await p.setViewportSize({ width, height });
|
|
113
75
|
const findings = [];
|
|
114
|
-
if (
|
|
115
|
-
await live.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
await settle(design);
|
|
119
|
-
await live.goto(url).catch(() => { });
|
|
120
|
-
await settle(live);
|
|
121
|
-
}
|
|
122
|
-
if (open && route.prepare) {
|
|
123
|
-
// A failing prepare is this version's error; the run goes on with the others.
|
|
124
|
-
try {
|
|
125
|
-
await (await load(route.prepare))({ page: live, url: app.url });
|
|
126
|
-
}
|
|
127
|
-
catch (e) {
|
|
128
|
-
const message = (e instanceof Error ? e.message : String(e)).split('\n')[0];
|
|
129
|
-
findings.push({ severity: 'error', type: 'prepare', message: `${route.prepare} failed: ${message}` });
|
|
130
|
-
}
|
|
131
|
-
await settle(live);
|
|
76
|
+
if (open) {
|
|
77
|
+
pair = await live.open(s);
|
|
78
|
+
if (pair.prepareError)
|
|
79
|
+
findings.push({ severity: 'error', type: 'prepare', message: pair.prepareError });
|
|
132
80
|
}
|
|
133
|
-
|
|
81
|
+
else
|
|
82
|
+
await live.resize(width);
|
|
83
|
+
const { design, live: page, url } = pair;
|
|
84
|
+
const [d, a] = [await design.evaluate(structure, config.prefix), await page.evaluate(structure, config.prefix)];
|
|
134
85
|
const [dc, ac] = [
|
|
135
86
|
(await design.evaluate(collectComponents, opts)).components,
|
|
136
|
-
(await
|
|
87
|
+
(await page.evaluate(collectComponents, opts)).components,
|
|
137
88
|
];
|
|
138
89
|
const missing = Object.keys(d.counts).filter((k) => !a.counts[k]);
|
|
139
90
|
const extra = Object.keys(a.counts).filter((k) => !d.counts[k]);
|
|
@@ -163,18 +114,18 @@ async function compareAt(s, width, open) {
|
|
|
163
114
|
}
|
|
164
115
|
// The same layout rules check runs on the screens, on the app: a control squeezed over another shows up here
|
|
165
116
|
// even when the screen never drew that state.
|
|
166
|
-
for (const i of await
|
|
117
|
+
for (const i of await page.evaluate(audit, { comfortable: config.comfortableTarget }))
|
|
167
118
|
if (i.severity === 'error' && LAYOUT_ERRORS.includes(i.type))
|
|
168
119
|
findings.push({ severity: 'error', type: i.type, message: `${i.message} (“${i.text}”)` });
|
|
169
120
|
const kinds = Object.keys(d.counts);
|
|
170
121
|
const match = kinds.length ? Math.round((100 * kinds.filter((k) => a.counts[k]).length) / kinds.length) : 100;
|
|
171
|
-
return { findings, match, url
|
|
122
|
+
return { findings, match, url };
|
|
172
123
|
}
|
|
173
124
|
for (const s of versions) {
|
|
174
|
-
const { findings, match, url
|
|
125
|
+
const { findings, match, url } = await compareAt(s, s.width, true);
|
|
175
126
|
const base = `${s.screen}-${s.device}`;
|
|
176
|
-
await design.screenshot({ path: join(out, `${base}-design.png`), fullPage: true });
|
|
177
|
-
await live.screenshot({ path: join(out, `${base}-app.png`), fullPage: true });
|
|
127
|
+
await pair.design.screenshot({ path: join(out, `${base}-design.png`), fullPage: true });
|
|
128
|
+
await pair.live.screenshot({ path: join(out, `${base}-app.png`), fullPage: true });
|
|
178
129
|
const seen = new Set(findings.map((f) => `${f.type} ${f.message}`));
|
|
179
130
|
for (const width of widthRange(s.device) ?? []) {
|
|
180
131
|
if (width === s.width)
|
|
@@ -193,8 +144,7 @@ for (const s of versions) {
|
|
|
193
144
|
for (const f of findings.filter((f) => f.severity === 'error'))
|
|
194
145
|
console.log(` ${f.type}: ${f.message}`);
|
|
195
146
|
}
|
|
196
|
-
await
|
|
197
|
-
server.close();
|
|
147
|
+
await live.close();
|
|
198
148
|
const esc = (v) => String(v ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
199
149
|
const rows = results
|
|
200
150
|
.map((r) => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/geometry.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where things sit in the live app against the approved screens. look compares how text is drawn; geometry compares
|
|
3
|
+
* where it and the shared components land: the size of each box, its left edge, and the space between neighbours
|
|
4
|
+
* (a title that wraps under its icon, a button pushed against the text above, a card a few pixels narrower). Boxes
|
|
5
|
+
* are paired by what they hold, so data that differs is skipped, and the page is measured at every width a layout can
|
|
6
|
+
* change at, not only the one the screen is drawn at.
|
|
7
|
+
*
|
|
8
|
+
* Each version is measured at the width it is drawn at, at both ends of its variant's width range and at every
|
|
9
|
+
* breakpoint inside it (the widths check renders); --width narrows that to the widths you give, and --drawn to the
|
|
10
|
+
* width each version is drawn at. The first differences in document order are shown (what sits further down often
|
|
11
|
+
* moves because of them); all are in <design>/geometry.json.
|
|
12
|
+
*
|
|
13
|
+
* Usage: stitch2 geometry [screen…] [--width 360,1280 | --drawn] [--strict]
|
|
14
|
+
*/
|
|
15
|
+
import { writeFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { boxes, compareBoxes } from './boxes.js';
|
|
18
|
+
import { config, ROOT } from './config.js';
|
|
19
|
+
import { openLive, screenArgs, scrollThrough, widthsFor } from './live.js';
|
|
20
|
+
const SHOWN = 6;
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const strict = args.includes('--strict');
|
|
23
|
+
const live = await openLive('geometry', screenArgs(args));
|
|
24
|
+
const results = [];
|
|
25
|
+
let total = 0;
|
|
26
|
+
const started = Date.now();
|
|
27
|
+
for (const s of live.versions) {
|
|
28
|
+
const opened = await live.open(s);
|
|
29
|
+
const base = `${s.screen}-${s.device}`;
|
|
30
|
+
if (opened.prepareError) {
|
|
31
|
+
console.log(`✗ ${base}: ${opened.prepareError}`);
|
|
32
|
+
total++;
|
|
33
|
+
}
|
|
34
|
+
let at = s.width;
|
|
35
|
+
for (const width of widthsFor(s, args)) {
|
|
36
|
+
if (width !== at)
|
|
37
|
+
await live.resize(width);
|
|
38
|
+
at = width;
|
|
39
|
+
await scrollThrough(opened.design);
|
|
40
|
+
await scrollThrough(opened.live);
|
|
41
|
+
const found = compareBoxes(await opened.design.evaluate(boxes, config.prefix), await opened.live.evaluate(boxes, config.prefix));
|
|
42
|
+
total += found.mismatches.length;
|
|
43
|
+
results.push({ screen: s.screen, device: s.device, version: s.version, width, ...found });
|
|
44
|
+
console.log(`${found.mismatches.length ? '✗' : '✓'} ${`${base} @${width}`.padEnd(34)} ${found.matched} boxes matched${found.mismatches.length ? `, ${found.mismatches.length} differ` : ''}`);
|
|
45
|
+
for (const m of found.mismatches.slice(0, SHOWN))
|
|
46
|
+
console.log(` ${m.type}: ${m.message}`);
|
|
47
|
+
if (found.mismatches.length > SHOWN)
|
|
48
|
+
console.log(` … and ${found.mismatches.length - SHOWN} more`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
await live.close();
|
|
52
|
+
writeFileSync(join(ROOT, 'geometry.json'), `${JSON.stringify({ measuredAt: new Date().toISOString(), url: live.appUrl, results }, null, 2)}\n`);
|
|
53
|
+
console.log(`\n${live.versions.length} versions measured, ${total} differences, ${Math.round((Date.now() - started) / 1000)}s. Details: ${config.designDir}/geometry.json`);
|
|
54
|
+
if (strict && total)
|
|
55
|
+
process.exit(1);
|
package/dist/live.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type Page } from '@playwright/test';
|
|
2
|
+
import { type Screen } from './screens.ts';
|
|
3
|
+
export type Route = string | {
|
|
4
|
+
path: string;
|
|
5
|
+
prepare?: string;
|
|
6
|
+
signedOut?: boolean;
|
|
7
|
+
};
|
|
8
|
+
export interface Opened {
|
|
9
|
+
/** The approved version, served by the lab. */
|
|
10
|
+
design: Page;
|
|
11
|
+
/** The app page, signed in (or not, for a signedOut route) and prepared. */
|
|
12
|
+
live: Page;
|
|
13
|
+
/** The app page's address. */
|
|
14
|
+
url: string;
|
|
15
|
+
/** Set when the route's prepare script threw: the page is open, but not in the state the screen draws. */
|
|
16
|
+
prepareError?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface Live {
|
|
19
|
+
/** The approved versions that have a route, narrowed to the screens named on the command line. */
|
|
20
|
+
versions: Screen[];
|
|
21
|
+
/** Screens with an approved version and no route: not compared at all. */
|
|
22
|
+
unmapped: string[];
|
|
23
|
+
/** The app's address from the config. */
|
|
24
|
+
appUrl: string;
|
|
25
|
+
/** Opens a version and its app page at the width the version is drawn at, and runs the route's prepare script. */
|
|
26
|
+
open(version: Screen): Promise<Opened>;
|
|
27
|
+
/** Resizes the pages that are open to another width; the layout reflows and the prepared state stays. */
|
|
28
|
+
resize(width: number): Promise<void>;
|
|
29
|
+
close(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
/** Waits for the network to go quiet, then a little longer for what scripts draw after that. */
|
|
32
|
+
export declare const settle: (page: Page) => Promise<void>;
|
|
33
|
+
/** The viewport height a width is drawn at: a phone is taller than a window. */
|
|
34
|
+
export declare const heightAt: (width: number) => 800 | 844;
|
|
35
|
+
/**
|
|
36
|
+
* The widths a version is looked at: the width it is drawn at, both ends of its variant's range and every breakpoint
|
|
37
|
+
* inside it (the widths check renders). --width narrows them to the ones asked for, --drawn to the width each version
|
|
38
|
+
* is drawn at.
|
|
39
|
+
*/
|
|
40
|
+
export declare function widthsFor(s: Pick<Screen, 'width' | 'device'>, args?: string[]): number[];
|
|
41
|
+
/** The numbers after --width (360,1280 or two flags), for widthsFor. */
|
|
42
|
+
export declare function widthArgs(args: string[]): number[];
|
|
43
|
+
/** The screens named on the command line: the words that are neither flags nor the value of --width. */
|
|
44
|
+
export declare function screenArgs(args: string[]): string[];
|
|
45
|
+
/**
|
|
46
|
+
* Walks a page from top to bottom and back, so what loads on scroll is there when it is measured, and takes the pointer
|
|
47
|
+
* off the page: a prepare script that clicked something leaves it there, and after a resize a button can end up under it
|
|
48
|
+
* and be measured in its hover colours.
|
|
49
|
+
*/
|
|
50
|
+
export declare function scrollThrough(page: Page): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Starts the lab server and a browser, signs in to the app and returns the pair opener. Exits with a message when the
|
|
53
|
+
* config has no "app" section; `command` names the command in it.
|
|
54
|
+
*/
|
|
55
|
+
export declare function openLive(command: string, only: string[], scale?: number): Promise<Live>;
|
package/dist/live.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* The pair the app commands work on: an approved screen and its live page, open side by side at the same width.
|
|
11
|
+
* compare, look and geometry all start here, so a route, its sign-in and its prepare script mean the same in each.
|
|
12
|
+
*
|
|
13
|
+
* "app": {
|
|
14
|
+
* "url": "http://localhost:4321",
|
|
15
|
+
* "signIn": "design/app/sign-in.mjs", // export default async ({ page, url }) => { … }
|
|
16
|
+
* "routes": {
|
|
17
|
+
* "home": "/app",
|
|
18
|
+
* "exercise-detail": { "path": "/app/library", "prepare": "design/app/open-exercise.mjs" },
|
|
19
|
+
* "sign-in": { "path": "/login", "signedOut": true } // a page of its own, never signed in
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*/
|
|
23
|
+
import { resolve } from 'node:path';
|
|
24
|
+
import { pathToFileURL } from 'node:url';
|
|
25
|
+
import { chromium } from '@playwright/test';
|
|
26
|
+
import { BASE, checkWidths, config } from './config.js';
|
|
27
|
+
import { listScreens } from './screens.js';
|
|
28
|
+
import { startServer } from './serve.js';
|
|
29
|
+
const load = async (file) => (await import(__rewriteRelativeImportExtension(pathToFileURL(resolve(BASE, file)).href))).default;
|
|
30
|
+
/** Waits for the network to go quiet, then a little longer for what scripts draw after that. */
|
|
31
|
+
export const settle = async (page) => {
|
|
32
|
+
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => { });
|
|
33
|
+
await page.waitForTimeout(800);
|
|
34
|
+
};
|
|
35
|
+
/** The viewport height a width is drawn at: a phone is taller than a window. */
|
|
36
|
+
export const heightAt = (width) => (width > 700 ? 800 : 844);
|
|
37
|
+
/**
|
|
38
|
+
* The widths a version is looked at: the width it is drawn at, both ends of its variant's range and every breakpoint
|
|
39
|
+
* inside it (the widths check renders). --width narrows them to the ones asked for, --drawn to the width each version
|
|
40
|
+
* is drawn at.
|
|
41
|
+
*/
|
|
42
|
+
export function widthsFor(s, args = []) {
|
|
43
|
+
const only = widthArgs(args);
|
|
44
|
+
if (only.length)
|
|
45
|
+
return only;
|
|
46
|
+
if (args.includes('--drawn'))
|
|
47
|
+
return [s.width];
|
|
48
|
+
return [...new Set([s.width, ...checkWidths(s.device)])].sort((a, b) => a - b);
|
|
49
|
+
}
|
|
50
|
+
/** The numbers after --width (360,1280 or two flags), for widthsFor. */
|
|
51
|
+
export function widthArgs(args) {
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const [i, a] of args.entries())
|
|
54
|
+
if (a === '--width' || a.startsWith('--width='))
|
|
55
|
+
for (const v of (a.includes('=') ? a.split('=')[1] : (args[i + 1] ?? '')).split(','))
|
|
56
|
+
if (Number(v) > 0)
|
|
57
|
+
out.push(Number(v));
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/** The screens named on the command line: the words that are neither flags nor the value of --width. */
|
|
61
|
+
export function screenArgs(args) {
|
|
62
|
+
return args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--width');
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Walks a page from top to bottom and back, so what loads on scroll is there when it is measured, and takes the pointer
|
|
66
|
+
* off the page: a prepare script that clicked something leaves it there, and after a resize a button can end up under it
|
|
67
|
+
* and be measured in its hover colours.
|
|
68
|
+
*/
|
|
69
|
+
export async function scrollThrough(page) {
|
|
70
|
+
await page.mouse.move(-1, -1);
|
|
71
|
+
await page.evaluate(async () => {
|
|
72
|
+
for (let y = 0; y < document.body.scrollHeight; y += 700) {
|
|
73
|
+
window.scrollTo(0, y);
|
|
74
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
75
|
+
}
|
|
76
|
+
window.scrollTo(0, 0);
|
|
77
|
+
});
|
|
78
|
+
await page.waitForTimeout(300);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Starts the lab server and a browser, signs in to the app and returns the pair opener. Exits with a message when the
|
|
82
|
+
* config has no "app" section; `command` names the command in it.
|
|
83
|
+
*/
|
|
84
|
+
export async function openLive(command, only, scale = 1) {
|
|
85
|
+
const app = config.app;
|
|
86
|
+
if (!app?.url || !app.routes) {
|
|
87
|
+
console.error(`stitch2 ${command}: add an "app" section with "url" and "routes" to stitch2.config.json`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
const routeOf = (screen) => {
|
|
91
|
+
const r = app.routes[screen];
|
|
92
|
+
return typeof r === 'string' ? { path: r } : r;
|
|
93
|
+
};
|
|
94
|
+
const all = listScreens();
|
|
95
|
+
const versions = all.filter((s) => s.kind === 'screen' && s.status === 'approved' && routeOf(s.screen) && (!only.length || only.includes(s.screen)));
|
|
96
|
+
const unmapped = [...new Set(all.filter((s) => s.status === 'approved' && !routeOf(s.screen)).map((s) => s.screen))];
|
|
97
|
+
const server = await startServer(0);
|
|
98
|
+
const browser = await chromium.launch();
|
|
99
|
+
const context = await browser.newContext({ deviceScaleFactor: scale });
|
|
100
|
+
const signIn = await context.newPage();
|
|
101
|
+
if (app.signIn)
|
|
102
|
+
await (await load(app.signIn))({ page: signIn, url: app.url });
|
|
103
|
+
await signIn.close();
|
|
104
|
+
const design = await browser.newPage({ deviceScaleFactor: scale });
|
|
105
|
+
const signedIn = await context.newPage();
|
|
106
|
+
// Sign-in and other public pages often redirect a signed-in visitor, so they get a context of their own.
|
|
107
|
+
const signedOut = await (await browser.newContext({ deviceScaleFactor: scale })).newPage();
|
|
108
|
+
let current = signedIn;
|
|
109
|
+
return {
|
|
110
|
+
versions,
|
|
111
|
+
unmapped,
|
|
112
|
+
appUrl: app.url,
|
|
113
|
+
async open(s) {
|
|
114
|
+
const route = routeOf(s.screen);
|
|
115
|
+
const live = route.signedOut ? signedOut : signedIn;
|
|
116
|
+
current = live;
|
|
117
|
+
const url = new URL(route.path, app.url).href;
|
|
118
|
+
for (const p of [design, live])
|
|
119
|
+
await p.setViewportSize({ width: s.width, height: heightAt(s.width) });
|
|
120
|
+
await design.goto(`http://127.0.0.1:${server.port}/${s.path}`).catch(() => { });
|
|
121
|
+
await settle(design);
|
|
122
|
+
await live.goto(url).catch(() => { });
|
|
123
|
+
await settle(live);
|
|
124
|
+
let prepareError;
|
|
125
|
+
if (route.prepare) {
|
|
126
|
+
// A failing prepare is this version's error; the run goes on with the others.
|
|
127
|
+
try {
|
|
128
|
+
await (await load(route.prepare))({ page: live, url: app.url });
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
prepareError = `${route.prepare} failed: ${(e instanceof Error ? e.message : String(e)).split('\n')[0]}`;
|
|
132
|
+
}
|
|
133
|
+
await settle(live);
|
|
134
|
+
}
|
|
135
|
+
return { design, live, url, ...(prepareError ? { prepareError } : {}) };
|
|
136
|
+
},
|
|
137
|
+
async resize(width) {
|
|
138
|
+
for (const p of [design, current])
|
|
139
|
+
await p.setViewportSize({ width, height: heightAt(width) });
|
|
140
|
+
await current.waitForTimeout(500);
|
|
141
|
+
},
|
|
142
|
+
async close() {
|
|
143
|
+
await browser.close();
|
|
144
|
+
server.close();
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
package/dist/look.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/look.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the live app's text looks against the approved screens. compare says which components are there; look says
|
|
3
|
+
* whether the words in them are drawn the same: for every piece of text the screen and the app share, the font,
|
|
4
|
+
* size, weight, letter spacing, line height, case, alignment, decoration and colour. Text is matched by its words
|
|
5
|
+
* (the nth time a phrase appears on the screen is the nth time it appears in the app), so data that differs between
|
|
6
|
+
* a mock-up and the app is left out: a phrase on one side only is counted, not reported.
|
|
7
|
+
*
|
|
8
|
+
* Each version is looked at the width it is drawn at, at both ends of its variant's width range and at every
|
|
9
|
+
* breakpoint inside it (the widths check renders); --width narrows that to the widths you give, and --drawn to the
|
|
10
|
+
* width each version is drawn at. The same difference on many texts is one line with a count. Writes
|
|
11
|
+
* <design>/look.json.
|
|
12
|
+
*
|
|
13
|
+
* Usage: stitch2 look [screen…] [--width 360,1280 | --drawn] [--strict]
|
|
14
|
+
*/
|
|
15
|
+
import { writeFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { config, ROOT } from './config.js';
|
|
18
|
+
import { openLive, screenArgs, scrollThrough, widthsFor } from './live.js';
|
|
19
|
+
import { lookAt, texts } from './looks.js';
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const strict = args.includes('--strict');
|
|
22
|
+
const live = await openLive('look', screenArgs(args));
|
|
23
|
+
const results = [];
|
|
24
|
+
let total = 0;
|
|
25
|
+
const started = Date.now();
|
|
26
|
+
for (const s of live.versions) {
|
|
27
|
+
const opened = await live.open(s);
|
|
28
|
+
const base = `${s.screen}-${s.device}`;
|
|
29
|
+
if (opened.prepareError) {
|
|
30
|
+
console.log(`✗ ${base}: ${opened.prepareError}`);
|
|
31
|
+
total++;
|
|
32
|
+
}
|
|
33
|
+
let at = s.width;
|
|
34
|
+
for (const width of widthsFor(s, args)) {
|
|
35
|
+
if (width !== at)
|
|
36
|
+
await live.resize(width);
|
|
37
|
+
at = width;
|
|
38
|
+
await scrollThrough(opened.design);
|
|
39
|
+
await scrollThrough(opened.live);
|
|
40
|
+
const found = lookAt(await opened.design.evaluate(texts, config.prefix), await opened.live.evaluate(texts, config.prefix));
|
|
41
|
+
total += found.differences.length;
|
|
42
|
+
results.push({ screen: s.screen, device: s.device, version: s.version, width, ...found });
|
|
43
|
+
const extra = found.onlyApp.length || found.onlyDesign.length ? ` (${found.onlyApp.length} only in the app, ${found.onlyDesign.length} only in the design)` : '';
|
|
44
|
+
console.log(`${found.differences.length ? '✗' : '✓'} ${`${base} @${width}`.padEnd(34)} ${found.matched} texts matched${extra}`);
|
|
45
|
+
for (const d of found.differences.slice(0, 8))
|
|
46
|
+
console.log(` ${d.count > 1 ? `×${d.count} ` : ''}${d.what} — ${d.texts.map((t) => `“${t.slice(0, 28)}”`).join(', ')} (app: ${d.at})`);
|
|
47
|
+
if (found.differences.length > 8)
|
|
48
|
+
console.log(` … and ${found.differences.length - 8} more kinds of difference`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
await live.close();
|
|
52
|
+
writeFileSync(join(ROOT, 'look.json'), `${JSON.stringify({ lookedAt: new Date().toISOString(), url: live.appUrl, results }, null, 2)}\n`);
|
|
53
|
+
console.log(`\n${live.versions.length} versions looked at, ${total} differences, ${Math.round((Date.now() - started) / 1000)}s. Details: ${config.designDir}/look.json`);
|
|
54
|
+
if (strict && total)
|
|
55
|
+
process.exit(1);
|
package/dist/looks.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text as a page draws it, for stitch2 look: every element's own words with the font, size, weight, spacing, line
|
|
3
|
+
* height, colour, case, alignment and decoration they are drawn in, and the pairing of two pages' texts by their words.
|
|
4
|
+
*/
|
|
5
|
+
/** One piece of text and how it is drawn. */
|
|
6
|
+
export interface Text {
|
|
7
|
+
text: string;
|
|
8
|
+
/** The first font family only: the fallbacks after it differ between a page and a component for good reason. */
|
|
9
|
+
family: string;
|
|
10
|
+
size: string;
|
|
11
|
+
weight: string;
|
|
12
|
+
style: string;
|
|
13
|
+
spacing: string;
|
|
14
|
+
leading: string;
|
|
15
|
+
colour: string;
|
|
16
|
+
transform: string;
|
|
17
|
+
align: string;
|
|
18
|
+
decoration: string;
|
|
19
|
+
/** The shared component it is in (its data-<prefix> value), or empty: the same words in two roles are two texts. */
|
|
20
|
+
inside: string;
|
|
21
|
+
/** A short description of the element, for the report: tag and classes. */
|
|
22
|
+
at: string;
|
|
23
|
+
}
|
|
24
|
+
/** Runs in the page: every element's own text, as drawn. Self-contained: Playwright sends this function's source. */
|
|
25
|
+
export declare function texts(prefix: string): Text[];
|
|
26
|
+
export interface Difference {
|
|
27
|
+
/** What differs, for every property that does: "size 14px in the design, 16px in the app". */
|
|
28
|
+
what: string;
|
|
29
|
+
/** How many texts differ in exactly this way. */
|
|
30
|
+
count: number;
|
|
31
|
+
/** Some of them. */
|
|
32
|
+
texts: string[];
|
|
33
|
+
/** The element in the app (tag and classes) of the first one, to find it in the source. */
|
|
34
|
+
at: string;
|
|
35
|
+
}
|
|
36
|
+
interface Looked {
|
|
37
|
+
differences: Difference[];
|
|
38
|
+
matched: number;
|
|
39
|
+
onlyDesign: string[];
|
|
40
|
+
onlyApp: string[];
|
|
41
|
+
}
|
|
42
|
+
/** Pairs the texts of the two pages by their words and lists how the pairs are drawn differently. */
|
|
43
|
+
export declare function lookAt(design: Text[], app: Text[]): Looked;
|
|
44
|
+
export {};
|
package/dist/looks.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text as a page draws it, for stitch2 look: every element's own words with the font, size, weight, spacing, line
|
|
3
|
+
* height, colour, case, alignment and decoration they are drawn in, and the pairing of two pages' texts by their words.
|
|
4
|
+
*/
|
|
5
|
+
/** Runs in the page: every element's own text, as drawn. Self-contained: Playwright sends this function's source. */
|
|
6
|
+
export function texts(prefix) {
|
|
7
|
+
const out = [];
|
|
8
|
+
const marker = '[data-' + prefix + ']';
|
|
9
|
+
const range = document.createRange();
|
|
10
|
+
// Text kept for screen readers sits in a box a pixel wide with its overflow cut away: it is not drawn.
|
|
11
|
+
const clipped = (el) => {
|
|
12
|
+
for (let e = el; e && e !== document.body; e = e.parentElement) {
|
|
13
|
+
if (getComputedStyle(e).overflow === 'visible')
|
|
14
|
+
continue;
|
|
15
|
+
const r = e.getBoundingClientRect();
|
|
16
|
+
if (r.width < 2 || r.height < 2)
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
};
|
|
21
|
+
// Colours come back in the form they were written in (rgb, color(srgb …), oklch…): the canvas turns each into rgba.
|
|
22
|
+
const canvas = document.createElement('canvas');
|
|
23
|
+
canvas.width = canvas.height = 1;
|
|
24
|
+
const paint = canvas.getContext('2d', { willReadFrequently: true });
|
|
25
|
+
const rgba = (c) => {
|
|
26
|
+
paint.clearRect(0, 0, 1, 1);
|
|
27
|
+
paint.fillStyle = '#000';
|
|
28
|
+
paint.fillStyle = c;
|
|
29
|
+
paint.fillRect(0, 0, 1, 1);
|
|
30
|
+
const [r, g, b, a] = paint.getImageData(0, 0, 1, 1).data;
|
|
31
|
+
return `rgba(${r}, ${g}, ${b}, ${Math.round(((a ?? 255) / 255) * 100) / 100})`;
|
|
32
|
+
};
|
|
33
|
+
for (const el of document.body.querySelectorAll('*')) {
|
|
34
|
+
if (el.closest('script, style, noscript, template, option'))
|
|
35
|
+
continue;
|
|
36
|
+
let own = '';
|
|
37
|
+
for (const n of el.childNodes)
|
|
38
|
+
if (n.nodeType === Node.TEXT_NODE)
|
|
39
|
+
own += `${n.textContent ?? ''} `;
|
|
40
|
+
const text = own.replace(/\s+/g, ' ').trim();
|
|
41
|
+
// A number or a symbol on its own (93.1%, $12, 3/4) is data: the same figure can stand in another place on the other page.
|
|
42
|
+
if (text.length < 2 || !/\p{L}/u.test(text))
|
|
43
|
+
continue;
|
|
44
|
+
if (!el.checkVisibility({ opacityProperty: true, visibilityProperty: true }) || clipped(el))
|
|
45
|
+
continue;
|
|
46
|
+
const r = el.getBoundingClientRect();
|
|
47
|
+
if (r.width < 2 || r.height < 2)
|
|
48
|
+
continue;
|
|
49
|
+
const cs = getComputedStyle(el);
|
|
50
|
+
// Alignment shows only where text runs over more than one line; on a single line, where it sits is geometry's.
|
|
51
|
+
let [top, bottom] = [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
|
|
52
|
+
for (const n of el.childNodes) {
|
|
53
|
+
if (n.nodeType !== Node.TEXT_NODE || !(n.textContent ?? '').trim())
|
|
54
|
+
continue;
|
|
55
|
+
range.selectNodeContents(n);
|
|
56
|
+
const q = range.getBoundingClientRect();
|
|
57
|
+
top = Math.min(top, q.top);
|
|
58
|
+
bottom = Math.max(bottom, q.bottom);
|
|
59
|
+
}
|
|
60
|
+
const line = Number.parseFloat(cs.lineHeight) || Number.parseFloat(cs.fontSize) * 1.3;
|
|
61
|
+
const align = bottom - top > line * 1.5 ? { left: 'start', right: 'end' }[cs.textAlign] ?? cs.textAlign : '';
|
|
62
|
+
const classes = String(el.getAttribute('class') ?? '').trim().split(/\s+/).filter(Boolean).slice(0, 4).join('.');
|
|
63
|
+
out.push({
|
|
64
|
+
text: text.slice(0, 60),
|
|
65
|
+
family: cs.fontFamily.split(',')[0].replace(/["']/g, '').trim(),
|
|
66
|
+
size: cs.fontSize,
|
|
67
|
+
weight: cs.fontWeight,
|
|
68
|
+
style: cs.fontStyle,
|
|
69
|
+
spacing: cs.letterSpacing,
|
|
70
|
+
leading: cs.lineHeight,
|
|
71
|
+
colour: rgba(cs.color),
|
|
72
|
+
transform: cs.textTransform,
|
|
73
|
+
align,
|
|
74
|
+
decoration: cs.textDecorationLine,
|
|
75
|
+
inside: el.closest(marker)?.getAttribute('data-' + prefix) ?? '',
|
|
76
|
+
at: el.tagName.toLowerCase() + (classes ? `.${classes}` : ''),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
const PROPS = [
|
|
82
|
+
['family', 'font'],
|
|
83
|
+
['size', 'size'],
|
|
84
|
+
['weight', 'weight'],
|
|
85
|
+
['style', 'font style'],
|
|
86
|
+
['spacing', 'letter spacing'],
|
|
87
|
+
['leading', 'line height'],
|
|
88
|
+
['colour', 'colour'],
|
|
89
|
+
['transform', 'case'],
|
|
90
|
+
['align', 'alignment'],
|
|
91
|
+
['decoration', 'decoration'],
|
|
92
|
+
];
|
|
93
|
+
const px = (v) => (v === 'normal' ? 0 : Number.parseFloat(v));
|
|
94
|
+
const channels = (v) => (v.match(/[\d.]+/g) ?? []).map(Number);
|
|
95
|
+
/** Whether two computed values are the same drawing: lengths within a hair, "normal" spacing as 0px. */
|
|
96
|
+
function same(prop, a, b) {
|
|
97
|
+
if (a === b)
|
|
98
|
+
return true;
|
|
99
|
+
// Alignment is read only where the text runs over several lines; a text that wraps on one page and not on the other has none to compare.
|
|
100
|
+
if (prop === 'align' && (!a || !b))
|
|
101
|
+
return true;
|
|
102
|
+
if (prop === 'colour') {
|
|
103
|
+
const [x, y] = [channels(a), channels(b)];
|
|
104
|
+
return x.length === 4 && y.length === 4 && x.every((v, i) => Math.abs(v - y[i]) <= (i === 3 ? 0.02 : 1));
|
|
105
|
+
}
|
|
106
|
+
if (prop === 'size' || prop === 'spacing')
|
|
107
|
+
return Math.abs(px(a) - px(b)) < 0.06;
|
|
108
|
+
if (prop === 'leading')
|
|
109
|
+
return a !== 'normal' && b !== 'normal' && Math.abs(px(a) - px(b)) < 0.6;
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
/** Pairs the texts of the two pages by their words and lists how the pairs are drawn differently. */
|
|
113
|
+
export function lookAt(design, app) {
|
|
114
|
+
const byText = new Map();
|
|
115
|
+
const key = (t) => t.inside + '|' + t.text;
|
|
116
|
+
for (const t of design)
|
|
117
|
+
byText.set(key(t), [...(byText.get(key(t)) ?? []), t]);
|
|
118
|
+
const used = new Map();
|
|
119
|
+
const groups = new Map();
|
|
120
|
+
const onlyApp = [];
|
|
121
|
+
let matched = 0;
|
|
122
|
+
for (const a of app) {
|
|
123
|
+
const n = used.get(key(a)) ?? 0;
|
|
124
|
+
const d = byText.get(key(a))?.[n];
|
|
125
|
+
if (!d) {
|
|
126
|
+
onlyApp.push(a.text);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
used.set(key(a), n + 1);
|
|
130
|
+
matched++;
|
|
131
|
+
const what = PROPS.filter(([k]) => !same(k, d[k], a[k]))
|
|
132
|
+
.map(([k, label]) => `${label} ${d[k]} in the design, ${a[k]} in the app`)
|
|
133
|
+
.join('; ');
|
|
134
|
+
if (!what)
|
|
135
|
+
continue;
|
|
136
|
+
const g = groups.get(what) ?? { what, count: 0, texts: [], at: a.at };
|
|
137
|
+
g.count++;
|
|
138
|
+
if (g.texts.length < 4)
|
|
139
|
+
g.texts.push(a.text);
|
|
140
|
+
groups.set(what, g);
|
|
141
|
+
}
|
|
142
|
+
const onlyDesign = [];
|
|
143
|
+
for (const [k, list] of byText)
|
|
144
|
+
for (let i = used.get(k) ?? 0; i < list.length; i++)
|
|
145
|
+
onlyDesign.push(list[i].text);
|
|
146
|
+
return { differences: [...groups.values()].sort((x, y) => y.count - x.count), matched, onlyDesign, onlyApp };
|
|
147
|
+
}
|
package/package.json
CHANGED
package/skills/stitch2/SKILL.md
CHANGED
|
@@ -139,6 +139,8 @@ References, read before the first screen:
|
|
|
139
139
|
| `screens [name] [--status s] [--json] [--strict]` | Every version with its status and the user's note, and anything that makes the current version unclear |
|
|
140
140
|
| `approve <path or filter…>` | Approves variants for the user (only on their word); every earlier version of the screen on that device is archived |
|
|
141
141
|
| `compare [screen…]` | The live app against the approved versions: missing or extra components, order, looks; a side-by-side report |
|
|
142
|
+
| `look [screen…] [--width N,N \| --drawn]` | How the app's text is drawn against the approved versions (font, size, weight, spacing, colour), at every width |
|
|
143
|
+
| `geometry [screen…] [--width N,N \| --drawn]` | Where the app's components and text sit against the approved versions: size, left edge, space between neighbours, arrangement |
|
|
142
144
|
| `elements <entry>` | Bundles the app's components, turned into elements with `stitch2/elements`, for the screens |
|
|
143
145
|
| `tokens` | `tokens.css` (colours, `.type-*` classes) and `tailwind.tokens.js` from DESIGN.md |
|
|
144
146
|
| `lint` | Google's DESIGN.md linter |
|
|
@@ -54,6 +54,13 @@ Slots are for wiring. If what goes into a slot changes how the component looks,
|
|
|
54
54
|
what order, how the measured ones look, **and the same layout errors as check** (overflow, fixed-overlap,
|
|
55
55
|
control-overlap), so a control squeezed in the app shows up even when no screen drew it. Each version runs at
|
|
56
56
|
its drawn width and at both edges of its width range; a finding seen only at an edge starts with "at <width>px:".
|
|
57
|
+
- `stitch2 look` (the same routes): the words the screen and the app share, one by one, in the font, size, weight,
|
|
58
|
+
letter spacing, line height, colour, case, alignment and decoration they are drawn in. Text on one side only is
|
|
59
|
+
data and is counted, not reported.
|
|
60
|
+
- `stitch2 geometry` (the same routes): the size and left edge of every shared component and text, the space
|
|
61
|
+
between neighbours, and whether two things sit beside each other or one under the other. It runs at the drawn
|
|
62
|
+
width, both ends of the range and every breakpoint inside it, so a title that wraps under its icon at 1024px
|
|
63
|
+
shows up. `--width 360,1280` picks widths and `--drawn` uses only the drawn one.
|
|
57
64
|
- Both reports are snapshots that run only when called. The canvas sidebar says "N changed since" once the
|
|
58
65
|
screens they cover have changed; run them again before relying on them.
|
|
59
66
|
- Give every approved screen a route, and every state a `prepare` module that puts the app into it (a sheet
|
|
@@ -67,10 +74,16 @@ Read the findings as:
|
|
|
67
74
|
| `extra` in compare | The app shows a component the design does not | Fine if it is a kept feature the design leaves out; otherwise design it first |
|
|
68
75
|
| `control-overlap` / `overflow` in compare only | The app draws a state or control the canvas never showed | Show it on the canvas, fix the component there, then re-run both |
|
|
69
76
|
| `look` differences | The component is styled differently in the app | The app is overriding the component: remove the override, or change the component |
|
|
77
|
+
| `stitch2 look` reports a size, weight or colour | The app draws the words in a style the screen does not | Find the element from the tag and classes it prints; use the component's or the type level's class, not a hard-coded value |
|
|
78
|
+
| `stitch2 geometry` reports `size` or `position` | A component is wider, narrower or shifted against the screen | A wrapper, padding or width in the app's markup; the component carries its own |
|
|
79
|
+
| `stitch2 geometry` reports `gap` or `arrangement` | The space between two things, or how they are arranged, is not the screen's | The app's spacing or wrapping around the component; at one width only, a breakpoint is missing |
|
|
80
|
+
| A difference on text that is data (a name, a count, a list in another order) | The mock-up and the app hold different data | Not a bug: make the route's `prepare` put the same data on screen, or read it as noise |
|
|
70
81
|
|
|
71
82
|
## Before you call it done
|
|
72
83
|
|
|
73
84
|
1. Every screen you implemented is approved, and `stitch2 screens --strict` passes: one current version each.
|
|
74
85
|
2. `stitch2 check --strict` passes on the screens you touched (archived screens excepted).
|
|
75
86
|
3. `stitch2 compare` has no errors for the screens you touched; warnings are explained (a kept feature) or fixed.
|
|
76
|
-
4.
|
|
87
|
+
4. `stitch2 look` and `stitch2 geometry` report nothing on the screens you touched, except differences you can
|
|
88
|
+
name as data.
|
|
89
|
+
5. The user has seen the canvas versions of anything that changed how a component looks.
|