mosaic-headless 1.11.2 → 1.13.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.
@@ -0,0 +1,5 @@
1
+ type,note
2
+ component-instance,"Swept as COMMIT_500, which is what a BARE one does. The node's type is not `component-instance` but `component-instance/<componentID>` - the factory splits on that slash and a bare one has no id to read. With a real component it commits and renders; see sweep_components.py."
3
+ component-internal,"The component's content hangs under THIS node, in the `node/component/<id>` key of a componentDocumentInstance. `component-root` looks like the parent and accepts no children."
4
+ component-root,"Accepts no children. It frames the component's preview document, not its content."
5
+ loop,"COMMIT_500 on a bare page: the loop needs a query context that a plain document does not provide."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mosaic-headless",
3
- "version": "1.11.2",
3
+ "version": "1.13.0",
4
4
  "description": "AI-agent skill: build Mosaic Pro (Nextend) WordPress sites by writing the underlying data model directly - 23 custom tables, no visual editor, no DOM. Every node type, style property and node property swept against a live install and asserted on the delivered HTML and compiled CSS. Installs into Claude Code, Cursor, Codex CLI, Gemini CLI, Copilot, Continue, Windsurf and Claude.ai.",
5
5
  "keywords": [
6
6
  "wordpress",
@@ -93,3 +93,41 @@ takes variations from there.
93
93
  That is why the same visual change can be made in two very different places, and
94
94
  why writing a style onto a node directly is usually the wrong move: the class is
95
95
  the unit of reuse, and the collection/variable tables are the token layer beneath it.
96
+
97
+
98
+ ## Components: one definition, many places
99
+
100
+ Three tables (`components`, `component_documents`, `component_categories`), four
101
+ node types and eighteen REST routes. Driven end to end by
102
+ `tools/sweep_components.py`; every claim here was measured, and none of it is
103
+ guessable from the route list.
104
+
105
+ ```
106
+ 1 create adminComponentsEditorInstance, commit a `component` record
107
+ parentType MUST be "componentCategory" - Page, Block and Part exist
108
+ out of the box, and no other parent type is accepted at all.
109
+ parentType:"" gives HTTP 500, "Parent type not supported"
110
+
111
+ 2 heal GET componentDocumentInstance/<id>
112
+ the FIRST call 404s and creates the document; the second returns it,
113
+ healed into body / component-external / component-root / document
114
+
115
+ 3 fill commit into the SAME instance, under the `component-internal` node
116
+ in its `node/component/<id>` key.
117
+ NOT componentNodeEditorInstance - `isCommitAllowed()` returns false
118
+ there and the commit is refused with "Not allowed!" and a 500, even
119
+ though that is the instance which shows you the tree.
120
+ NOT component-root either - it accepts no children
121
+
122
+ 4 instance on a page, a node whose TYPE carries the component's id:
123
+ type: "component-instance/<componentID>"
124
+ `ComponentInstanceElementTypeFactory` splits on that slash. A bare
125
+ `component-instance` has no id for `$flags[0]` and fatals, which is
126
+ the entire reason it is COMMIT_500 in node-verification.csv
127
+ ```
128
+
129
+ Verified on a live page: two instances of one component, both rendered, the
130
+ component's own text present twice from a single definition. Edit the component and
131
+ every instance changes - which is the whole point, and the reason a real site should
132
+ use these rather than repeating a tree.
133
+
package/sites/_moksa.py CHANGED
@@ -1935,7 +1935,12 @@ PLATE = section("plate", [wrap("mk-plate-in", [
1935
1935
  [T("p", "01", fontFamily=DISPLAY, fontSize="132px",
1936
1936
  fontWeight="600", lineHeight=".82", letterSpacing="-0.05em",
1937
1937
  color="rgba(0,0,0,0)",
1938
- customStyles="-webkit-text-stroke:1px " + RULE_INK + ";",
1938
+ # RULE_INK at 42% measured 2.64:1 on the panel, and a 132px
1939
+ # numeral is large text, which needs 3.0. This is the lightest
1940
+ # stroke that clears it. It was invisible until the audit
1941
+ # learned to read `-webkit-text-stroke-color` instead of
1942
+ # giving up at a transparent `color`.
1943
+ customStyles="-webkit-text-stroke:1px rgba(22,24,28,.52);",
1939
1944
  _t={"fontSize": "104px"}, _m={"fontSize": "68px"})]),
1940
1945
  box("mk-plate-t", {}, [
1941
1946
  mono("PLATE 01 — POSITION", size="10px", color="--mk-accent-ink",
package/sites/moksa.json CHANGED
@@ -5351,7 +5351,7 @@
5351
5351
  "lineHeight": ".82",
5352
5352
  "letterSpacing": "-0.05em",
5353
5353
  "color": "rgba(0,0,0,0)",
5354
- "customStyles": "-webkit-text-stroke:1px rgba(22,24,28,.42);"
5354
+ "customStyles": "-webkit-text-stroke:1px rgba(22,24,28,.52);"
5355
5355
  },
5356
5356
  "_t": {
5357
5357
  "fontSize": "104px"
package/tools/mo.py CHANGED
@@ -104,6 +104,21 @@ def table(headers, body, gap=2):
104
104
  print(sep.join(c.ljust(width[i]) for i, c in enumerate(r)).rstrip())
105
105
 
106
106
 
107
+ def wrap(text: str, width: int) -> list[str]:
108
+ """Break on spaces. Slicing every N characters splits words in half, which in a
109
+ note about `component-instance/<componentID>` is actively misleading."""
110
+ out, line = [], ""
111
+ for word in (text or "").split():
112
+ if line and len(line) + 1 + len(word) > width:
113
+ out.append(line)
114
+ line = word
115
+ else:
116
+ line = (line + " " + word) if line else word
117
+ if line:
118
+ out.append(line)
119
+ return out
120
+
121
+
107
122
  def matches(text: str, needle: str | None) -> bool:
108
123
  return needle is None or needle.lower() in (text or "").lower()
109
124
 
@@ -119,6 +134,14 @@ def type_record(name: str) -> dict:
119
134
  name, ("\ndid you mean: " + ", ".join(near[:8])) if near else ""))
120
135
 
121
136
  v = index("node-verification", "type").get(name, {})
137
+ # A sweep outcome is what happened to ONE probe. Where that number is true but
138
+ # misleading on its own, the note says why - `component-instance` is COMMIT_500
139
+ # only because a bare one has no component id in its type.
140
+ note = ""
141
+ try:
142
+ note = index("node-type-notes", "type").get(name, {}).get("note", "")
143
+ except SystemExit:
144
+ note = ""
122
145
  p = index("placement-rules", "type").get(name, {})
123
146
  d = index("default-children", "type").get(name, {})
124
147
 
@@ -148,6 +171,7 @@ def type_record(name: str) -> dict:
148
171
  "edition": t.get("edition"),
149
172
  "outcome": v.get("outcome"),
150
173
  "outcome_note": OUTCOME_NOTE.get(v.get("outcome"), ""),
174
+ "note": note,
151
175
  "safe_to_commit": v.get("outcome") not in UNSAFE,
152
176
  "detail": v.get("detail"),
153
177
  "rendered_tag": v.get("rendered_tag"),
@@ -207,6 +231,9 @@ def cmd_type(a):
207
231
  print("verdict : %s" % flag)
208
232
  if rec["detail"]:
209
233
  print("failure : %s" % rec["detail"])
234
+ if rec["note"]:
235
+ for i, line in enumerate(wrap(rec["note"], 74)):
236
+ print("%-11s %s" % ("note :" if i == 0 else "", line))
210
237
  if rec["rendered_tag"]:
211
238
  print("renders as: <%s>%s" % (
212
239
  rec["rendered_tag"],
@@ -244,6 +271,96 @@ def cmd_type(a):
244
271
  emit(rec, render)
245
272
 
246
273
 
274
+ def cmd_params(a):
275
+ """Everything that can be SET on one node type, in one answer.
276
+
277
+ This is the question you actually have in front of an editor: not "does this
278
+ type exist" but "what may I put on it, in what shape, and which of those have
279
+ been seen to work". It is four tables joined - node properties by the data
280
+ class, the universal style surface, the style states that reach this type, and
281
+ the placement rule - because the answer is not in any one of them.
282
+
283
+ Mosaic differs from a widget-based builder here in a way worth stating: the
284
+ STYLE surface is universal. Every element takes the same 98 style properties;
285
+ what varies per type is the DATA properties and which node-type-scoped states
286
+ apply. So the style half of this output is the same for every type, and that
287
+ is a fact about the platform rather than a shortcut taken here."""
288
+ rec = type_record(a.name)
289
+ prop_status = {r["property"]: r for r in rows("node-property-verification")}
290
+ sv = index("style-verification", "property")
291
+ shapes = index("style-value-shapes", "property")
292
+ ver = index("style-state-verification", "state")
293
+
294
+ states = []
295
+ for r in rows("style-states"):
296
+ v = ver.get(r["state"], {})
297
+ if r["scope"] == "global" or v.get("host") == a.name:
298
+ states.append({"state": r["state"], "scope": r["scope"],
299
+ "selector": r["selector_template"],
300
+ "status": v.get("status", "base state"
301
+ if r["state"] == "&" else "")})
302
+
303
+ style = []
304
+ for r in rows("style-properties"):
305
+ v = sv.get(r["property"], {})
306
+ style.append({"property": r["property"], "group": r["group"],
307
+ "css": v.get("css_property", ""),
308
+ "status": v.get("status", ""),
309
+ "shape": shapes.get(r["property"], {}).get("shape", ""),
310
+ "accepted_values": split(r["accepted_values"])})
311
+
312
+ payload = {"type": a.name, "safe_to_commit": rec["safe_to_commit"],
313
+ "note": rec["note"], "data_properties": rec["properties"],
314
+ "style_properties": style, "states": states,
315
+ "placement_rule": rec["placement_rule"],
316
+ "allowed_children": rec["allowed_children"]}
317
+
318
+ def render():
319
+ head("%s - everything settable" % a.name)
320
+ print("verdict : %s%s"
321
+ % ("SAFE" if rec["safe_to_commit"] else "UNSAFE TO COMMIT",
322
+ " (%s)" % rec["outcome"] if rec["outcome"] else ""))
323
+ if rec["note"]:
324
+ for i, line in enumerate(wrap(rec["note"], 74)):
325
+ print("%-11s %s" % ("note :" if i == 0 else "", line))
326
+
327
+ print("\nDATA properties (%d) - these vary by type"
328
+ % len(rec["properties"]))
329
+ table(["property", "shared", "verified", "accepted values"],
330
+ [[p["property"], "yes" if p["inherited_from"] else "",
331
+ (prop_status.get(p["property"]) or {}).get("status", ""),
332
+ ", ".join(p["accepted_values"])[:44]]
333
+ for p in rec["properties"]])
334
+
335
+ usable = [s for s in states if s["status"] in ("COMPILED", "base state")]
336
+ print("\nSTATES reaching this type (%d usable of %d)"
337
+ % (len(usable), len(states)))
338
+ table(["state", "scope", "swept", "selector"],
339
+ [[s["state"], s["scope"], s["status"], s["selector"][:44]]
340
+ for s in states])
341
+
342
+ ok = [p for p in style if p["status"] == "COMPILED"]
343
+ grouped = [p for p in style if p["group"]]
344
+ print("\nSTYLE properties: %d in the surface, %d measured COMPILED,"
345
+ " %d belong to a group and are INERT set on their own."
346
+ "\nThe style surface is UNIVERSAL in Mosaic - it is the same "
347
+ "for every type."
348
+ % (len(style), len(ok), len(grouped)))
349
+ if a.style:
350
+ table(["property", "group", "css", "swept", "shape"],
351
+ [[p["property"], p["group"], p["css"], p["status"],
352
+ p["shape"][:30]] for p in style])
353
+ else:
354
+ print("(pass --style to list them, or `mo.py style` for the same "
355
+ "table on its own)")
356
+
357
+ print("\nCHILDREN : rule=%s%s" % (rec["placement_rule"],
358
+ (" " + ", ".join(rec["allowed_children"]))
359
+ if rec["allowed_children"] else ""))
360
+
361
+ emit(payload, render)
362
+
363
+
247
364
  def cmd_types(a):
248
365
  ver = index("node-verification", "type")
249
366
  out = []
@@ -655,6 +772,12 @@ def main():
655
772
  p = add("type", cmd_type, "one node type, fully joined")
656
773
  p.add_argument("name")
657
774
 
775
+ p = add("params", cmd_params,
776
+ "EVERYTHING settable on one type: data, style, states, placement")
777
+ p.add_argument("name")
778
+ p.add_argument("--style", action="store_true",
779
+ help="list all 98 style properties too, not just count them")
780
+
658
781
  p = add("check", cmd_check, "exit 1 if any named type is unsafe or unknown")
659
782
  p.add_argument("names", nargs="+")
660
783
 
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env python3
2
+ """Drive Mosaic's component system end to end, and check every step.
3
+
4
+ python tools/sweep_components.py --config c.json --post 20 --slug probe-lab
5
+ python tools/sweep_components.py --config c.json --post 20 --slug probe-lab \
6
+ --csv data/component-verification.csv
7
+
8
+ Components are how a Mosaic site stops repeating itself: build a card once, place it
9
+ in twenty documents, edit the one and all twenty change. Three tables, four node
10
+ types and eighteen REST routes serve it, and this skill had never touched any of
11
+ them - `component-instance` sat in `node-verification.csv` as COMMIT_500, which is
12
+ true and useless, because it only says what happens when you commit one WRONG.
13
+
14
+ Four things had to be found out, and none is guessable:
15
+
16
+ 1. **A component must hang off a `componentCategory`.** `parentType:""` gives
17
+ `Uncaught Exception: Parent type not supported` and an HTTP 500. Three
18
+ categories exist out of the box - Page, Block, Part - and the manager accepts
19
+ no other parent type at all.
20
+
21
+ 2. **The component's document is created lazily by the first GET.** Ask for
22
+ `componentDocumentInstance/<id>` immediately after creating the component and it
23
+ is a 404; ask again and it is there, healed into body / component-external /
24
+ component-root / document.
25
+
26
+ 3. **`componentNodeEditorInstance` is READ ONLY.** Its `isCommitAllowed()` returns
27
+ `false`, so a commit there is refused with `Not allowed!` and an HTTP 500 - even
28
+ though it is the instance that shows you the tree you want to edit. The writable
29
+ one is `componentDocumentInstance`, and the content goes under the
30
+ `component-internal` node in its `node/component/<id>` key. `component-root`
31
+ looks like the obvious parent and accepts no children at all.
32
+
33
+ 4. **An instance's node type carries the component's ID.** Not
34
+ `type: "component-instance"` but `type: "component-instance/<componentID>"` -
35
+ `ComponentInstanceElementTypeFactory` splits on that slash, which is why a bare
36
+ one fatals: there is no id for `$flags[0]` to read.
37
+
38
+ Every step below is asserted against the row that came back or the HTML the site
39
+ served, and the run ends by placing two instances and counting the component's own
40
+ text in the delivered page - because one instance rendering proves less than two.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import argparse
45
+ import csv
46
+ import json
47
+ import os
48
+ import sys
49
+ import time
50
+ import urllib.error
51
+ import urllib.request
52
+ import uuid
53
+
54
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
55
+ from build_page import Surface, flatten # noqa: E402
56
+ from build_site import bind_page, build_shell # noqa: E402
57
+ from sweep_node_types import Client, envelopes, exceptions_of, unwrap # noqa: E402
58
+
59
+ MARKER = "COMPONENT PROBE — REUSED"
60
+
61
+
62
+ def card(attr):
63
+ """What the component contains. Deliberately something with a border and a
64
+ string, so both the CSS and the text can be looked for in the delivered page."""
65
+ return {"type": "div", "data": {"attrID": attr},
66
+ "style": {"&": {"_": {"paddingTop": "18px", "paddingBottom": "18px",
67
+ "paddingLeft": "20px", "paddingRight": "20px",
68
+ "customStyles":
69
+ "border:1px solid rgb(214,214,206);"}}},
70
+ "children": [
71
+ {"type": "text",
72
+ "data": {"tagName": "p", "attrID": attr + "-t"},
73
+ "style": {"&": {"_": {"fontSize": "13px",
74
+ "letterSpacing": "0.16em"}}},
75
+ "text": MARKER}]}
76
+
77
+
78
+ def fetch(url):
79
+ sep = "&" if "?" in url else "?"
80
+ req = urllib.request.Request(
81
+ "%s%s_v=%d" % (url, sep, int(time.time() * 1000)),
82
+ headers={"User-Agent": "Mozilla/5.0", "Cache-Control": "no-cache"})
83
+ try:
84
+ with urllib.request.urlopen(req, timeout=90) as r:
85
+ return r.read().decode("utf-8", "replace")
86
+ except urllib.error.HTTPError as e:
87
+ return e.read().decode("utf-8", "replace")
88
+
89
+
90
+ def main():
91
+ ap = argparse.ArgumentParser()
92
+ ap.add_argument("--config", required=True)
93
+ ap.add_argument("--post", type=int, required=True)
94
+ ap.add_argument("--slug", required=True)
95
+ ap.add_argument("--csv")
96
+ a = ap.parse_args()
97
+
98
+ cfg = json.load(open(a.config, encoding="utf-8"))
99
+ client, surface = Client(cfg), Surface()
100
+ rows, failed = [], 0
101
+
102
+ def step(name, ok, detail):
103
+ nonlocal failed
104
+ rows.append([name, "PASS" if ok else "FAIL", detail])
105
+ print(" %-28s %-5s %s" % (name, "PASS" if ok else "FAIL", detail))
106
+ if not ok:
107
+ failed += 1
108
+
109
+ # ── 1. the categories a component may hang from ──────────────────────────
110
+ inst = unwrap(client.get("adminComponentsEditorInstance"),
111
+ "adminComponentsEditorInstance")
112
+ cats = inst.get("componentCategory") or []
113
+ step("categories exist", bool(cats),
114
+ ", ".join((r.get("data") or {}).get("name", "?") for r in cats)
115
+ or "none - a component has nothing to hang from")
116
+ if not cats:
117
+ sys.exit(1)
118
+
119
+ # ── 2. creating one, correctly parented ──────────────────────────────────
120
+ cid = str(uuid.uuid4())
121
+ resp = client.commit("adminComponentsEditorInstance", envelopes(inst),
122
+ {"component": [{"newRevisionRecord": {
123
+ "ID": cid, "parentType": "componentCategory",
124
+ "parentID": cats[0]["ID"], "ordering": "a0",
125
+ "status": "publish", "revision": "", "version": "",
126
+ "name": "Probe Card", "path": ""},
127
+ "originalRevisionRecord": None}]})
128
+ err = exceptions_of(resp)
129
+ inst2 = unwrap(client.get("adminComponentsEditorInstance"),
130
+ "adminComponentsEditorInstance")
131
+ mine = [r for r in (inst2.get("component") or []) if r["ID"] == cid]
132
+ step("component created", bool(mine) and not err,
133
+ "parented to %r" % (cats[0].get("data") or {}).get("name")
134
+ if mine else "rejected: %s" % (err or "row never appeared"))
135
+
136
+ # ── 3. the document heals on first read ──────────────────────────────────
137
+ instance = "componentDocumentInstance/%s" % cid
138
+ first = client.get(instance)
139
+ doc = unwrap(client.get(instance), "componentDocumentInstance")
140
+ ckey = "node/component/%s" % cid
141
+ internal = next((n for n in doc.get(ckey, [])
142
+ if n["type"] == "component-internal"), None)
143
+ step("document healed", internal is not None,
144
+ "first GET %s, second gave component-internal"
145
+ % ("404'd" if "_httperror" in first else "succeeded"))
146
+ if internal is None:
147
+ sys.exit(1)
148
+
149
+ # ── 4. content goes under component-internal, via the WRITABLE instance ──
150
+ recs = flatten(card("cmp-card"), internal["ID"], cid, surface, False,
151
+ parent_type="component-internal")
152
+ for r in recs:
153
+ r["documentType"], r["documentID"] = "component", cid
154
+ resp = client.commit(instance, envelopes(doc),
155
+ {ckey: [{"newRevisionRecord": r,
156
+ "originalRevisionRecord": None} for r in recs]})
157
+ err = exceptions_of(resp)
158
+ doc2 = unwrap(client.get(instance), "componentDocumentInstance")
159
+ types = sorted({n["type"] for n in doc2.get(ckey, [])})
160
+ step("component filled", "div" in types and not err,
161
+ "tree is %s" % ", ".join(types))
162
+
163
+ # ── 5. the read-only instance refuses the same write ─────────────────────
164
+ # A negative control: this is the endpoint that LOOKS like the right one, and
165
+ # the difference between the two is not visible from the route list.
166
+ ro = "componentNodeEditorInstance/%s" % cid
167
+ rodoc = unwrap(client.get(ro), "componentNodeEditorInstance")
168
+ ro_resp = client.commit(ro, envelopes(rodoc), {ckey: [
169
+ {"newRevisionRecord": dict(recs[0], ID=str(uuid.uuid4())),
170
+ "originalRevisionRecord": None}]})
171
+ refused = "_httperror" in ro_resp or bool(exceptions_of(ro_resp))
172
+ step("read-only instance refuses", refused,
173
+ "componentNodeEditorInstance rejected the same commit"
174
+ if refused else "it ACCEPTED a write it documents as not allowed")
175
+
176
+ # ── 6. two instances on a real page, and the page must show both ─────────
177
+ master = build_shell(client, cfg, {"pages": [], "shell": {}}, surface)
178
+ template = bind_page(client, cfg, master, a.slug, a.post)
179
+ tinst = "templateDocumentInstance/%s/%s" % (master, template)
180
+ tdoc = unwrap(client.get(tinst), "templateDocumentInstance")
181
+ tkey = "node/template/%s" % template
182
+ root = next(n for n in tdoc[tkey] if n["type"] == "template-internal")
183
+ base = {"parentType": "node", "parentID": root["ID"], "status": "publish",
184
+ "revision": "", "version": "", "documentType": "template",
185
+ "documentID": template}
186
+ place = [{"newRevisionRecord": dict(base, ID=str(uuid.uuid4()),
187
+ ordering="a%d" % i,
188
+ type="component-instance/%s" % cid,
189
+ data={"attrID": "cmp-use-%d" % i}),
190
+ "originalRevisionRecord": None} for i in range(2)]
191
+ resp = client.commit(tinst, envelopes(tdoc), {tkey: place})
192
+ err = exceptions_of(resp)
193
+ step("instances committed", not err, err or "type carries the component id")
194
+
195
+ html = fetch("%s/%s/" % (cfg["base"].rstrip("/"), a.slug))
196
+ ids = html.count('id="cmp-use-')
197
+ body = html.count(MARKER)
198
+ step("both instances rendered", ids == 2,
199
+ "%d instance elements in the delivered HTML" % ids)
200
+ step("component content reused", body == 2,
201
+ "the component's own text appears %d times from ONE definition" % body)
202
+
203
+ print("\n%d of %d checks passed" % (len(rows) - failed, len(rows)))
204
+ print("COMPONENT_ID=%s" % cid)
205
+ if a.csv:
206
+ with open(a.csv, "w", newline="", encoding="utf-8") as fh:
207
+ w = csv.writer(fh)
208
+ w.writerow(["step", "result", "detail"])
209
+ w.writerows(rows)
210
+ print("wrote", a.csv)
211
+ sys.exit(1 if failed else 0)
212
+
213
+
214
+ if __name__ == "__main__":
215
+ main()
@@ -403,14 +403,43 @@ PROBE = r"""
403
403
  // rgba(0,0,0,0) as pure black - which then scores a perfect contrast ratio
404
404
  // against any light ground. That is a blind spot scoring itself as a pass, so
405
405
  // it gets its own finding instead: the check cannot run here, and says so.
406
- const rgba = (cs.color.match(/[\d.]+/g) || []).map(Number);
406
+ let rgba = (cs.color.match(/[\d.]+/g) || []).map(Number);
407
+ let paintedBy = '';
407
408
  if (rgba.length === 4 && rgba[3] < 0.05) {
408
- out.audit.push({check: 'TEXT_CLIP', level: 'warn', node: id,
409
- detail: 'color is ' + cs.color + '; painted by ' +
410
- (cs.backgroundClip === 'text' ? 'background-clip:text'
411
- : 'something else') +
412
- ' - contrast NOT checked here',
413
- sample: own.slice(0, 24)});
409
+ // The colour is transparent, but the glyphs are not: something else is
410
+ // painting them. Reporting "cannot check" here was the tool declining to
411
+ // look one property further. Both of the ways this page does it are
412
+ // readable from the computed style.
413
+ const stops = [];
414
+ if (cs.backgroundClip === 'text' || cs.webkitBackgroundClip === 'text') {
415
+ // every colour stop in the gradient; the worst one is what decides
416
+ for (const m of (cs.backgroundImage || '').matchAll(
417
+ /rgba?\(([^)]+)\)/g)) {
418
+ const v = m[1].split(',').map(Number);
419
+ if (v.length >= 3) stops.push(v);
420
+ }
421
+ if (stops.length) paintedBy = 'background-clip:text';
422
+ }
423
+ const strokeW = parseFloat(cs.webkitTextStrokeWidth) || 0;
424
+ if (!stops.length && strokeW > 0) {
425
+ const v = (cs.webkitTextStrokeColor.match(/[\d.]+/g) || []).map(Number);
426
+ if (v.length >= 3) { stops.push(v); paintedBy = 'text-stroke'; }
427
+ }
428
+ if (stops.length) {
429
+ // the worst stop is the one that decides whether the string is readable
430
+ let worst = null, wr = Infinity;
431
+ for (const v of stops) {
432
+ const c = over(v.slice(0, 3), bgOf(el), v.length === 4 ? v[3] : 1);
433
+ const r = ratio(c, bgOf(el));
434
+ if (r < wr) { wr = r; worst = c; }
435
+ }
436
+ rgba = worst.concat([1]);
437
+ } else {
438
+ out.audit.push({check: 'TEXT_CLIP', level: 'warn', node: id,
439
+ detail: 'color is ' + cs.color + ' and nothing readable is '
440
+ + 'painting it - contrast NOT checked here',
441
+ sample: own.slice(0, 24)});
442
+ }
414
443
  }
415
444
  const bg0 = bgOf(el);
416
445
  const fg = over(rgba.slice(0, 3), bg0,
@@ -421,7 +450,8 @@ PROBE = r"""
421
450
  const need = large ? 3.0 : 4.5;
422
451
  if (r < need) out.audit.push({
423
452
  check: 'CONTRAST', level: r < need - 1 ? 'error' : 'warn', node: id,
424
- detail: r.toFixed(2) + ':1 against its background, needs ' + need,
453
+ detail: r.toFixed(2) + ':1 against its background, needs ' + need
454
+ + (paintedBy ? ' (painted by ' + paintedBy + ', worst stop)' : ''),
425
455
  sample: own.slice(0, 24)});
426
456
  }
427
457
 
@@ -541,6 +571,10 @@ def main():
541
571
  ap.add_argument("--site", required=True)
542
572
  ap.add_argument("--csv", help="the computed-value table")
543
573
  ap.add_argument("--audit", help="the design-audit findings")
574
+ ap.add_argument("--ack", default=os.path.join(
575
+ os.path.dirname(os.path.abspath(__file__)), "..", "data",
576
+ "design-audit-acknowledged.csv"),
577
+ help="findings reviewed and accepted, with the reason for each")
544
578
  ap.add_argument("--shots")
545
579
  ap.add_argument("--page", help="only this slug")
546
580
  a = ap.parse_args()
@@ -617,6 +651,26 @@ def main():
617
651
  print(" %-4s %-22s %-16s declared %-18s got %s"
618
652
  % (r[1], r[2][:22], r[3], r[4][:18], r[5][:34]))
619
653
 
654
+ # A finding that is correct by design and will never be fixed should not sit
655
+ # in the same bucket as one nobody has looked at yet - a list where most
656
+ # entries are permanent is a list people stop reading. So they are
657
+ # ACKNOWLEDGED rather than suppressed: the acknowledgement lives in the
658
+ # repository beside the data, every one carries a written reason, and the
659
+ # count is still printed. Nothing is dropped; it is only separated from what
660
+ # nobody has looked at.
661
+ acks = []
662
+ if a.ack and os.path.exists(a.ack):
663
+ with open(a.ack, encoding="utf-8", newline="") as fh:
664
+ acks = list(csv.DictReader(fh))
665
+
666
+ def acknowledged(finding):
667
+ for row in acks:
668
+ if (row["check"] == finding["check"]
669
+ and (finding.get("node") or "").startswith(
670
+ row["node_prefix"])):
671
+ return row["reason"]
672
+ return None
673
+
620
674
  # Audit findings collapse across breakpoints: the same headline reported at three
621
675
  # widths is one defect, not three, and printing it three times buries the others.
622
676
  seen: dict[tuple, dict] = {}
@@ -624,13 +678,29 @@ def main():
624
678
  key = (f["check"], f.get("node", ""), f.get("detail", ""))
625
679
  seen.setdefault(key, dict(f, breakpoints=[]))["breakpoints"].append(
626
680
  f["breakpoint"])
627
- audit = sorted(seen.values(), key=lambda f: (f["level"] != "error", f["check"]))
681
+ audit = sorted(seen.values(),
682
+ key=lambda f: (f["level"] != "error", f["check"]))
683
+ for f in audit:
684
+ why = acknowledged(f)
685
+ if why:
686
+ f["level"], f["ack"] = "ack", why
628
687
  errors = [f for f in audit if f["level"] == "error"]
688
+ open_warns = [f for f in audit if f["level"] == "warn"]
689
+ acked = [f for f in audit if f["level"] == "ack"]
629
690
  hard += len(errors)
630
691
 
631
- print("\ndesign audit (%d findings: %d error, %d warn)"
632
- % (len(audit), len(errors), len(audit) - len(errors)))
692
+ print("\ndesign audit (%d findings: %d error, %d unreviewed warn, "
693
+ "%d acknowledged)"
694
+ % (len(audit), len(errors), len(open_warns), len(acked)))
695
+ for f in acked[:6]:
696
+ print(" ack %-22s %-26s %s"
697
+ % (f["check"], (f.get("node") or "")[:26], f["ack"][:64]))
698
+ if len(acked) > 6:
699
+ print(" ack ... %d more under the same acknowledgement"
700
+ % (len(acked) - 6))
633
701
  for f in audit[:40]:
702
+ if f["level"] == "ack":
703
+ continue
634
704
  print(" %-5s %-22s %-26s %s"
635
705
  % (f["level"], f["check"], (f.get("node") or "")[:26],
636
706
  f.get("detail", "")))
@@ -653,20 +723,21 @@ def main():
653
723
  if a.audit:
654
724
  with open(a.audit, "w", newline="", encoding="utf-8") as fh:
655
725
  w = csv.writer(fh)
656
- w.writerow(["url", "breakpoints", "check", "level", "node", "detail",
657
- "sample"])
726
+ w.writerow(["url", "breakpoints", "check", "level", "node",
727
+ "detail", "sample", "acknowledged_because"])
658
728
  for f in audit:
659
729
  w.writerow([f["url"], ",".join(f["breakpoints"]), f["check"],
660
730
  f["level"], f.get("node", ""), f.get("detail", ""),
661
- f.get("sample", "")])
731
+ f.get("sample", ""), f.get("ack", "")])
662
732
  print("wrote %s (%d findings)" % (a.audit, len(audit)))
663
733
 
664
734
  # "clean" was overstating it once the audit began reporting blind spots as
665
735
  # warnings. A run carrying two labelled unknowns is not a run carrying none, and
666
736
  # the summary line is the part people read.
667
737
  print("\n%s" % (("PASS - every comparable declaration is what the browser "
668
- "computed; 0 audit errors, %d warnings"
669
- % (len(audit) - len(errors)))
738
+ "computed; 0 audit errors, %d unreviewed warnings, "
739
+ "%d acknowledged"
740
+ % (len(open_warns), len(acked)))
670
741
  if not hard else
671
742
  "FAIL - %d overridden declarations, %d audit errors"
672
743
  % (counts.get("OVERRIDDEN", 0), len(errors))))