justskill-design 0.0.1 → 3.2.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.en.md +235 -0
- package/README.md +184 -11
- package/SKILL.md +435 -0
- package/canvas/app.mjs +2870 -0
- package/canvas/groups.mjs +204 -0
- package/canvas/index.html +141 -0
- package/canvas/styles.css +1032 -0
- package/canvas/tree.mjs +249 -0
- package/cordis.patch.yml +8 -0
- package/design.mjs +172 -0
- package/docs/dsh-workbench.png +0 -0
- package/dsh/client.js +864 -0
- package/dsh/index.js +354 -0
- package/dsh/scan.js +139 -0
- package/dsh/skill.js +41 -0
- package/lib/base-css.mjs +92 -0
- package/lib/detect.mjs +123 -0
- package/lib/diagnostics.mjs +382 -0
- package/lib/export-html.mjs +309 -0
- package/lib/export-svg.mjs +111 -0
- package/lib/fragment.mjs +702 -0
- package/lib/geometry.mjs +827 -0
- package/lib/icons.mjs +106 -0
- package/lib/levenshtein.mjs +133 -0
- package/lib/node/cli.mjs +387 -0
- package/lib/node/export-svg.mjs +75 -0
- package/lib/node/fs.mjs +309 -0
- package/lib/node/op-json.mjs +75 -0
- package/lib/node/raster-run.mjs +115 -0
- package/lib/node/server.mjs +1264 -0
- package/lib/node/static.mjs +206 -0
- package/lib/node/verbs/add.mjs +220 -0
- package/lib/node/verbs/export.mjs +161 -0
- package/lib/node/verbs/format.mjs +106 -0
- package/lib/node/verbs/import.mjs +126 -0
- package/lib/node/verbs/ls.mjs +98 -0
- package/lib/node/verbs/migrate.mjs +236 -0
- package/lib/node/verbs/move.mjs +76 -0
- package/lib/node/verbs/new.mjs +284 -0
- package/lib/node/verbs/remove.mjs +41 -0
- package/lib/node/verbs/rename.mjs +87 -0
- package/lib/node/verbs/serve.mjs +164 -0
- package/lib/node/verbs/set.mjs +150 -0
- package/lib/node/verbs/show.mjs +122 -0
- package/lib/node/verbs/tokens.mjs +167 -0
- package/lib/node/verbs/validate.mjs +130 -0
- package/lib/node/write-verbs.mjs +215 -0
- package/lib/normalize.mjs +288 -0
- package/lib/ops.mjs +2063 -0
- package/lib/parse.mjs +997 -0
- package/lib/raster.mjs +186 -0
- package/lib/readers/pen.mjs +1684 -0
- package/lib/receipt.mjs +203 -0
- package/lib/render.mjs +503 -0
- package/lib/serialize.mjs +441 -0
- package/lib/spans.mjs +177 -0
- package/lib/svg-path.mjs +483 -0
- package/lib/table.mjs +1047 -0
- package/lib/tokens.mjs +295 -0
- package/lib/validate.mjs +929 -0
- package/package.json +68 -6
- package/references/cookbook.md +156 -0
- package/references/images.md +171 -0
- package/references/product-flow.md +408 -0
- package/references/schema.md +90 -0
- package/references/styling.md +181 -0
- package/scripts/install.mjs +95 -0
package/lib/geometry.mjs
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/geometry.mjs — certainty-only geometric checks.
|
|
3
|
+
*
|
|
4
|
+
* Pure ESM. No `node:` imports. Runs in the browser.
|
|
5
|
+
*
|
|
6
|
+
* There is no layout engine here, and that is deliberate. A second implementation
|
|
7
|
+
* of flexbox would disagree with the browser in the corners, and a check that
|
|
8
|
+
* cries wolf is worse than a check that stays quiet, because people stop reading
|
|
9
|
+
* it. So this module models only the part of flexbox that is arithmetic. Every
|
|
10
|
+
* quantity it cannot know is `UNKNOWN` and travels upward, and the checks that
|
|
11
|
+
* need it report nothing.
|
|
12
|
+
*
|
|
13
|
+
* What it cannot know: the width of a run of text, and the intrinsic size of an
|
|
14
|
+
* image or a glyph. A design with much text therefore produces few findings — by
|
|
15
|
+
* design, not by oversight.
|
|
16
|
+
*
|
|
17
|
+
* What it does not check: overflow along the **cross** axis. A 500px child in a
|
|
18
|
+
* 200px column is not reported here, because the arithmetic answers "does the
|
|
19
|
+
* main axis fit" and nothing more. The limit is inherited: `lib/geometry.mjs` in
|
|
20
|
+
* the version 1 tree checks the main axis only, in the same words. The measured
|
|
21
|
+
* pass in `canvas/app.mjs` does catch it, because it compares both axes of two
|
|
22
|
+
* real boxes; on the command line this one stays quiet about it, which is the
|
|
23
|
+
* direction to be wrong in.
|
|
24
|
+
*
|
|
25
|
+
* What it reads of `display`: only `none` and `flex`. `block` is a legal value
|
|
26
|
+
* in the property table, and block layout is not flexbox — a `flex-direction` on
|
|
27
|
+
* it is inert and `flex-shrink` does not apply to its children. A node whose
|
|
28
|
+
* `display` is neither `flex` nor absent is therefore given no flex semantics at
|
|
29
|
+
* all, rather than the wrong ones. See `laysOutWithFlex`.
|
|
30
|
+
*
|
|
31
|
+
* What it reads of `margin`: the main-axis pair, as part of the space a child
|
|
32
|
+
* needs. A child with `margin-left: 50px` in a row occupies 50px more of the row
|
|
33
|
+
* than its border box says, and leaving that out reported no overflow where a
|
|
34
|
+
* browser draws one. The cross-axis pair is still not read.
|
|
35
|
+
*
|
|
36
|
+
* `docs/format-v3.md` §6 gives this prefix five codes and the severity
|
|
37
|
+
* `warning`. A warning never blocks a write: `lib/validate.mjs` compares only
|
|
38
|
+
* the diagnostics whose severity is `error`.
|
|
39
|
+
*
|
|
40
|
+
* The findings carry no `offset`, because they speak about layout rather than
|
|
41
|
+
* about a position in the text. The diagnostic key is then
|
|
42
|
+
* `code|""|target|message`, which is why §6 says `target` is the one part of
|
|
43
|
+
* that key which cannot be dropped.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { getAttr, elementChildren, pagesOf, styleMapOf, nodePath } from "./parse.mjs";
|
|
47
|
+
import { expandBox, splitValueParts } from "./table.mjs";
|
|
48
|
+
import { resolveTokens } from "./tokens.mjs";
|
|
49
|
+
|
|
50
|
+
/** A quantity that needs a measurement to be known. */
|
|
51
|
+
const UNKNOWN = null;
|
|
52
|
+
|
|
53
|
+
const MAX_DEPTH = 64;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How many findings of one code a report carries.
|
|
57
|
+
*
|
|
58
|
+
* A design that uses one faint token for secondary text produces that finding
|
|
59
|
+
* once per text node — 92 of them on the reference file. All 92 are true, and
|
|
60
|
+
* 92 identical lines is how a report stops being read. The first few show the
|
|
61
|
+
* pattern and the summary already counts the rest.
|
|
62
|
+
*
|
|
63
|
+
* Exported so `lib/validate.mjs` bounds the contrast findings by the same rule
|
|
64
|
+
* rather than by a second copy of it.
|
|
65
|
+
*/
|
|
66
|
+
export const MAX_REPORTED_PER_CODE = 20;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A sub-pixel overhang is rounding, not a defect. One pixel is the smallest
|
|
70
|
+
* amount a reader could act on.
|
|
71
|
+
*/
|
|
72
|
+
export const MEASURED_TOLERANCE_PX = 1;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Values of `overflow` that hide their content in a static deliverable.
|
|
76
|
+
*
|
|
77
|
+
* `auto` and `scroll` give a browser a scroll affordance, so the content is
|
|
78
|
+
* reachable on screen. The deliverable is a still, and there the content is
|
|
79
|
+
* gone. That difference is exactly what this format refuses to leave silent.
|
|
80
|
+
*/
|
|
81
|
+
const CLIPPING_OVERFLOW = new Set(["hidden", "auto", "scroll"]);
|
|
82
|
+
|
|
83
|
+
/** The `align-items` of a container. CSS stretches by default. */
|
|
84
|
+
function alignItemsOf(style) {
|
|
85
|
+
return style.get("align-items") || "stretch";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* True when the parent takes this node out of layout.
|
|
90
|
+
*
|
|
91
|
+
* `data-hidden="true"` is the format's own way to say this, and `docs/format-v3.md`
|
|
92
|
+
* defines it as equivalent to `display:none`. Reading only the first of the two
|
|
93
|
+
* meant a node the browser never draws was still counted against its parent: a
|
|
94
|
+
* hidden 400px child in a 100px row reported an overflow that cannot happen.
|
|
95
|
+
*/
|
|
96
|
+
function isHidden(node, style) {
|
|
97
|
+
if (getAttr(node, "data-hidden") === "true") return true;
|
|
98
|
+
return style.get("display") === "none";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** True when a node's `display` gives its children flex layout. */
|
|
102
|
+
function laysOutWithFlex(style) {
|
|
103
|
+
const display = style.get("display");
|
|
104
|
+
return display === undefined || display === "flex";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The children that take part in this node's layout.
|
|
109
|
+
*
|
|
110
|
+
* A hidden child is not laid out. An absolutely positioned child is out of flow,
|
|
111
|
+
* so it takes no space in its parent and is checked separately by
|
|
112
|
+
* `checkAbsolute`. Both rules were applied in two places and drifted apart from
|
|
113
|
+
* `measure`, which is why they live here now.
|
|
114
|
+
*/
|
|
115
|
+
function layoutChildren(node) {
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const child of elementChildren(node)) {
|
|
118
|
+
const style = styleMapOf(child);
|
|
119
|
+
if (isHidden(child, style)) continue;
|
|
120
|
+
if (style.get("position") === "absolute") continue;
|
|
121
|
+
out.push(child);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The content box an absolutely positioned descendant resolves against.
|
|
128
|
+
*
|
|
129
|
+
* The rule is CSS's: the nearest ancestor that is positioned. The page is added
|
|
130
|
+
* to it because a page is the surface the deliverable draws, and
|
|
131
|
+
* `docs/format-v3.md` forbids positioning one (`structure/page-position`) — so a
|
|
132
|
+
* page can never become a containing block by being positioned, and an absolutely
|
|
133
|
+
* positioned box with no positioned ancestor would otherwise have no base at all.
|
|
134
|
+
*
|
|
135
|
+
* A static wrapper that is not a page does not become a base, which is the case
|
|
136
|
+
* that used to be got wrong: with a relative grandparent, the browser lays the box
|
|
137
|
+
* out against the grandparent, not against the static parent in between.
|
|
138
|
+
*/
|
|
139
|
+
function containingBoxOf(node, style, box, inherited) {
|
|
140
|
+
const position = style.get("position");
|
|
141
|
+
if (position === "relative" || position === "absolute") return box.content;
|
|
142
|
+
// A transform or a backdrop filter makes the element a containing block for
|
|
143
|
+
// its absolutely positioned descendants even when it is static (CSS Transforms
|
|
144
|
+
// §3, Filter Effects §6). Missing this measured an absolutely positioned card
|
|
145
|
+
// inside a tilted card against the page instead of against the card.
|
|
146
|
+
for (const property of ["transform", "backdrop-filter"]) {
|
|
147
|
+
const value = style.get(property);
|
|
148
|
+
if (value !== undefined && value !== "none") return box.content;
|
|
149
|
+
}
|
|
150
|
+
if (getAttr(node, "data-type") === "page") return box.content;
|
|
151
|
+
return inherited;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The context a node is measured and checked against.
|
|
156
|
+
*
|
|
157
|
+
* `containing` is the box an absolutely positioned descendant resolves `left` and
|
|
158
|
+
* `top` against. It is not the same box as `content`, and a node that is not a
|
|
159
|
+
* base passes its own ancestor's value down unchanged.
|
|
160
|
+
*
|
|
161
|
+
* `alignItems` belongs here because a child that declares no cross size is
|
|
162
|
+
* stretched only when its parent says so. The default is `stretch`, and
|
|
163
|
+
* `align-items: flex-start` — which the pen reader writes on every row it
|
|
164
|
+
* imports, 297 times in the reference file — turns it off. Reading it from the
|
|
165
|
+
* child's own `align-self` alone overstated the cross size of every such child,
|
|
166
|
+
* which made the checks quieter than they should be.
|
|
167
|
+
*/
|
|
168
|
+
function contextOf(node, box, containing) {
|
|
169
|
+
const style = styleMapOf(node);
|
|
170
|
+
return {
|
|
171
|
+
content: box.content,
|
|
172
|
+
row: style.get("flex-direction") === "row",
|
|
173
|
+
alignItems: alignItemsOf(style),
|
|
174
|
+
containing: containingBoxOf(node, style, box, containing),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Types whose size comes from their own declarations, or not at all. */
|
|
179
|
+
const MEASURABLE = new Set(["page", "frame"]);
|
|
180
|
+
|
|
181
|
+
// -- values ------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A resolved length in pixels, or `UNKNOWN`.
|
|
185
|
+
*
|
|
186
|
+
* `basis` is the containing size that a percentage is measured against. A
|
|
187
|
+
* percentage with no basis is not knowable.
|
|
188
|
+
*/
|
|
189
|
+
function toPx(value, basis) {
|
|
190
|
+
if (value === UNKNOWN || value === undefined || value === null) return UNKNOWN;
|
|
191
|
+
const raw = String(value).trim();
|
|
192
|
+
if (raw === "" || raw === "auto") return UNKNOWN;
|
|
193
|
+
const match = /^(-?\d+(?:\.\d+)?)(px|%)?$/.exec(raw);
|
|
194
|
+
if (!match) return UNKNOWN;
|
|
195
|
+
const number = Number(match[1]);
|
|
196
|
+
const unit = match[2] || "px";
|
|
197
|
+
if (unit === "px") return number;
|
|
198
|
+
if (unit === "%") return typeof basis === "number" ? (number / 100) * basis : UNKNOWN;
|
|
199
|
+
return UNKNOWN;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Resolve the token chain, then read the result as a length. */
|
|
203
|
+
function lengthOf(value, tokens, basis) {
|
|
204
|
+
if (value === undefined) return UNKNOWN;
|
|
205
|
+
return toPx(resolveTokens(String(value), tokens), basis);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The angle a `transform: rotate(...)` asks for, in degrees. 0 when there is none. */
|
|
209
|
+
const ROTATE_DEGREES = /rotate\(\s*(-?\d+(?:\.\d+)?)\s*deg\s*\)/i;
|
|
210
|
+
|
|
211
|
+
function rotationOf(style, tokens) {
|
|
212
|
+
const raw = style.get("transform");
|
|
213
|
+
if (raw === undefined) return 0;
|
|
214
|
+
const match = ROTATE_DEGREES.exec(resolveTokens(String(raw), tokens));
|
|
215
|
+
return match ? Number(match[1]) : 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The four values of a box property, resolved, or `UNKNOWN`. */
|
|
219
|
+
function boxOf(raw, tokens, basisW, basisH) {
|
|
220
|
+
const parts = expandBox(splitValueParts(resolveTokens(String(raw), tokens)));
|
|
221
|
+
const top = toPx(parts[0], basisH);
|
|
222
|
+
const right = toPx(parts[1], basisW);
|
|
223
|
+
const bottom = toPx(parts[2], basisH);
|
|
224
|
+
const left = toPx(parts[3], basisW);
|
|
225
|
+
if ([top, right, bottom, left].some((value) => value === UNKNOWN)) return UNKNOWN;
|
|
226
|
+
return { top, right, bottom, left };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Padding plus border, as the inset that separates the border box from the
|
|
231
|
+
* content box.
|
|
232
|
+
*/
|
|
233
|
+
function insetOf(style, tokens, basisW, basisH) {
|
|
234
|
+
let padX = 0;
|
|
235
|
+
let padY = 0;
|
|
236
|
+
let borderX = 0;
|
|
237
|
+
let borderY = 0;
|
|
238
|
+
|
|
239
|
+
const padding = style.get("padding");
|
|
240
|
+
if (padding !== undefined) {
|
|
241
|
+
const box = boxOf(padding, tokens, basisW, basisH);
|
|
242
|
+
if (box === UNKNOWN) return UNKNOWN;
|
|
243
|
+
padX = box.left + box.right;
|
|
244
|
+
padY = box.top + box.bottom;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const borderWidth = style.get("border-width");
|
|
248
|
+
if (borderWidth !== undefined) {
|
|
249
|
+
const box = boxOf(borderWidth, tokens, basisW, basisH);
|
|
250
|
+
if (box === UNKNOWN) return UNKNOWN;
|
|
251
|
+
borderX = box.left + box.right;
|
|
252
|
+
borderY = box.top + box.bottom;
|
|
253
|
+
} else {
|
|
254
|
+
const border = style.get("border");
|
|
255
|
+
if (border !== undefined) {
|
|
256
|
+
// `1px solid #e5e7eb` — the length is the width on every side.
|
|
257
|
+
const parts = splitValueParts(resolveTokens(String(border), tokens));
|
|
258
|
+
const width = parts.map((part) => toPx(part, UNKNOWN)).find((value) => value !== UNKNOWN);
|
|
259
|
+
if (width === undefined) return UNKNOWN;
|
|
260
|
+
borderX = width * 2;
|
|
261
|
+
borderY = width * 2;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { padX, padY, borderX, borderY, x: padX + borderX, y: padY + borderY };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The two gaps, in CSS order: row gap first. `gap` alone sets both. */
|
|
269
|
+
function gapsOf(style, tokens) {
|
|
270
|
+
const raw = style.get("gap");
|
|
271
|
+
if (raw === undefined) return { row: 0, column: 0 };
|
|
272
|
+
const parts = splitValueParts(resolveTokens(String(raw), tokens));
|
|
273
|
+
const row = toPx(parts[0], UNKNOWN);
|
|
274
|
+
const column = parts.length > 1 ? toPx(parts[1], UNKNOWN) : row;
|
|
275
|
+
if (row === UNKNOWN || column === UNKNOWN) return UNKNOWN;
|
|
276
|
+
return { row, column };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The margin a child adds to the main axis, in CSS order: leading then trailing.
|
|
281
|
+
*
|
|
282
|
+
* `margin` is a formal member of the property table — `docs/format-v3.md` §6
|
|
283
|
+
* keeps it because pen's export nudges a focus ring with `margin:-0.5px 0 0 0`,
|
|
284
|
+
* and the eval snippets space a card with `margin:16px 0`. A margin is outside
|
|
285
|
+
* the border box, so a child that reserves 50px to its left needs 50px more of
|
|
286
|
+
* the row than its own width says. Leaving it out lost a real overflow: two
|
|
287
|
+
* 150px children in a 300px row report nothing, and adding `margin-left:50px` to
|
|
288
|
+
* the second one makes the browser scroll to 350px.
|
|
289
|
+
*
|
|
290
|
+
* A percentage margin resolves against the containing block's **width** on every
|
|
291
|
+
* side, including top and bottom — not against the height. Verified against the
|
|
292
|
+
* browser, and the reason `basisW` is the only basis here.
|
|
293
|
+
*
|
|
294
|
+
* `auto` is UNKNOWN: an auto margin absorbs whatever space is free, so it has no
|
|
295
|
+
* size to add. Negative values are legal and keep their sign.
|
|
296
|
+
*
|
|
297
|
+
* Only the shorthand is read, because only the shorthand is allowed: `margin-top`
|
|
298
|
+
* and its three siblings are in `REJECTED_PROPERTIES`. A document that uses one
|
|
299
|
+
* really does overflow in a browser, and this module says nothing about it —
|
|
300
|
+
* `style/rejected-property` is the finding that document earns.
|
|
301
|
+
*/
|
|
302
|
+
function mainMarginsOf(style, tokens, row, basisW) {
|
|
303
|
+
const raw = style.get("margin");
|
|
304
|
+
if (raw === undefined) return { before: 0, after: 0 };
|
|
305
|
+
const parts = expandBox(splitValueParts(resolveTokens(String(raw), tokens)));
|
|
306
|
+
const before = toPx(row ? parts[3] : parts[0], basisW);
|
|
307
|
+
const after = toPx(row ? parts[1] : parts[2], basisW);
|
|
308
|
+
if (before === UNKNOWN || after === UNKNOWN) return UNKNOWN;
|
|
309
|
+
return { before, after };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** True when the node grows along the parent's main axis. */
|
|
313
|
+
function growsAlongMain(style) {
|
|
314
|
+
const flex = style.get("flex");
|
|
315
|
+
if (flex === undefined) return false;
|
|
316
|
+
const parts = splitValueParts(String(flex));
|
|
317
|
+
const grow = Number(parts[0]);
|
|
318
|
+
return Number.isFinite(grow) && grow > 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* True when the node may shrink along the parent's main axis.
|
|
323
|
+
*
|
|
324
|
+
* This is the rule that keeps the checks honest. A flex item shrinks by default,
|
|
325
|
+
* so three `width:100%` children in a row do not overflow — they shrink to a
|
|
326
|
+
* third each. Only a child that refuses to shrink has a size the container
|
|
327
|
+
* cannot absorb, and only that size can be counted against the frame.
|
|
328
|
+
*
|
|
329
|
+
* Without this, a tabs strip of three full-width children reported 981px of
|
|
330
|
+
* content in a 327px frame. The report was certain, and it was wrong.
|
|
331
|
+
*/
|
|
332
|
+
function shrinksAlongMain(style, tokens) {
|
|
333
|
+
const explicit = style.get("flex-shrink");
|
|
334
|
+
if (explicit !== undefined) {
|
|
335
|
+
const value = toPx(resolveTokens(String(explicit), tokens), UNKNOWN);
|
|
336
|
+
return value !== 0;
|
|
337
|
+
}
|
|
338
|
+
const flex = style.get("flex");
|
|
339
|
+
if (flex !== undefined) {
|
|
340
|
+
const parts = splitValueParts(resolveTokens(String(flex), tokens));
|
|
341
|
+
const shrink = Number(parts[1]);
|
|
342
|
+
if (Number.isFinite(shrink)) return shrink !== 0;
|
|
343
|
+
}
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// -- the measurement ---------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The border box and the content box of one node, in pixels.
|
|
351
|
+
*
|
|
352
|
+
* An axis that hugs its content is computed from the children. An axis that is
|
|
353
|
+
* `UNKNOWN` stays unknown and suppresses every check that depends on it.
|
|
354
|
+
*
|
|
355
|
+
* @param {object} node a parsed element
|
|
356
|
+
* @param {{ content: { w: number|null, h: number|null }, row: boolean }} parent
|
|
357
|
+
* @param {Map} tokens name to `{ value }`
|
|
358
|
+
* @param {number} depth
|
|
359
|
+
*/
|
|
360
|
+
function measure(node, parent, tokens, depth) {
|
|
361
|
+
const unknown = { w: UNKNOWN, h: UNKNOWN, content: { w: UNKNOWN, h: UNKNOWN } };
|
|
362
|
+
if (!node || depth > MAX_DEPTH) return unknown;
|
|
363
|
+
|
|
364
|
+
const style = styleMapOf(node);
|
|
365
|
+
const parentContent = parent?.content ?? { w: UNKNOWN, h: UNKNOWN };
|
|
366
|
+
const parentIsRow = parent?.row === true;
|
|
367
|
+
|
|
368
|
+
const inset = insetOf(style, tokens, parentContent.w, parentContent.h);
|
|
369
|
+
if (inset === UNKNOWN) return unknown;
|
|
370
|
+
|
|
371
|
+
const type = getAttr(node, "data-type") || "frame";
|
|
372
|
+
const measurable = MEASURABLE.has(type);
|
|
373
|
+
const flexContainer = laysOutWithFlex(style);
|
|
374
|
+
const positioned = style.get("position") === "absolute";
|
|
375
|
+
|
|
376
|
+
const children = layoutChildren(node);
|
|
377
|
+
|
|
378
|
+
const gaps = gapsOf(style, tokens);
|
|
379
|
+
const gapCount = Math.max(0, children.length - 1);
|
|
380
|
+
|
|
381
|
+
const flexDirection = style.get("flex-direction");
|
|
382
|
+
const row = flexDirection ? flexDirection === "row" : false;
|
|
383
|
+
const nodeAlign = alignItemsOf(style);
|
|
384
|
+
const alignSelf = style.get("align-self");
|
|
385
|
+
const parentAligns = (parent?.alignItems ?? "stretch") === "stretch";
|
|
386
|
+
// An absolutely positioned box is out of flow: `align-items` and `align-self`
|
|
387
|
+
// do not reach it, so it never stretches. Letting it stretch read the parent's
|
|
388
|
+
// content width as its own — 300px for an empty box the browser draws at 0.
|
|
389
|
+
const stretches =
|
|
390
|
+
!positioned && (alignSelf === "stretch" || (alignSelf === undefined && parentAligns && measurable));
|
|
391
|
+
// `flex` does not apply to an out-of-flow box either.
|
|
392
|
+
const grows = growsAlongMain(style) && !positioned;
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The stated size of one axis.
|
|
396
|
+
*
|
|
397
|
+
* `undefined` means "this axis hugs its content". `UNKNOWN` means the value
|
|
398
|
+
* exists but cannot be read.
|
|
399
|
+
*/
|
|
400
|
+
const stated = (property, basis, isMainAxis) => {
|
|
401
|
+
const raw = style.get(property);
|
|
402
|
+
if (raw === undefined) return undefined;
|
|
403
|
+
const text = resolveTokens(String(raw), tokens);
|
|
404
|
+
if (text === "fit-content" || text === "max-content" || text === "min-content") return undefined;
|
|
405
|
+
if (text === "100%" && isMainAxis && growsAlongMain(style)) return basis;
|
|
406
|
+
const value = toPx(text, basis);
|
|
407
|
+
return value === UNKNOWN ? UNKNOWN : value;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const minW = lengthOf(style.get("min-width"), tokens, parentContent.w);
|
|
411
|
+
const maxW = lengthOf(style.get("max-width"), tokens, parentContent.w);
|
|
412
|
+
const minH = lengthOf(style.get("min-height"), tokens, parentContent.h);
|
|
413
|
+
const maxH = lengthOf(style.get("max-height"), tokens, parentContent.h);
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The stated size of one axis, after its minimum and maximum.
|
|
417
|
+
*
|
|
418
|
+
* CSS applies the maximum first and the minimum second, so a minimum wins over
|
|
419
|
+
* a maximum. That is the same order `checkAxisConflict` describes in its hint,
|
|
420
|
+
* and the reverse of what this function used to do: for
|
|
421
|
+
* `width:100px;min-width:120px;max-width:80px` it answered 80 where the browser
|
|
422
|
+
* draws 120. The module reported the conflict correctly and then measured the
|
|
423
|
+
* losing side of it.
|
|
424
|
+
*/
|
|
425
|
+
const clamp = (value, min, max) => {
|
|
426
|
+
if (value === UNKNOWN) return UNKNOWN;
|
|
427
|
+
let out = value;
|
|
428
|
+
if (typeof max === "number") out = Math.min(out, max);
|
|
429
|
+
if (typeof min === "number") out = Math.max(out, min);
|
|
430
|
+
return out;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// Width is the main axis exactly when the parent lays its children in a row.
|
|
434
|
+
const fillW = parentIsRow ? grows : stretches;
|
|
435
|
+
const fillH = parentIsRow ? stretches : grows;
|
|
436
|
+
|
|
437
|
+
let ownW = stated("width", parentContent.w, parentIsRow);
|
|
438
|
+
let ownH = stated("height", parentContent.h, !parentIsRow);
|
|
439
|
+
if (ownW === undefined && (fillW || measurable === false)) {
|
|
440
|
+
ownW = fillW ? parentContent.w : UNKNOWN;
|
|
441
|
+
}
|
|
442
|
+
if (ownH === undefined && (fillH || measurable === false)) {
|
|
443
|
+
ownH = fillH ? parentContent.h : UNKNOWN;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const known = (value) => (typeof value === "number" ? value : UNKNOWN);
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Whether this node's axes can be hugged at all.
|
|
450
|
+
*
|
|
451
|
+
* A node that does not lay out with flex is not sized by the flex algorithm, so
|
|
452
|
+
* its content-derived size is a block-layout question this module does not
|
|
453
|
+
* answer. A stated `width` or `height` on the same node is still read — only the
|
|
454
|
+
* hug is withdrawn.
|
|
455
|
+
*/
|
|
456
|
+
const hugIsModelled = measurable && flexContainer;
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* `flex-wrap: wrap` makes the cross size a measurement.
|
|
460
|
+
*
|
|
461
|
+
* The cross size is the number of lines, and the number of lines depends on the
|
|
462
|
+
* available main size, which is not a property of this node. Two 200x60 children
|
|
463
|
+
* in a wrapping row 300px wide are two lines and 120px tall; the same row with no
|
|
464
|
+
* stated width is one line and 60px. Summing them reports 60 for the first and
|
|
465
|
+
* misses a real clip — so the cross axis is UNKNOWN instead.
|
|
466
|
+
*
|
|
467
|
+
* The main axis is unaffected: a container that hugs its main axis is exactly as
|
|
468
|
+
* long as its children need, so nothing wraps and the sum is right.
|
|
469
|
+
*/
|
|
470
|
+
const wraps = style.get("flex-wrap") === "wrap";
|
|
471
|
+
const crossAxisIsMeasured = wraps;
|
|
472
|
+
|
|
473
|
+
const hugW = () => {
|
|
474
|
+
if (!hugIsModelled) return UNKNOWN;
|
|
475
|
+
if (crossAxisIsMeasured && !row) return UNKNOWN;
|
|
476
|
+
let total = 0;
|
|
477
|
+
for (const child of children) {
|
|
478
|
+
const box = measure(child, { content: { w: UNKNOWN, h: known(ownH) }, row, alignItems: nodeAlign }, tokens, depth + 1);
|
|
479
|
+
if (box.w === UNKNOWN) return UNKNOWN;
|
|
480
|
+
total = row ? total + box.w : Math.max(total, box.w);
|
|
481
|
+
}
|
|
482
|
+
if (!row) return total + inset.x;
|
|
483
|
+
if (gaps === UNKNOWN) return UNKNOWN;
|
|
484
|
+
return total + gaps.column * gapCount + inset.x;
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const hugH = () => {
|
|
488
|
+
if (!hugIsModelled) return UNKNOWN;
|
|
489
|
+
if (crossAxisIsMeasured && row) return UNKNOWN;
|
|
490
|
+
let total = 0;
|
|
491
|
+
for (const child of children) {
|
|
492
|
+
const box = measure(child, { content: { w: known(ownW), h: UNKNOWN }, row, alignItems: nodeAlign }, tokens, depth + 1);
|
|
493
|
+
if (box.h === UNKNOWN) return UNKNOWN;
|
|
494
|
+
total = row ? Math.max(total, box.h) : total + box.h;
|
|
495
|
+
}
|
|
496
|
+
if (row) return total + inset.y;
|
|
497
|
+
if (gaps === UNKNOWN) return UNKNOWN;
|
|
498
|
+
return total + gaps.row * gapCount + inset.y;
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// The cross axis comes first: measuring the children needs it, and it never
|
|
502
|
+
// depends on the main axis. On a row the cross axis is the height.
|
|
503
|
+
if (row) {
|
|
504
|
+
if (ownH === undefined) ownH = clamp(hugH(), minH, maxH);
|
|
505
|
+
if (ownW === undefined) ownW = clamp(hugW(), minW, maxW);
|
|
506
|
+
} else {
|
|
507
|
+
if (ownW === undefined) ownW = clamp(hugW(), minW, maxW);
|
|
508
|
+
if (ownH === undefined) ownH = clamp(hugH(), minH, maxH);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
ownW = typeof ownW === "number" ? clamp(ownW, minW, maxW) : UNKNOWN;
|
|
512
|
+
ownH = typeof ownH === "number" ? clamp(ownH, minH, maxH) : UNKNOWN;
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
w: ownW,
|
|
516
|
+
h: ownH,
|
|
517
|
+
content: {
|
|
518
|
+
w: ownW === UNKNOWN ? UNKNOWN : Math.max(0, ownW - inset.x),
|
|
519
|
+
h: ownH === UNKNOWN ? UNKNOWN : Math.max(0, ownH - inset.y),
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// -- the checks --------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
function finding(code, node, message, hint) {
|
|
527
|
+
return {
|
|
528
|
+
severity: "warning",
|
|
529
|
+
code,
|
|
530
|
+
target: getAttr(node, "data-id") || undefined,
|
|
531
|
+
path: nodePath(node),
|
|
532
|
+
message,
|
|
533
|
+
hint,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function checkAxisConflict(node, style, tokens, parentContent, findings) {
|
|
538
|
+
const pairs = [
|
|
539
|
+
["min-width", "max-width", parentContent.w],
|
|
540
|
+
["min-height", "max-height", parentContent.h],
|
|
541
|
+
];
|
|
542
|
+
for (const [minName, maxName, basis] of pairs) {
|
|
543
|
+
const min = lengthOf(style.get(minName), tokens, basis);
|
|
544
|
+
const max = lengthOf(style.get(maxName), tokens, basis);
|
|
545
|
+
if (typeof min !== "number" || typeof max !== "number" || min <= max) continue;
|
|
546
|
+
findings.push(
|
|
547
|
+
finding(
|
|
548
|
+
"geometry/axis-conflict",
|
|
549
|
+
node,
|
|
550
|
+
`${minName} (${min}px) is greater than ${maxName} (${max}px), so ${maxName} never applies`,
|
|
551
|
+
`CSS resolves a conflicting minimum and maximum in favour of the minimum, so the ${maxName} of ${max}px is unreachable`,
|
|
552
|
+
),
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Report the main axis not fitting. The code says whether the spill is visible.
|
|
559
|
+
*
|
|
560
|
+
* `needed` is the space the children that cannot give way already require, and
|
|
561
|
+
* `content` is what the box has.
|
|
562
|
+
*/
|
|
563
|
+
function reportMainOverflow(node, style, content, needed, findings) {
|
|
564
|
+
const clipped = CLIPPING_OVERFLOW.has(style.get("overflow"));
|
|
565
|
+
findings.push(
|
|
566
|
+
finding(
|
|
567
|
+
clipped ? "geometry/clipped" : "geometry/overflow",
|
|
568
|
+
node,
|
|
569
|
+
clipped
|
|
570
|
+
? `The children that cannot shrink need ${Math.round(needed)}px on the main axis but the frame has ${Math.round(content)}px and hides the rest, so the last of them is cut off`
|
|
571
|
+
: `The children that cannot shrink need ${Math.round(needed)}px on the main axis but the frame only has ${Math.round(content)}px`,
|
|
572
|
+
clipped
|
|
573
|
+
? "raise the frame's size, or change overflow to visible if the spill is intentional"
|
|
574
|
+
: "widen the frame, shrink the children, or let the frame hug its content",
|
|
575
|
+
),
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function checkMainAxis(node, box, style, tokens, children, findings) {
|
|
580
|
+
// Block layout is not flexbox: `flex-direction` is inert on it and its children
|
|
581
|
+
// do not shrink. Running the flex arithmetic here reported an overflow for two
|
|
582
|
+
// 80px children in a `display:block;flex-direction:row` box, which the browser
|
|
583
|
+
// stacks vertically and draws without overflowing. Rather than model block
|
|
584
|
+
// layout — a second layout engine, and the thing this module refuses to be —
|
|
585
|
+
// a node that does not lay out with flex is not checked on either axis.
|
|
586
|
+
if (!laysOutWithFlex(style)) return;
|
|
587
|
+
|
|
588
|
+
const flexDirection = style.get("flex-direction");
|
|
589
|
+
const row = flexDirection === "row";
|
|
590
|
+
const content = row ? box.content.w : box.content.h;
|
|
591
|
+
if (content === UNKNOWN || children.length === 0) return;
|
|
592
|
+
|
|
593
|
+
const gaps = gapsOf(style, tokens);
|
|
594
|
+
if (gaps === UNKNOWN) return;
|
|
595
|
+
const gap = row ? gaps.column : gaps.row;
|
|
596
|
+
const gapCount = Math.max(0, children.length - 1);
|
|
597
|
+
|
|
598
|
+
const sizes = [];
|
|
599
|
+
for (const child of children) {
|
|
600
|
+
const childStyle = styleMapOf(child);
|
|
601
|
+
const childBox = measure(child, { content: box.content, row, alignItems: alignItemsOf(style) }, tokens, 0);
|
|
602
|
+
const main = row ? childBox.w : childBox.h;
|
|
603
|
+
if (main === UNKNOWN) return;
|
|
604
|
+
// A margin sits outside the border box, so the space a child takes is its box
|
|
605
|
+
// plus its leading and trailing margins.
|
|
606
|
+
const margins = mainMarginsOf(childStyle, tokens, row, box.content.w);
|
|
607
|
+
if (margins === UNKNOWN) return;
|
|
608
|
+
sizes.push({
|
|
609
|
+
main: main + margins.before + margins.after,
|
|
610
|
+
grows: growsAlongMain(childStyle),
|
|
611
|
+
shrinks: shrinksAlongMain(childStyle, tokens),
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const gapTotal = gap * gapCount;
|
|
616
|
+
|
|
617
|
+
// A wrapping container puts its children on more than one line when their sum
|
|
618
|
+
// is too large, so their sum is not the size of any line and cannot exceed
|
|
619
|
+
// anything. Two 200px children in a wrapping 300px row wrap instead of
|
|
620
|
+
// overflowing, and adding them up reports an overflow the browser never draws.
|
|
621
|
+
//
|
|
622
|
+
// What wrapping cannot fix is one child that is wider than a line. There is no
|
|
623
|
+
// other line for it to go to, so the browser overflows: a single 400px child in
|
|
624
|
+
// a wrapping 100px row scrolls to 400. Only that case is reported here.
|
|
625
|
+
const wraps = style.get("flex-wrap") === "wrap";
|
|
626
|
+
if (wraps) {
|
|
627
|
+
const candidates = sizes.filter((entry) => !entry.grows);
|
|
628
|
+
if (candidates.length === 0) return;
|
|
629
|
+
const worst = candidates.reduce((a, b) => (b.main > a.main ? b : a));
|
|
630
|
+
if (worst.main <= content) return;
|
|
631
|
+
const clipped = CLIPPING_OVERFLOW.has(style.get("overflow"));
|
|
632
|
+
findings.push(
|
|
633
|
+
finding(
|
|
634
|
+
clipped ? "geometry/clipped" : "geometry/overflow",
|
|
635
|
+
node,
|
|
636
|
+
clipped
|
|
637
|
+
? `A child needs ${Math.round(worst.main)}px on the main axis but the frame has ${Math.round(content)}px and hides the rest; a child that is wider than its line has nowhere to wrap to, so the end of it is cut off`
|
|
638
|
+
: `A child needs ${Math.round(worst.main)}px on the main axis but the frame has ${Math.round(content)}px; a child that is wider than its line has nowhere to wrap to, so it spills`,
|
|
639
|
+
"give the child a smaller size, or let the frame hug its content",
|
|
640
|
+
),
|
|
641
|
+
);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const fillers = sizes.filter((entry) => entry.grows);
|
|
646
|
+
const others = sizes.filter((entry) => !entry.grows);
|
|
647
|
+
|
|
648
|
+
if (fillers.length > 0) {
|
|
649
|
+
// A filling child has a flex basis of zero, so it has nothing to shrink and
|
|
650
|
+
// it takes exactly the space the others leave. When they leave none, it gets
|
|
651
|
+
// none — whether or not they shrink, because shrinking them only returns the
|
|
652
|
+
// overrun, never more.
|
|
653
|
+
const demanded = others.reduce((sum, entry) => sum + entry.main, 0) + gapTotal;
|
|
654
|
+
if (content - demanded > 0) return;
|
|
655
|
+
const fixed = others.reduce((sum, entry) => sum + entry.main, 0);
|
|
656
|
+
findings.push(
|
|
657
|
+
finding(
|
|
658
|
+
"geometry/fill-collapsed",
|
|
659
|
+
node,
|
|
660
|
+
`No space is left on the main axis: the children that do not fill already need ${Math.round(fixed)}px of ${Math.round(content)}px, so the filling child gets nothing`,
|
|
661
|
+
`reduce the fixed ${row ? "width" : "height"} of the siblings, or let one of them hug its content`,
|
|
662
|
+
),
|
|
663
|
+
);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Without a filler, the frame overflows only when the children that cannot
|
|
668
|
+
// shrink already need more than it has. Every other child gives way, so
|
|
669
|
+
// counting them would report a layout the browser never produces.
|
|
670
|
+
const fixed = others.filter((entry) => !entry.shrinks);
|
|
671
|
+
if (fixed.length === 0) return;
|
|
672
|
+
const irreducible = fixed.reduce((sum, entry) => sum + entry.main, 0) + gapTotal;
|
|
673
|
+
if (irreducible <= content) return;
|
|
674
|
+
|
|
675
|
+
reportMainOverflow(node, style, content, irreducible, findings);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* An absolutely positioned box against the box it is actually positioned in.
|
|
680
|
+
*
|
|
681
|
+
* The base is the nearest ancestor that is **positioned**, not the parent. When
|
|
682
|
+
* the parent is `static` and a grandparent is `position:relative`, the browser
|
|
683
|
+
* lays the box out against the grandparent; comparing it with the static parent
|
|
684
|
+
* reported a box 150px below a 50px-tall parent as leaving it, when the box was
|
|
685
|
+
* sitting inside the grandparent exactly where it was asked to be.
|
|
686
|
+
*
|
|
687
|
+
* No positioned ancestor means the initial containing block, whose size is not in
|
|
688
|
+
* the file. That is a measurement, so the check reports nothing.
|
|
689
|
+
*/
|
|
690
|
+
function checkAbsolute(node, style, tokens, containing, findings, rotation) {
|
|
691
|
+
if (style.get("position") !== "absolute") return;
|
|
692
|
+
if (containing.w === UNKNOWN || containing.h === UNKNOWN) return;
|
|
693
|
+
// A rotated box no longer occupies its layout rectangle. Measuring its
|
|
694
|
+
// unrotated corners against the containing block would report clips that are
|
|
695
|
+
// not there and miss the ones that are, and this pass has no way to say which
|
|
696
|
+
// — so it says nothing. The canvas measures the painted box in a browser and
|
|
697
|
+
// makes the same abstention in `measuredGeometryDiagnostics`.
|
|
698
|
+
if (rotation !== 0) return;
|
|
699
|
+
|
|
700
|
+
const width = lengthOf(style.get("width"), tokens, containing.w);
|
|
701
|
+
const height = lengthOf(style.get("height"), tokens, containing.h);
|
|
702
|
+
const left = lengthOf(style.get("left"), tokens, containing.w);
|
|
703
|
+
const top = lengthOf(style.get("top"), tokens, containing.h);
|
|
704
|
+
if ([width, height, left, top].some((value) => typeof value !== "number")) return;
|
|
705
|
+
|
|
706
|
+
const right = left + width;
|
|
707
|
+
const bottom = top + height;
|
|
708
|
+
if (left >= 0 && top >= 0 && right <= containing.w && bottom <= containing.h) return;
|
|
709
|
+
|
|
710
|
+
findings.push(
|
|
711
|
+
finding(
|
|
712
|
+
"geometry/absolute-outside",
|
|
713
|
+
node,
|
|
714
|
+
`The box at (${Math.round(left)}, ${Math.round(top)}) sized ${Math.round(width)}x${Math.round(height)} is not fully inside the containing block of ${Math.round(containing.w)}x${Math.round(containing.h)}`,
|
|
715
|
+
"an absolutely positioned node is clipped by an ancestor that clips, so part of it may be invisible",
|
|
716
|
+
),
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function walk(node, parent, tokens, findings, depth, inheritedRotation = 0) {
|
|
721
|
+
if (!node || depth > MAX_DEPTH) return;
|
|
722
|
+
const style = styleMapOf(node);
|
|
723
|
+
// Rotations compose by addition because `transform` in this format is one
|
|
724
|
+
// `rotate()` and no translate or scale. A page cannot be rotated
|
|
725
|
+
// (`style/transform-on-page`), so the chain starts at 0 on every page.
|
|
726
|
+
const rotation = inheritedRotation + rotationOf(style, tokens);
|
|
727
|
+
|
|
728
|
+
// A node the browser does not draw has no layout and no findings. Without this,
|
|
729
|
+
// a `display:none` frame was still laid out and could report an overflow inside
|
|
730
|
+
// a box that is not on the page.
|
|
731
|
+
if (isHidden(node, style)) return;
|
|
732
|
+
|
|
733
|
+
const children = layoutChildren(node);
|
|
734
|
+
const box = measure(node, parent, tokens, depth);
|
|
735
|
+
const parentContent = parent?.content ?? { w: UNKNOWN, h: UNKNOWN };
|
|
736
|
+
// The base this node is checked against comes from its ancestors. A node that is
|
|
737
|
+
// itself positioned establishes a base for its descendants, not for itself —
|
|
738
|
+
// reading it for both made a positioned box its own containing block, and a box
|
|
739
|
+
// can never be outside itself.
|
|
740
|
+
const inherited = parent?.containing ?? { w: UNKNOWN, h: UNKNOWN };
|
|
741
|
+
|
|
742
|
+
checkAxisConflict(node, style, tokens, parentContent, findings);
|
|
743
|
+
checkAbsolute(node, style, tokens, inherited, findings, rotation);
|
|
744
|
+
|
|
745
|
+
const type = getAttr(node, "data-type") || "frame";
|
|
746
|
+
if (MEASURABLE.has(type)) checkMainAxis(node, box, style, tokens, children, findings);
|
|
747
|
+
|
|
748
|
+
for (const child of elementChildren(node)) {
|
|
749
|
+
walk(child, contextOf(node, box, inherited), tokens, findings, depth + 1, rotation);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// -- public API --------------------------------------------------------------
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Geometric findings for a design document.
|
|
757
|
+
*
|
|
758
|
+
* @param {object} doc a parsed document
|
|
759
|
+
* @param {Map} [tokens] the token map from `collectTokens`, so `var(--x)` resolves
|
|
760
|
+
* @returns {Array<{ severity, code, target, path, message, hint }>}
|
|
761
|
+
*/
|
|
762
|
+
export function lintGeometry(doc, tokens) {
|
|
763
|
+
const map = tokens instanceof Map ? tokens : new Map();
|
|
764
|
+
const findings = [];
|
|
765
|
+
const counts = new Map();
|
|
766
|
+
|
|
767
|
+
for (const page of pagesOf(doc)) {
|
|
768
|
+
walk(page, { content: { w: UNKNOWN, h: UNKNOWN }, row: false }, map, findings, 0);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// A pathological document can produce the same complaint once per node.
|
|
772
|
+
// Report the first few and stay quiet about the rest.
|
|
773
|
+
return findings.filter((entry) => {
|
|
774
|
+
const seen = counts.get(entry.code) || 0;
|
|
775
|
+
counts.set(entry.code, seen + 1);
|
|
776
|
+
return seen < MAX_REPORTED_PER_CODE;
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* How far a measured child leaves its parent's measured content box, and which
|
|
782
|
+
* code that is.
|
|
783
|
+
*
|
|
784
|
+
* This is the half of the canvas pass that can be checked without a browser. The
|
|
785
|
+
* canvas divides both rectangles back to CSS pixels and reads the parent's
|
|
786
|
+
* padding and border, which is the only part that needs a DOM; what to do with
|
|
787
|
+
* the four numbers is arithmetic, so it lives here with the other arithmetic and
|
|
788
|
+
* `test/geometry.test.mjs` can reach it.
|
|
789
|
+
*
|
|
790
|
+
* `content` is the parent's content box, `child` is the child's border box, both
|
|
791
|
+
* relative to the same origin.
|
|
792
|
+
*
|
|
793
|
+
* Returns `null` when nothing overhangs, so a caller cannot report a finding it
|
|
794
|
+
* did not earn. A fraction of a pixel is rounding, not a defect.
|
|
795
|
+
*/
|
|
796
|
+
export function overhangOf(child, content, options = {}) {
|
|
797
|
+
const over = Math.max(
|
|
798
|
+
-child.left,
|
|
799
|
+
-child.top,
|
|
800
|
+
child.left + child.width - content.width,
|
|
801
|
+
child.top + child.height - content.height,
|
|
802
|
+
);
|
|
803
|
+
if (!(over > MEASURED_TOLERANCE_PX)) return null;
|
|
804
|
+
const code = options.positioned
|
|
805
|
+
? "geometry/absolute-outside"
|
|
806
|
+
: options.clips ? "geometry/clipped" : "geometry/overflow";
|
|
807
|
+
return { over, code };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** The measured box of every node, keyed by `data-id`. Exposed for tests. */
|
|
811
|
+
export function measureById(doc, tokens) {
|
|
812
|
+
const map = tokens instanceof Map ? tokens : new Map();
|
|
813
|
+
const out = new Map();
|
|
814
|
+
const visit = (node, parent, depth) => {
|
|
815
|
+
if (!node || depth > MAX_DEPTH) return;
|
|
816
|
+
const style = styleMapOf(node);
|
|
817
|
+
if (isHidden(node, style)) return;
|
|
818
|
+
const box = measure(node, parent, map, depth);
|
|
819
|
+
const id = getAttr(node, "data-id");
|
|
820
|
+
if (id) out.set(id, box);
|
|
821
|
+
for (const child of elementChildren(node)) {
|
|
822
|
+
visit(child, contextOf(node, box, parent?.containing ?? { w: UNKNOWN, h: UNKNOWN }), depth + 1);
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
for (const page of pagesOf(doc)) visit(page, { content: { w: UNKNOWN, h: UNKNOWN }, row: false }, 0);
|
|
826
|
+
return out;
|
|
827
|
+
}
|