Add Loop type (flywheel) + palette linter

- New type #27: Loop — ring of 5-8 stations around exactly one hub,
  solid flow arrows around the ring, dashed write-back spokes into
  the hub. Reference doc with ring layout math, budget, and
  anti-patterns; flagship example "The self-improving loop" in
  light/dark/full variants; gallery tab; README showcase under the
  hero image.
- scripts/lint-skin.py — contributor palette linter: reads allowed
  tokens from style-guide.md at runtime, flags off-palette hexes,
  pure black, external assets, scripts, and off-system fonts.
  Baseline file grandfathers pre-2.0 examples; --all --baseline is
  green.
- Linter immediately caught 36 near-miss hexes in 9 of the newly
  regenerated examples; swapped to exact series-palette tokens and
  recaptured the affected screenshots.
This commit is contained in:
Cathryn Lavery
2026-07-14 19:41:17 -07:00
parent 2e9cefb9f3
commit e175eb723b
25 changed files with 912 additions and 50 deletions
@@ -0,0 +1,220 @@
# Loop
**Best for:** reinforcing cycles, flywheels, feedback loops, and operating loops — anything where the last step feeds the first and a shared hub accumulates state. Use Loop when the reader must see both motions at once: work advances clockwise around the ring, while each pass writes durable state back to one common center.
Prefer **Flowchart** when the path ends, branches toward an outcome, or never truly returns to its first step. Prefer **Cycle** when the center does not accumulate shared state. The dashed write-back spokes are the defining signal here: remove them and the figure is only a circular process.
This type is **parametric**. The inputs in §1 determine station count, angles, edge intersections, connector paths, and viewBox bounds. Identical inputs should produce identical geometry.
---
## 1. Inputs — the parameter contract
```yaml
title: "The self-improving loop"
subtitle: "Every pass improves the shared operating record"
hub: # exactly one
name: "Shared memory"
sublabel: "one record, every loop"
stations: # 5..8, clockwise from top
- { name: "Capture", sublabel: "signals in", spoke_label: "SIGNALS" }
- { name: "Research", sublabel: "evidence pulled" }
- { name: "Decide", sublabel: "human approves", focal: true }
- { name: "Act", sublabel: "work ships", spoke_label: "OUTCOMES" }
- { name: "Measure", sublabel: "outcomes logged" }
- { name: "Learn", sublabel: "playbook updated" }
station_w: 160
station_h: 64
hub_w: 200
hub_h: 104
radius: 240
margin: 64
ring_style: "arc" # arc | rounded-orthogonal
dark: false
```
**Budget (hard):** **5–8 stations plus exactly one hub.** Above 8 stations, split the subject into an overview Loop and one or more detail diagrams. Exactly one hub — a loop with two hubs is two diagrams. At most one station may set `focal: true`; zero is allowed when no editorial gate deserves emphasis.
Station order is semantic. `stations[0]` is the top station, then entries proceed clockwise. The last station always connects back to station 0; if that return would be false, use a Flowchart instead.
---
## 2. Layout math — deterministic geometry
Use SVG coordinates, where positive y points downward. Let the hub center be `C = (cx, cy)`, station count be `N`, station ring radius be `R`, station half-size be `a = station_w/2`, `b = station_h/2`, and hub half-size be `A = hub_w/2`, `B = hub_h/2`.
### 2.1 Station centers
For zero-indexed station `k`:
```text
theta_k = -90deg + k * (360deg / N)
u_k = (cos(theta_k), sin(theta_k))
P_k = C + R * u_k
station_center_x(k) = cx + R * cos(theta_k)
station_center_y(k) = cy + R * sin(theta_k)
station_x(k) = station_center_x(k) - station_w/2
station_y(k) = station_center_y(k) - station_h/2
```
Thus station 1 sits at the top, and increasing `k` moves clockwise. Round rendered coordinates to the nearest 4px grid point after computing the ideal geometry; preserve symmetry when rounding paired stations.
### 2.2 Solid ring-flow endpoints
Ring connectors travel from station `k` to station `j = (k + 1) mod N`. They terminate on box edges, never at box centers. The clockwise unit tangent at station `k` is:
```text
t_k = (-sin(theta_k), cos(theta_k))
```
The distance from the center of an axis-aligned station box to its boundary along a unit direction `v` is:
```text
box_distance(v, a, b) = min(a / abs(v.x), b / abs(v.y))
```
Ignore a division whose denominator is zero. Then:
```text
d_exit = box_distance( t_k, a, b)
d_entry = box_distance(-t_j, a, b)
ring_start(k) = P_k + d_exit * t_k
ring_end(k) = P_j - d_entry * t_j
```
For `ring_style: arc`, connect those edge points with a cubic whose control vectors follow the local tangents:
```text
delta = 2pi / N
lambda = 0.55 * R * delta
M ring_start
C ring_start + lambda*t_k,
ring_end - lambda*t_j,
ring_end
```
Reduce `lambda` only enough to keep the curve outside the hub and neighboring boxes. For `ring_style: rounded-orthogonal`, route both edge points to an outer rail and join them with `r=8` quarter-arcs. Every bend is rounded; no slanted straight segments. The closing connector from station `N-1` to station 0 uses the same formula and must remain visibly continuous with the other ring arrows.
### 2.3 Dashed write-back spoke endpoints
Each spoke runs inward from the station edge toward the hub. Use the same ray/box intersection formula, now on the radial vector `u_k`:
```text
d_station = box_distance(u_k, a, b)
d_hub = box_distance(u_k, A, B)
marker_gap = 6 # 4..8px; 6px canonical
spoke_start(k) = P_k - d_station * u_k
hub_edge(k) = C + d_hub * u_k
spoke_end(k) = C + (d_hub + marker_gap) * u_k
```
Because the arrow travels from the station toward `C`, adding `marker_gap` leaves the endpoint just outside the hub boundary. The lighter arrowhead stops before the hub stroke instead of colliding with it. Radial spokes are the type-specific exception to the general ban on slanted straight connectors; they must remain true radii, must not cross one another, and may touch only their source station and the hub.
Labels are optional when the station sublabel already names the write-back. When used, they follow the `arrow-label` role, stay to one side of the spoke, and receive an opaque `paper` mask with a visible 6–10px gap from the stroke. Label a curated subset rather than forcing six labels into the hub halo.
### 2.4 ViewBox sizing
The viewBox must include the full station rectangles, outer ring curves, arrowheads, and at least `margin` breathing room:
```text
left <= cx - R - station_w/2 - margin
right >= cx + R + station_w/2 + margin
top <= cy - R - station_h/2 - margin
bottom >= cy + R + station_h/2 + margin
viewBox_w = right - left
viewBox_h = bottom - top
```
If cubic controls or an outer orthogonal rail extend farther than those bounds, grow the corresponding margin. Never shrink the canvas until a station stroke, marker, or curved return clips. For the six-station canonical example, `viewBox="0 0 1040 680"`, `C=(520,340)`, `R=240`, station size `160×64`, and hub size `200×104` leave generous outer clearance.
---
## 3. Visual grammar
| Element | Treatment |
|---|---|
| Station | Standard node: `paper` fill, `ink` stroke, `radius-md`; name in `node-name`, sublabel in `sublabel` |
| Hub | The one dark element: `ink` fill, `paper` text; slightly larger than a station |
| Focal station | At most one: `accent-tint` fill, `accent` stroke; station name may use `accent` |
| Ring flow | Solid `muted` stroke, default arrowhead at the destination; clockwise only |
| Write-back spoke | Dashed `soft` stroke at reduced emphasis, `stroke-dasharray="5,4"`, with a `soft` arrowhead |
| Spoke label | `arrow-label` role, `soft`, uppercase, paper mask, 6–10px clear of the connector |
Draw in this order: paper or optional dot grid → ring arrows → dashed spokes → spoke-label masks and labels → station boxes → hub → text. The nodes mask microscopic connector overshoot, while every intended endpoint still lands on an edge.
The hub is not a seventh process step. It is accumulated state: memory, standards, evidence, policy, or a shared operating record. Keep its copy to one name plus one short sublabel.
---
## 4. Connector rules (mandatory)
SKILL.md §6 applies in full, with the radial-spoke exception stated in §2.3:
- Ring arrows are solid and clockwise. Their destination markers land on station edges; no connector ends at a center point.
- Spokes are dashed and point inward. A solid spoke destroys the visual distinction between operating flow and write-back.
- Labels use opaque masks and maintain a visible 6–10px connector gap. Never place text on the stroke.
- No ring connector or spoke may overlap another connector. Ring paths remain outside the hub; spokes occupy distinct radial routes.
- When two spokes must leave the same station edge, fan their attach points by the §6 formula with at least 12px separation. The normal Loop has one spoke per station; use a second only when the semantics cannot be merged.
- If a ring route would cross the hub, increase `R`, move to the outer rail, or split the diagram. Do not thread flow through shared state.
---
## 5. Dark variant — token swap
Apply the style-guide inversion rule; do not invent a second palette.
| Role | Light | Dark |
|---|---|---|
| Canvas and station fill | `paper` | dark `paper` |
| Primary text and station stroke | `ink` | inverted `ink` |
| Hub fill / hub text | `ink` / `paper` | inverted `ink` / dark `paper` |
| Ring flow | `muted` | dark `muted` |
| Write-back spokes and labels | `soft` | dark `soft` |
| Focal fill / stroke | `accent-tint` / `accent` | dark `accent-tint` / brighter dark `accent` |
| Rule and dot grid | `rule` | inverted `rule` at the same opacity |
The semantic relationship stays unchanged in dark mode: one `ink`-filled hub, one optional `accent` station, neutral solid ring arrows, and lighter dashed write-backs.
---
## 6. Reproducibility checklist
1. Station count is 5–8 and hub count is exactly one.
2. Station 0 is at `-90deg`; all others use equal `360/N` steps clockwise.
3. Every solid ring arrow connects adjacent stations and the last returns to the first.
4. Every ring marker lands on a station edge, not its center.
5. Every dashed spoke begins on a station's inner edge and stops `marker_gap` before the hub stroke.
6. Ring connectors stay outside the hub; spokes do not cross or overlap.
7. At most one station uses `accent-tint` + `accent`; the hub alone uses the dark `ink` fill.
8. Spoke labels, if present, use `arrow-label`, an opaque mask, and a 6–10px gap.
9. The viewBox includes station boxes, strokes, curves, markers, and margins without clipping.
---
## 7. Anti-patterns
| Anti-pattern | Why it fails / correction |
|---|---|
| Two hubs | Two accumulated states create two systems. Draw two diagrams. |
| Solid spokes | They look like primary flow and kill the dashed return signal. Use dashed `soft` write-backs. |
| Stations at uneven angles without reason | The ring stops reading as one operating cadence. Use equal `360/N` spacing unless a documented phase grouping requires a deliberate gap. |
| Connectors crossing the hub | Flow becomes confused with state. Route the ring outside or enlarge the radius. |
| Accent on multiple stations | The editorial gate disappears. Keep one focal station at most. |
| More than 8 stations | Labels and spokes crowd the hub. Split into overview + detail. |
| A cycle that never actually returns | That is a Flowchart arranged in a circle. Use Flowchart and show the real endpoint. |
---
## 8. Examples
- `assets/example-loop.html` — minimal light: six-station self-improving operating loop.
- `assets/example-loop-dark.html` — the same geometry under the dark token inversion.
- `assets/example-loop-full.html` — editorial page with the flagship loop, three summary cards, and colophon.