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.
- package/README.ja.md +2 -0
- package/README.ko.md +2 -0
- package/README.md +2 -0
- package/README.zh-TW.md +2 -0
- package/SKILL.md +15 -2
- 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 +19 -0
- package/data/component-verification.csv +9 -0
- package/data/design-audit-acknowledged.csv +2 -0
- package/data/design-audit.csv +25 -28
- package/data/intro-verification.csv +14 -14
- package/data/node-type-notes.csv +5 -0
- package/package.json +1 -1
- package/references/data-model.md +38 -0
- package/sites/_moksa.py +6 -1
- package/sites/moksa.json +1 -1
- package/tools/mo.py +123 -0
- package/tools/sweep_components.py +215 -0
- package/tools/verify_browser.py +87 -16
|
@@ -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.
|
|
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",
|
package/references/data-model.md
CHANGED
|
@@ -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
|
-
|
|
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,.
|
|
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()
|
package/tools/verify_browser.py
CHANGED
|
@@ -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
|
-
|
|
406
|
+
let rgba = (cs.color.match(/[\d.]+/g) || []).map(Number);
|
|
407
|
+
let paintedBy = '';
|
|
407
408
|
if (rgba.length === 4 && rgba[3] < 0.05) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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(),
|
|
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
|
-
%
|
|
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",
|
|
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
|
-
%
|
|
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))))
|