diff --git a/tests/test_icon_design_layers.py b/tests/test_icon_design_layers.py
new file mode 100644
index 00000000..90051f64
--- /dev/null
+++ b/tests/test_icon_design_layers.py
@@ -0,0 +1,576 @@
+"""The icon's design source must keep the properties nothing renders for us.
+
+`tests/test_gen_assets_keeps_the_icon_transparent.py` guards the *products* the
+generator emits, and reads the generator's script; its only statement about the
+design source is `MARGIN = 56`. So every property `packaging/assets/icon.svg`
+carries on its own went unmeasured, and a revert of any of them passes every leg.
+
+Four properties, each one a defect this project has already shipped or nearly
+shipped:
+
+1. **Layer order at the hub.** The hub's circles are painted *after* the
+ branches. Painted before them, the branch caps converge on the hub's face and
+ each branch's own gradient starts at its darkest stop there, which reads as a
+ dark blotch on the sphere.
+2. **One light for the fan.** The branches are all stroked with a single radial
+ gradient in `userSpaceOnUse` units centred on the hub — `branchGrad` on the
+ shipped file. Under the default `objectBoundingBox` units each branch resolves
+ the gradient against its own bounding box, and the illumination points a
+ different way per branch.
+3. **Four nodes, one per branch.** A fifth branch close to its neighbours shrinks
+ to a blob below 128 px; a branch that arrives bare, or a node beside the fan,
+ breaks the same count. Which layer is a node's is read from the document — the
+ largest circle where a branch arrives — so no id and no size is fixed here.
+4. **The body rectangle.** 56 inset, 912 square, `rx` 206 on a 1024 canvas — the
+ shape every platform clips an app icon into.
+
+The guard is a function over SVG **text**, so both directions cost a literal
+rather than a filesystem: the shipped file must come back faultless, a document
+with the previous layering must be refused, and a redesign that keeps the four
+properties must come back faultless too. An assertion that can only ever see the
+good file proves nothing about the bad one, and one that pins the shipped
+coordinates rather than the design reports every redesign as a broken design.
+
+It reads no generated product: those are gitignored build output, and producing
+them needs a renderer the test runners cannot be assumed to have.
+"""
+
+from __future__ import annotations
+
+import re
+import xml.etree.ElementTree as ET
+from collections import Counter
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parent.parent
+SVG = REPO / "packaging" / "assets" / "icon.svg"
+
+NS = "{http://www.w3.org/2000/svg}"
+
+#: How many branches the fan has, and therefore how many nodes.
+BRANCHES = 4
+
+#: No constant names a node, because a node has no name in the design: it is a place
+#: a branch arrives. Counting one named its layer's gradient id, so a fan whose
+#: per-node layer was renamed coherently — the gradient and every `fill` that uses
+#: it, nothing else touched — was refused as having no nodes at all. That is the
+#: same fault as counting a radius, one constant over: an id is a name, and the
+#: property is that each branch ends in a node shaped like the others.
+
+#: The app-icon body: 1024 canvas, inset 56, so a 912 square with a 206 radius.
+BODY = {"x": "56", "y": "56", "width": "912", "height": "912", "rx": "206"}
+
+
+def _start_point(element: ET.Element) -> tuple[float, float] | None:
+ """Where a path begins, in user units; `None` when its `d` does not say."""
+ match = re.match(r"\s*M\s*(-?[\d.]+)[\s,]+(-?[\d.]+)", element.get("d") or "")
+ if match is None:
+ return None
+ return (float(match.group(1)), float(match.group(2)))
+
+
+def _end_point(element: ET.Element) -> tuple[float, float] | None:
+ """Where a path arrives, in user units; `None` when its `d` does not say.
+
+ The last coordinate pair is where the branch lands, which is where its node is.
+ """
+ pairs = re.findall(r"(-?\d+(?:\.\d+)?)[\s,]+(-?\d+(?:\.\d+)?)", element.get("d") or "")
+ if not pairs:
+ return None
+ x, y = pairs[-1]
+ return (float(x), float(y))
+
+
+def _centre(element: ET.Element) -> tuple[float, float] | None:
+ """A shape's centre, or `None` when it carries no placeable `cx`/`cy`."""
+ try:
+ return (float(element.get("cx")), float(element.get("cy")))
+ except (TypeError, ValueError):
+ return None
+
+
+def _distance(a: tuple[float, float], b: tuple[float, float]) -> float:
+ return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5
+
+
+def hub_centre(elements: list[ET.Element]) -> tuple[float, float] | None:
+ """The point the fan emanates from, read off the paths instead of pinned.
+
+ The branches begin at the hub, so the start point the most stroked paths share
+ *is* the hub; a blurred copy of a branch starts where its crisp original does,
+ which only makes that point more common. `None` when no path declares a start,
+ which the caller reports rather than supplying a centre for.
+
+ Deriving it is the difference between pinning the design and pinning a pair of
+ coordinates: a redesign that moves the hub, its highlight, its light and the
+ branch origins together is still the same design, and a literal at the top of
+ this file cannot tell that from a fan detached from its hub.
+ """
+ starts = [
+ _start_point(element)
+ for element in elements
+ if element.tag == NS + "path" and element.get("stroke")
+ ]
+ counted = Counter(point for point in starts if point is not None)
+ if not counted:
+ return None
+ return counted.most_common(1)[0][0]
+
+
+def faults(svg_text: str) -> list[str]:
+ """Every way `svg_text` breaks the design, in the design's own terms.
+
+ Returns an empty list for a document that keeps all four properties — the
+ shipped one, and any redesign that keeps them, which the accept arms at the end
+ of this file hold it to. Parsing is the caller's problem to see: an unreadable
+ document raises, because "could not measure" is not "passed".
+ """
+ root = ET.fromstring(svg_text)
+ elements = list(root.iter())
+ centre = hub_centre(elements)
+
+ problems: list[str] = []
+
+ # 1 + 3. The branches, in document order, and the hub's place among them.
+ painted = [
+ i
+ for i, element in enumerate(elements)
+ if element.tag in (NS + "path", NS + "circle", NS + "rect", NS + "ellipse")
+ ]
+ # A branch is a path that leaves the hub lit by a gradient. Neither the gradient's
+ # id nor the hub's coordinates are named here: an id is a name and a coordinate is
+ # where the last designer put it, while the property is where the light resolves to
+ # and which point the fan leaves from.
+ branches = [
+ i
+ for i in painted
+ if centre is not None
+ and elements[i].tag == NS + "path"
+ and _start_point(elements[i]) == centre
+ and (elements[i].get("stroke") or "").startswith("url(#")
+ ]
+ concentric = [
+ i
+ for i in painted
+ if centre is not None
+ and elements[i].tag == NS + "circle"
+ and _centre(elements[i]) == centre
+ ]
+ # The hub's third layer is its specular highlight, and it is deliberately not
+ # concentric: it sits up and left of the centre, the direction every node's core
+ # leans (a node's body is at `(x, y)`, its core at `(x-6, y-8)`). So it is read as
+ # the nearest circle beside the centre — the lean is the property, and how far it
+ # leans is the design's to choose, which a maximum distance would take away.
+ beside = [
+ i
+ for i in painted
+ if centre is not None
+ and elements[i].tag == NS + "circle"
+ and _centre(elements[i]) is not None
+ and _centre(elements[i]) != centre
+ ]
+ highlight = (
+ min(beside, key=lambda i: _distance(_centre(elements[i]), centre))
+ if beside and centre is not None
+ else None
+ )
+ hub = concentric + ([] if highlight is None else [highlight])
+
+ if len(branches) != BRANCHES:
+ problems.append(
+ f"the fan has {len(branches)} branch(es) leaving the hub lit by a "
+ f"gradient, expected {BRANCHES}"
+ )
+ if centre is not None:
+ if len(concentric) != 2:
+ problems.append(
+ f"the hub has {len(concentric)} layer(s) centred on the point its "
+ "branches leave, expected 2: a glow halo and a body"
+ )
+ if highlight is None:
+ problems.append(
+ "the hub has no layer beside its centre, so nothing on it says which "
+ "way the light comes from and the sphere reads as a flat disc"
+ )
+ else:
+ hx, hy = _centre(elements[highlight])
+ dx, dy = hx - centre[0], hy - centre[1]
+ if not (dx < 0 and dy < 0):
+ problems.append(
+ f"the hub's bright core is offset ({dx:+g}, {dy:+g}) from the "
+ "centre; it must sit up and left of it, the direction every "
+ "node's core leans, or the sphere reads as a flat disc"
+ )
+ if not branches or not hub:
+ problems.append(
+ "no branch and hub to order: the layer-order property cannot be read "
+ "without both, so this document is not the icon this test knows"
+ )
+ elif min(hub) < max(branches):
+ problems.append(
+ "the hub is painted before the branches: its circles must come after "
+ f"every branch (first hub layer at position {min(hub)}, last branch at "
+ f"{max(branches)}), or the branch caps smear across its face"
+ )
+
+ # 3. One node per branch, read from where the branches arrive rather than from a
+ # name or a size. Nothing here says what a node's layer is called or how big it
+ # is: a node is a place a branch ends, its outermost layer is whatever is largest
+ # there, and the property is that the fan's nodes are shaped alike. A renamed or
+ # resized node therefore passes, while a node missing from one branch, an extra
+ # one where no branch ends, or a fan of uneven nodes is refused.
+ arrivals = sorted(
+ {
+ point
+ for i in branches
+ if (point := _end_point(elements[i])) is not None
+ }
+ )
+ outermost: dict[tuple[float, float], float | None] = {}
+ for point in arrivals:
+ at_node = [
+ float(element.get("r"))
+ for element in elements
+ if element.tag == NS + "circle"
+ and _centre(element) == point
+ and element.get("r") is not None
+ ]
+ outermost[point] = max(at_node) if at_node else None
+
+ if len(arrivals) != BRANCHES:
+ problems.append(
+ f"the fan's branches arrive at {len(arrivals)} point(s), expected "
+ f"{BRANCHES}, so it cannot carry one node per branch"
+ )
+ if not outermost:
+ problems.append(
+ "no node can be read: no branch's path says where it arrives, so the "
+ "fan's node count cannot be measured from this document"
+ )
+ else:
+ bare = [point for point, size in outermost.items() if size is None]
+ if bare:
+ problems.append(
+ f"a branch arrives at {bare[0][0]:g}, {bare[0][1]:g} where no node is "
+ "painted, so that branch ends bare"
+ )
+ sizes = sorted({size for size in outermost.values() if size is not None})
+ if len(sizes) > 1:
+ problems.append(
+ f"the fan's nodes are not shaped alike: their outermost layers measure "
+ f"{', '.join(f'{size:g}' for size in sizes)}, so at least one node is "
+ "missing its outer layer or carries a second one"
+ )
+ if sizes:
+ stray = [
+ _centre(element)
+ for element in elements
+ if element.tag == NS + "circle"
+ and element.get("r") is not None
+ and float(element.get("r")) == sizes[-1]
+ and _centre(element) not in outermost
+ ]
+ if stray:
+ problems.append(
+ f"a node layer of {sizes[-1]:g} sits at "
+ f"{stray[0][0]:g}, {stray[0][1]:g} where no branch ends: the fan "
+ f"has {len(arrivals)} node(s) for {len(arrivals) + len(stray)} "
+ "painted, so a fifth sits beside the four"
+ )
+
+ # 2. One light for the fan: the gradient its branches reference, in user space,
+ # centred on the hub they leave.
+ referenced = [
+ match.group(1)
+ for i in branches
+ if (match := re.fullmatch(r"\s*url\(#([^)]+)\)\s*", elements[i].get("stroke") or ""))
+ ]
+ if branches and len(set(referenced)) != 1:
+ problems.append(
+ f"the fan's {len(branches)} branch(es) reference {len(set(referenced))} "
+ f"different gradient(s) ({', '.join(sorted(set(referenced)))}): one light "
+ "for the fan means one gradient"
+ )
+ elif referenced:
+ wanted = referenced[0]
+ gradient = next(
+ (
+ element
+ for element in elements
+ if element.tag == NS + "radialGradient" and element.get("id") == wanted
+ ),
+ None,
+ )
+ if gradient is None:
+ problems.append(
+ f"the branches are stroked with url(#{wanted}) and no radialGradient "
+ "declares that id, so the light they carry cannot be read"
+ )
+ else:
+ if gradient.get("gradientUnits") != "userSpaceOnUse":
+ problems.append(
+ f"{wanted} is not in userSpaceOnUse units: under the default "
+ "objectBoundingBox each branch resolves the light against its own "
+ "box, so the fan is lit from a different direction per branch"
+ )
+ lit = _centre(gradient)
+ if lit is None:
+ problems.append(
+ f"{wanted} has no cx/cy that read as a centre, so where the fan "
+ "is lit from cannot be measured"
+ )
+ elif centre is not None and lit != centre:
+ problems.append(
+ f"{wanted} is centred on ({lit[0]:g}, {lit[1]:g}) and the branches "
+ f"leave the hub at ({centre[0]:g}, {centre[1]:g}): the light must "
+ "come from the hub, or the fan is lit from beside it"
+ )
+
+ # 4. The app-icon body every platform clips against.
+ rects = [element for element in elements if element.tag == NS + "rect"]
+ if len(rects) != 1:
+ problems.append(f"expected exactly one body rect, found {len(rects)}")
+ else:
+ for name, value in BODY.items():
+ if rects[0].get(name) != value:
+ problems.append(
+ f"the body rect's {name} is {rects[0].get(name)!r}, expected {value!r}"
+ )
+
+ return problems
+
+
+def test_the_shipped_icon_keeps_every_designed_property() -> None:
+ """The file that ships is the file these four properties describe."""
+ problems = faults(SVG.read_text(encoding="utf-8"))
+ assert problems == [], "the shipped icon.svg breaks its own design:\n" + "\n".join(
+ f" - {problem}" for problem in problems
+ )
+
+
+def test_the_check_refuses_the_layering_it_replaced() -> None:
+ """Below the branches, and five of them: refused, and refused for both reasons.
+
+ The document is synthetic on purpose. The previous design is not in this tree
+ any more, so a refusal proved against a missing file would measure nothing;
+ this one carries the two defects in the smallest document that can hold them.
+ """
+ old_layering = """"""
+
+ problems = faults(old_layering)
+
+ assert any("painted before the branches" in problem for problem in problems), (
+ "the check accepted the hub painted under the branches; it reports:\n"
+ + "\n".join(f" - {problem}" for problem in problems)
+ )
+ assert any("5 branch(es)" in problem for problem in problems), (
+ "the check accepted the five-branch fan; it reports:\n"
+ + "\n".join(f" - {problem}" for problem in problems)
+ )
+ assert any("userSpaceOnUse" in problem for problem in problems), (
+ "the check accepted a branchGrad in objectBoundingBox units; it reports:\n"
+ + "\n".join(f" - {problem}" for problem in problems)
+ )
+
+
+def test_the_check_refuses_a_body_rect_that_moved() -> None:
+ """A whole-file replacement is where the 56/912/206 body quietly moves.
+
+ Every product is clipped to this rectangle, so a shifted square is invisible
+ to the transparency guards and visible to every user's Dock.
+ """
+ moved = SVG.read_text(encoding="utf-8").replace(
+ 'x="56" y="56" width="912" height="912" rx="206"',
+ 'x="64" y="56" width="896" height="912" rx="206"',
+ 1,
+ )
+ assert moved != SVG.read_text(encoding="utf-8"), (
+ "the substitution did not apply, so this arm measures nothing"
+ )
+
+ problems = faults(moved)
+ assert any("x is '64'" in problem for problem in problems), (
+ "the check accepted a body rect that moved; it reports:\n"
+ + "\n".join(f" - {problem}" for problem in problems)
+ )
+
+
+def test_the_check_refuses_a_highlight_that_leans_the_wrong_way() -> None:
+ """The hub's bright core is where the light comes from, and it leans up-left.
+
+ Mirroring it across the centre says the light comes from below right, and the
+ sphere then reads as a flat disc lit from nowhere the branches agree with. The
+ shipped file carries the one substitution, so a fault reported here can only
+ be the lean.
+ """
+ mirrored = SVG.read_text(encoding="utf-8")
+ bent = mirrored.replace('cx="505" cy="642"', 'cx="519" cy="658"', 1)
+ assert bent != mirrored, "the substitution did not apply, so this arm measures nothing"
+
+ problems = faults(bent)
+ assert len(problems) == 1, (
+ "the check reports more than the lean, so this arm cannot attribute it:\n"
+ + "\n".join(f" - {problem}" for problem in problems)
+ )
+ assert "up and left" in problems[0], (
+ f"the check reported {problems[0]!r}, which does not name the lean"
+ )
+
+
+# --- the accept direction: a redesign that keeps the properties --------------------------------
+#
+# The three arms above prove the guard refuses what it means to refuse. They cannot prove it
+# accepts what it means to accept, and each of these three was refused by an earlier version of
+# this file: it counted a radius, it pinned a hub's coordinates, and it named the gradient. A
+# guard that reports every redesign as a broken design is measuring the shipped file, not the
+# design, and it will be answered by loosening the guard until it measures nothing.
+
+
+def test_the_check_accepts_a_fan_whose_nodes_were_resized() -> None:
+ """A halo's size is a size, not the node: four haloes stay four nodes at 92.
+
+ Counting `r="88"` refused a fan with four branches, four nodes, one light and an
+ untouched body rect, and its message named the radius instead of the design. What
+ makes four nodes four is that four of them are painted at all.
+ """
+ text = SVG.read_text(encoding="utf-8")
+ resized = text.replace('r="88"', 'r="92"')
+ assert resized != text, "the substitution did not apply, so this arm measures nothing"
+ found = resized.count('r="92"')
+ assert found == BRANCHES, f"expected {BRANCHES} resized haloes, found {found}"
+
+ problems = faults(resized)
+ assert problems == [], (
+ "the check refuses a fan whose nodes were resized, though every property holds:\n"
+ + "\n".join(f" - {problem}" for problem in problems)
+ )
+
+
+def test_the_check_accepts_a_hub_that_moved_with_its_light() -> None:
+ """The hub, its highlight, its light and the branch origins moved together: one design.
+
+ Moved down 40 units as a set, the sphere is the same sphere in a different place.
+ Four literals had to agree for the shipped file to pass, so a coherent move broke all
+ four at once while breaking nothing a reader of the icon could see.
+ """
+ text = SVG.read_text(encoding="utf-8")
+ moved = text.replace("M 512 650 ", "M 512 690 ")
+ moved = moved.replace('cx="512" cy="650"', 'cx="512" cy="690"')
+ moved = moved.replace('cx="505" cy="642"', 'cx="505" cy="682"')
+
+ assert moved != text, "the substitution did not apply, so this arm measures nothing"
+ at_new_centre = moved.count('cy="690"')
+ assert at_new_centre == 3, (
+ "the hub, its body and the gradient must all sit at the new centre, found "
+ f"{at_new_centre} of 3"
+ )
+ assert "M 512 650 " not in moved, "a branch still leaves the old centre"
+
+ problems = faults(moved)
+ assert problems == [], (
+ "the check refuses a hub that moved together with its light:\n"
+ + "\n".join(f" - {problem}" for problem in problems)
+ )
+
+
+def test_the_check_accepts_a_fan_lit_by_a_renamed_gradient() -> None:
+ """`branchGrad` is a name; the property is user-space units centred on the hub.
+
+ Renamed everywhere it appears — the gradient and every stroke that references it — the
+ light is the same light. Pinning the id made the name the requirement.
+ """
+ text = SVG.read_text(encoding="utf-8")
+ renamed = text.replace("branchGrad", "fanLight")
+ assert renamed != text and "branchGrad" not in renamed, (
+ "the substitution did not apply, so this arm measures nothing"
+ )
+ assert renamed.count("url(#fanLight)") == BRANCHES
+
+ problems = faults(renamed)
+ assert problems == [], (
+ "the check refuses a fan whose one gradient was renamed:\n"
+ + "\n".join(f" - {problem}" for problem in problems)
+ )
+
+
+def test_the_check_accepts_a_fan_whose_node_layer_was_renamed() -> None:
+ """A node's layer is named too, and its name is no more a property than the
+ gradient's: renamed at the gradient and at every `fill` that uses it, the fan is
+ the same fan with four nodes.
+
+ This is the arm that was missing when the count was pinned to `url(#nodeGlow)`.
+ The file already held that an id is a name for the light, and broke the rule one
+ constant over for the nodes — a rename is a rename whichever layer carries it.
+ """
+ text = SVG.read_text(encoding="utf-8")
+ renamed = text.replace("nodeGlow", "nodeHalo")
+ assert renamed != text and "nodeGlow" not in renamed, (
+ "the substitution did not apply, so this arm measures nothing"
+ )
+ assert renamed.count("url(#nodeHalo)") == text.count("url(#nodeGlow)"), (
+ "the rename must reach the gradient and every fill that uses it, found "
+ f"{renamed.count('url(#nodeHalo)')} of {text.count('url(#nodeGlow)')}"
+ )
+
+ problems = faults(renamed)
+ assert problems == [], (
+ "the check refuses a fan whose per-node layer was renamed, though every "
+ "property holds:\n" + "\n".join(f" - {problem}" for problem in problems)
+ )
+
+
+def test_the_check_refuses_a_node_missing_its_outer_layer() -> None:
+ """One branch arriving at a node that is not shaped like the rest: refused.
+
+ The rename arm above must not be bought by making the count vacuous. A node whose
+ outermost layer is gone still has a body and a highlight where its branch lands,
+ so a count of places would call it a node; what marks it is that the four are no
+ longer alike.
+ """
+ text = SVG.read_text(encoding="utf-8")
+ dropped = text.replace(
+ ' \n', "", 1
+ )
+ assert dropped != text, "the substitution did not apply, so this arm measures nothing"
+
+ problems = faults(dropped)
+ assert any("not shaped alike" in problem for problem in problems), (
+ "the check accepted a node that lost its outer layer; it reports:\n"
+ + "\n".join(f" - {problem}" for problem in problems)
+ )
+
+
+def test_the_check_refuses_a_node_beside_the_fan() -> None:
+ """A fifth node where no branch ends: refused, since four nodes are the design."""
+ text = SVG.read_text(encoding="utf-8")
+ added = text.replace(
+ ' ',
+ ' \n'
+ ' ',
+ 1,
+ )
+ assert added != text, "the substitution did not apply, so this arm measures nothing"
+
+ problems = faults(added)
+ assert any("where no branch ends" in problem for problem in problems), (
+ "the check accepted a node beside the fan; it reports:\n"
+ + "\n".join(f" - {problem}" for problem in problems)
+ )