mosaic-headless 1.7.0 → 1.9.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.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh-TW.md +2 -2
- package/SKILL.md +13 -9
- package/assets/templates/platforms/claude-ai.json +1 -1
- package/assets/templates/platforms/claude-code.json +1 -1
- package/assets/templates/platforms/codex-cli.json +1 -1
- package/assets/templates/platforms/copilot.json +1 -1
- package/assets/templates/platforms/gemini-cli.json +1 -1
- package/bin/check-release.mjs +3 -3
- package/data/browser-verification.csv +30 -0
- package/data/design-audit.csv +3 -0
- package/data/intro-verification.csv +13 -12
- package/data/rwd-verification.csv +3 -0
- package/package.json +1 -1
- package/sites/_moksa.py +180 -10
- package/sites/moksa.json +1572 -35
- package/tools/verify_browser.py +53 -7
- package/tools/verify_intro.py +49 -5
package/tools/verify_browser.py
CHANGED
|
@@ -34,6 +34,14 @@ AUDIT what only a browser knows, checked whether or not it was declared: fon
|
|
|
34
34
|
fallback, tracking against script, text contrast, horizontal overflow,
|
|
35
35
|
clipped text, and line measure.
|
|
36
36
|
|
|
37
|
+
Its scope is stated rather than implied: readings are taken with the page
|
|
38
|
+
at rest at scroll-top, so a defect that only exists part-way down a scroll
|
|
39
|
+
- a fixed element crossing a band of a different colour - is outside what
|
|
40
|
+
this pass sees. The contrast check does hit-test the ground behind a fixed
|
|
41
|
+
or sticky element rather than walking its ancestors, so it would find such
|
|
42
|
+
a case if it were looking there; it simply is not scrolling. `verify_intro`
|
|
43
|
+
samples over time; nothing here yet samples over scroll.
|
|
44
|
+
|
|
37
45
|
Exit status is non-zero if any declaration is OVERRIDDEN or any audit finding is
|
|
38
46
|
rated `error`.
|
|
39
47
|
"""
|
|
@@ -307,11 +315,31 @@ PROBE = r"""
|
|
|
307
315
|
|
|
308
316
|
// Effective background behind a text node, for contrast. Walks up until something
|
|
309
317
|
// is actually painted; a transparent parent is not a background.
|
|
318
|
+
const solid = b => {
|
|
319
|
+
const n = (b.match(/[\d.]+/g) || []).map(Number);
|
|
320
|
+
return (n.length >= 3 && (n.length < 4 || n[3] > 0.55)) ? n.slice(0, 3) : null;
|
|
321
|
+
};
|
|
310
322
|
const bgOf = el => {
|
|
323
|
+
const cs0 = getComputedStyle(el);
|
|
324
|
+
// A FIXED or STICKY element does not sit on its ancestors - it sits on whatever
|
|
325
|
+
// the page has scrolled underneath it, and the ancestor chain knows nothing
|
|
326
|
+
// about that. The clause index on this page is fixed to the left margin in a
|
|
327
|
+
// faint grey; when an accent-coloured band scrolls behind it the text becomes
|
|
328
|
+
// unreadable, and walking parents reports the paper ground it was declared on
|
|
329
|
+
// and passes. Hit-testing is the only way to ask what is actually back there.
|
|
330
|
+
if (cs0.position === 'fixed' || cs0.position === 'sticky') {
|
|
331
|
+
const r = el.getBoundingClientRect();
|
|
332
|
+
const x = Math.min(Math.max(r.left + r.width / 2, 1), innerWidth - 1);
|
|
333
|
+
const y = Math.min(Math.max(r.top + r.height / 2, 1), innerHeight - 1);
|
|
334
|
+
for (const under of document.elementsFromPoint(x, y)) {
|
|
335
|
+
if (under === el || el.contains(under) || under.contains(el)) continue;
|
|
336
|
+
const c = solid(getComputedStyle(under).backgroundColor);
|
|
337
|
+
if (c) return c;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
311
340
|
for (let p = el; p; p = p.parentElement) {
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
if (n.length >= 3 && (n.length < 4 || n[3] > 0.55)) return n.slice(0, 3);
|
|
341
|
+
const c = solid(getComputedStyle(p).backgroundColor);
|
|
342
|
+
if (c) return c;
|
|
315
343
|
}
|
|
316
344
|
return [255, 255, 255];
|
|
317
345
|
};
|
|
@@ -363,8 +391,22 @@ PROBE = r"""
|
|
|
363
391
|
sample: own.slice(0, 24)});
|
|
364
392
|
}
|
|
365
393
|
|
|
366
|
-
|
|
367
|
-
|
|
394
|
+
// Text can be painted by something other than `color`. A headline using
|
|
395
|
+
// `background-clip:text` sets `color:transparent`, and a naive parse reads
|
|
396
|
+
// rgba(0,0,0,0) as pure black - which then scores a perfect contrast ratio
|
|
397
|
+
// against any light ground. That is a blind spot scoring itself as a pass, so
|
|
398
|
+
// it gets its own finding instead: the check cannot run here, and says so.
|
|
399
|
+
const rgba = (cs.color.match(/[\d.]+/g) || []).map(Number);
|
|
400
|
+
if (rgba.length === 4 && rgba[3] < 0.05) {
|
|
401
|
+
out.audit.push({check: 'TEXT_CLIP', level: 'warn', node: id,
|
|
402
|
+
detail: 'color is ' + cs.color + '; painted by ' +
|
|
403
|
+
(cs.backgroundClip === 'text' ? 'background-clip:text'
|
|
404
|
+
: 'something else') +
|
|
405
|
+
' - contrast NOT checked here',
|
|
406
|
+
sample: own.slice(0, 24)});
|
|
407
|
+
}
|
|
408
|
+
const fg = rgba.slice(0, 3);
|
|
409
|
+
if (fg.length === 3 && !(rgba.length === 4 && rgba[3] < 0.05)) {
|
|
368
410
|
const r = ratio(fg, bgOf(el));
|
|
369
411
|
const large = size >= 24 || (size >= 18.66 && px(cs.fontWeight) >= 700);
|
|
370
412
|
const need = large ? 3.0 : 4.5;
|
|
@@ -577,8 +619,12 @@ def main():
|
|
|
577
619
|
f.get("sample", "")])
|
|
578
620
|
print("wrote %s (%d findings)" % (a.audit, len(audit)))
|
|
579
621
|
|
|
580
|
-
|
|
581
|
-
|
|
622
|
+
# "clean" was overstating it once the audit began reporting blind spots as
|
|
623
|
+
# warnings. A run carrying two labelled unknowns is not a run carrying none, and
|
|
624
|
+
# the summary line is the part people read.
|
|
625
|
+
print("\n%s" % (("PASS - every comparable declaration is what the browser "
|
|
626
|
+
"computed; 0 audit errors, %d warnings"
|
|
627
|
+
% (len(audit) - len(errors)))
|
|
582
628
|
if not hard else
|
|
583
629
|
"FAIL - %d overridden declarations, %d audit errors"
|
|
584
630
|
% (counts.get("OVERRIDDEN", 0), len(errors))))
|
package/tools/verify_intro.py
CHANGED
|
@@ -72,7 +72,14 @@ PROBE = r"""
|
|
|
72
72
|
pointerEvents: cs.pointerEvents,
|
|
73
73
|
clipPath: cs.clipPath,
|
|
74
74
|
transform: cs.transform,
|
|
75
|
-
|
|
75
|
+
// Every registered custom property this element carries, not one name
|
|
76
|
+
// picked in advance. The counters are drawn by `::after` through
|
|
77
|
+
// `counter()`, so `textContent` is empty and computed `content` comes back
|
|
78
|
+
// as the literal `counter(s, decimal-leading-zero)` rather than the digits -
|
|
79
|
+
// the animated integer underneath is the only readable trace they leave.
|
|
80
|
+
// Hard-coding `--mk-n` meant a running clock counted as motionless.
|
|
81
|
+
counter: Array.from(cs).filter(p => p.startsWith('--'))
|
|
82
|
+
.map(p => p + '=' + cs.getPropertyValue(p).trim()).join(';'),
|
|
76
83
|
text: (el.textContent || '').trim().slice(0, 24),
|
|
77
84
|
w: Math.round(r.width), h: Math.round(r.height),
|
|
78
85
|
};
|
|
@@ -115,7 +122,12 @@ PROBE = r"""
|
|
|
115
122
|
|
|
116
123
|
|
|
117
124
|
def read_ids(spec, slug):
|
|
118
|
-
"""Every attrID
|
|
125
|
+
"""Every attrID the visitor will see - the page tree AND the shell.
|
|
126
|
+
|
|
127
|
+
Walking only `page["tree"]` misses the header and footer entirely, because in
|
|
128
|
+
Mosaic those live on the master rather than on the template. The running clock
|
|
129
|
+
is in the header, so a check for "is anything still moving" could not see the
|
|
130
|
+
one element built specifically to always be moving."""
|
|
119
131
|
ids = []
|
|
120
132
|
|
|
121
133
|
def walk(node):
|
|
@@ -129,6 +141,9 @@ def read_ids(spec, slug):
|
|
|
129
141
|
for v in node:
|
|
130
142
|
walk(v)
|
|
131
143
|
|
|
144
|
+
shell = spec.get("shell") or {}
|
|
145
|
+
walk(shell.get("header"))
|
|
146
|
+
walk(shell.get("footer"))
|
|
132
147
|
for page in spec["pages"]:
|
|
133
148
|
if page["slug"] == slug:
|
|
134
149
|
walk(page.get("tree"))
|
|
@@ -206,6 +221,8 @@ def main():
|
|
|
206
221
|
ap.add_argument("--page")
|
|
207
222
|
ap.add_argument("--veil-prefix", default="mk-boot",
|
|
208
223
|
help="ids under this prefix are the intro, not the content")
|
|
224
|
+
ap.add_argument("--min-ambient", type=int, default=3,
|
|
225
|
+
help="how many elements must still be moving once settled")
|
|
209
226
|
ap.add_argument("--deadline-ms", type=int, default=4200,
|
|
210
227
|
help="by this point the intro must be over")
|
|
211
228
|
ap.add_argument("--csv")
|
|
@@ -248,9 +265,13 @@ def main():
|
|
|
248
265
|
"%d of %d intro elements changed state" % (len(veil_moved),
|
|
249
266
|
len(veil))))
|
|
250
267
|
# the counter is an animated integer, so it can be read rather than admired
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
268
|
+
import re as _re
|
|
269
|
+
nums = []
|
|
270
|
+
for r in readings:
|
|
271
|
+
sig = (r["nodes"].get(a.veil_prefix + "-num") or {}).get("counter", "")
|
|
272
|
+
hit = _re.search(r"--mk-n=(-?\d+)", sig)
|
|
273
|
+
if hit:
|
|
274
|
+
nums.append(int(hit.group(1)))
|
|
254
275
|
checks.append(("COUNTS", bool(nums) and max(nums) >= 99,
|
|
255
276
|
"--mk-n reached %s" % (max(nums) if nums else "nothing")))
|
|
256
277
|
|
|
@@ -271,6 +292,29 @@ def main():
|
|
|
271
292
|
cleared = bool(clicked) and not str(clicked).startswith(a.veil_prefix)
|
|
272
293
|
checks.append(("CLEARS", cleared, "a real click landed on %r" % clicked))
|
|
273
294
|
|
|
295
|
+
# AMBIENT - does anything still move once the page has settled?
|
|
296
|
+
#
|
|
297
|
+
# Every other motion check here fires on an event: the document loading, an
|
|
298
|
+
# element entering the viewport. A page can pass all of them and still be
|
|
299
|
+
# completely static the moment you stop scrolling, which is a real quality a
|
|
300
|
+
# reader notices and nothing was measuring. So: take the last two readings,
|
|
301
|
+
# both well past the intro, and count how many elements differ between them.
|
|
302
|
+
settled = [r for r in readings if r["t_ms"] >= a.deadline_ms]
|
|
303
|
+
moving = set()
|
|
304
|
+
if len(settled) >= 2:
|
|
305
|
+
first, last_ = settled[-2], settled[-1]
|
|
306
|
+
for i in ids:
|
|
307
|
+
x, y = first["nodes"].get(i), last_["nodes"].get(i)
|
|
308
|
+
if not x or not y:
|
|
309
|
+
continue
|
|
310
|
+
if (x["transform"], x["opacity"], x["counter"], x["text"]) != (y["transform"], y["opacity"], y["counter"], y["text"]):
|
|
311
|
+
moving.add(i)
|
|
312
|
+
checks.append(("AMBIENT", len(moving) >= a.min_ambient,
|
|
313
|
+
"%d elements still moving between %dms and %dms with no input: %s"
|
|
314
|
+
% (len(moving), settled[-2]["t_ms"] if len(settled) >= 2 else 0,
|
|
315
|
+
settled[-1]["t_ms"] if settled else 0,
|
|
316
|
+
", ".join(sorted(moving)[:6]) or "nothing")))
|
|
317
|
+
|
|
274
318
|
# DEGRADES
|
|
275
319
|
red, red_click, _ = run(url, ids, True, None)
|
|
276
320
|
first = red[0]
|