diff --git a/docs/ux/mockups/gen_offer_mockups.py b/docs/ux/mockups/gen_offer_mockups.py
index b2379df846..6ddd4caae4 100644
--- a/docs/ux/mockups/gen_offer_mockups.py
+++ b/docs/ux/mockups/gen_offer_mockups.py
@@ -491,6 +491,84 @@ def ring_items(A, grouped):
# ---------------------------------------------------------------- rendering
+def menu(cx, cy, header, rows, submenu=None, sub_at=None):
+ """Vertical list form of the offer. rows: (glyph, name, key, count, enabled, reason).
+
+ The invariant is unchanged — a verb has one permanent row index, and rows that do not
+ apply are DISABLED IN PLACE, never removed. What changes against the ring is what an
+ unavailable slot can say: an empty circle says nothing, a greyed row says its own name
+ and the reason it is grey, in the words the product already ships.
+ """
+ RW, RH, HD = 324, 34, 38
+ # The reason line is the whole point of a disabled row, so it must FIT: at 10px italic a
+ # glyph is ~4.9px, and anything past the box edge is a promise the layout does not keep.
+ fit = int((RW - 62) / 4.9)
+ x, y = cx + 26, cy - 30
+ h = HD + len(rows) * RH + 10
+ if y + h > H - 44:
+ y = max(100, H - 44 - h)
+ g = [f'',
+ f'',
+ f'{header.upper()}',
+ f'']
+ # a leader from the pick point to the menu, so the list is visibly ABOUT that geometry
+ g.insert(0, f'')
+ g.insert(0, f'')
+ for i, (gl, name, key, count, on, reason) in enumerate(rows):
+ ry = y + HD + i * RH
+ op = "1" if on else "0.34"
+ if on and i == 0:
+ g.append(f'')
+ g.append(f'')
+ g.append(glyph(gl, x + 26, ry + RH / 2, C["text"], 0.72, 1.7))
+ g.append(f'{name}')
+ if key:
+ kw = 13 + len(key) * 6.6
+ g.append(f''
+ f'{key}')
+ elif count and count > 1:
+ g.append(f'')
+ g.append(f'{count}')
+ g.append('')
+ if not on and reason:
+ r = reason if len(reason) <= fit else reason[:fit - 1].rstrip(" ,—-") + "…"
+ g.append(f'{r}')
+ if submenu:
+ sy = y + HD + (sub_at or 0) * RH - 6
+ sh = 12 + len(submenu) * RH
+ sx = x + RW + 8
+ g.append(f'')
+ g.append(f'')
+ for i, (gl, name, key, _c, on, _r) in enumerate(submenu):
+ ry = sy + 6 + i * RH
+ g.append(f'')
+ g.append(glyph(gl, sx + 24, ry + RH / 2, C["text"], 0.72, 1.7))
+ g.append(f'{name}')
+ if key:
+ kw = 13 + len(key) * 6.6
+ g.append(f''
+ f'{key}')
+ g.append('')
+ return "".join(g)
+
+
OVERFLOWS = [] # (selection, doc state, family, verbs that did not fit the sub-ring)
@@ -499,6 +577,41 @@ def svg_doc(inner):
f'viewBox="0 0 {W} {H}">{inner}')
+def render_list_state(A, sel, doc, expand=None):
+ """The vertical-list form: every family row always present, in the same order, with the
+ ones that do not apply disabled and carrying their reason."""
+ verbs = eligible(A, sel["id"], doc)
+ grouped = by_slot(A, verbs)
+ fresh = doc["bodies"] == 0 and doc["sketches"] == 0
+ shape = "origin" if (fresh and sel["shape"] == "empty") else sel["shape"]
+ art, (ax, ay) = scene(shape, 620, 360)
+ rows, sub, sub_at = [], None, None
+ for idx, s in enumerate(A["slots"]):
+ vs = grouped.get(s["id"], [])
+ if len(vs) == 1:
+ v = vs[0]
+ rows.append((VERB_GLYPH.get(v["id"], "fam_" + s["id"]), v["name"], v.get("key"), 1, True, None))
+ elif len(vs) > 1:
+ rows.append(("fam_" + s["id"], s["label"], None, len(vs), True, None))
+ if expand == s["id"]:
+ sub_at = idx
+ sub = [(VERB_GLYPH.get(v["id"], "fam_" + s["id"]), v["name"], v.get("key"), 1, True, None)
+ for v in vs]
+ else:
+ # Disabled in place, with the product's own refusal text — the thing an empty
+ # slot in the ring could never say.
+ cands = [v for v in A["verbs"]
+ if v["slot"] == s["id"] and v.get("mode", "model") == sel["mode"]]
+ why = next((v["refusal"] for v in cands if v.get("refusal")), None)
+ rows.append(("fam_" + s["id"], s["label"], None, 0, False, why))
+ status = ("Right-click the geometry to see what you can do with it" if not expand
+ else f'{sel["name"]} — pick one')
+ inner = chrome(f'{doc["name"]} · vertical list', status, sel["mode"], empty_doc=fresh)
+ inner += art
+ inner += menu(ax, ay, sel["name"], rows, sub, sub_at)
+ return svg_doc(inner)
+
+
def render_state(A, sel, doc, capacity=8, secondary=None):
verbs = eligible(A, sel["id"], doc)
grouped = by_slot(A, verbs)
@@ -577,6 +690,17 @@ def main():
"detail": {sid: [v["name"] for v in vs] for sid, vs in grouped.items()},
})
+ # Form-factor comparison: the SAME state as a ring and as a vertical list.
+ for sid in ("face_planar", "edge_str", "body_solid", "sk_line", "none"):
+ sel = next(s for s in A["selections"] if s["id"] == sid)
+ d = docs["fresh"] if sid == "none" else docs["rich"]
+ with open(os.path.join(outdir, f"list__{sid}.svg"), "w", encoding="utf-8") as f:
+ f.write(render_list_state(A, sel, d))
+ for sid, fam in (("face_planar", "add"), ("sk_none", "create")):
+ sel = next(s for s in A["selections"] if s["id"] == sid)
+ with open(os.path.join(outdir, f"list__{sid}__{fam}.svg"), "w", encoding="utf-8") as f:
+ f.write(render_list_state(A, sel, docs["rich"], expand=fam))
+
# comparison sheet: 8 vs 12 slots on the same three selections
for cap in (8, 12):
for sid in ("face_planar", "edge_str", "sk_line"):
@@ -665,6 +789,16 @@ def write_atlas(A, rows, files, n_prim, n_sec, mean_fill):
+
Decision 0 — ring or vertical list
+
The live question. Both forms carry the SAME map and the same invariant — fixed order, never
+ re-sorted, nothing compacted; only the geometry differs. Left-click selects; right-click opens the
+ offer. What the list buys: a disabled row can state its own reason in the words the product
+ already ships, where an empty slot in a ring is mute; nine sketch primitives fit without an
+ overflow; shortcuts line up in a readable column; long translated names fit; and it is navigable
+ by arrow key and by screen reader, which a radial is not. What it costs: no equidistant flick
+ gesture, and travel to the last row is longer than to the nearest direction. Pairs below —
+ list first, the same state as a ring second.
+
Decision 1 — ring capacity
The same three selections at eight and at twelve. Eight keeps 45° between neighbours, which is
the reliable eyes-free pointing threshold and maps 1:1 to the numpad; twelve buys direct addresses
@@ -710,7 +844,21 @@ def write_atlas(A, rows, files, n_prim, n_sec, mean_fill):
s = open(p, encoding="utf-8").read()
return s.replace("
\ No newline at end of file
diff --git a/docs/ux/mockups/list__edge_str.svg b/docs/ux/mockups/list__edge_str.svg
new file mode 100644
index 0000000000..3942000d25
--- /dev/null
+++ b/docs/ux/mockups/list__edge_str.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/list__face_planar.svg b/docs/ux/mockups/list__face_planar.svg
new file mode 100644
index 0000000000..5ff6892902
--- /dev/null
+++ b/docs/ux/mockups/list__face_planar.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/list__face_planar__add.svg b/docs/ux/mockups/list__face_planar__add.svg
new file mode 100644
index 0000000000..fb80f2a5c7
--- /dev/null
+++ b/docs/ux/mockups/list__face_planar__add.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/list__none.svg b/docs/ux/mockups/list__none.svg
new file mode 100644
index 0000000000..1e68606383
--- /dev/null
+++ b/docs/ux/mockups/list__none.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/list__sk_line.svg b/docs/ux/mockups/list__sk_line.svg
new file mode 100644
index 0000000000..1b1c2e9cd2
--- /dev/null
+++ b/docs/ux/mockups/list__sk_line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/mockups/list__sk_none__create.svg b/docs/ux/mockups/list__sk_none__create.svg
new file mode 100644
index 0000000000..0b41268fa6
--- /dev/null
+++ b/docs/ux/mockups/list__sk_none__create.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/ux/offer_atlas.html b/docs/ux/offer_atlas.html
index 13ef9beac3..c92f694f28 100644
--- a/docs/ux/offer_atlas.html
+++ b/docs/ux/offer_atlas.html
@@ -36,6 +36,16 @@
+
Decision 0 — ring or vertical list
+
The live question. Both forms carry the SAME map and the same invariant — fixed order, never
+ re-sorted, nothing compacted; only the geometry differs. Left-click selects; right-click opens the
+ offer. What the list buys: a disabled row can state its own reason in the words the product
+ already ships, where an empty slot in a ring is mute; nine sketch primitives fit without an
+ overflow; shortcuts line up in a readable column; long translated names fit; and it is navigable
+ by arrow key and by screen reader, which a radial is not. What it costs: no equidistant flick
+ gesture, and travel to the last row is longer than to the nearest direction. Pairs below —
+ list first, the same state as a ring second.
+
Decision 1 — ring capacity
The same three selections at eight and at twelve. Eight keeps 45° between neighbours, which is
the reliable eyes-free pointing threshold and maps 1:1 to the numpad; twelve buys direct addresses
diff --git a/docs/ux/offer_atlas_inline.html b/docs/ux/offer_atlas_inline.html
index 51f7b28f61..36fdccf461 100644
--- a/docs/ux/offer_atlas_inline.html
+++ b/docs/ux/offer_atlas_inline.html
@@ -36,6 +36,16 @@
+
Decision 0 — ring or vertical list
+
The live question. Both forms carry the SAME map and the same invariant — fixed order, never
+ re-sorted, nothing compacted; only the geometry differs. Left-click selects; right-click opens the
+ offer. What the list buys: a disabled row can state its own reason in the words the product
+ already ships, where an empty slot in a ring is mute; nine sketch primitives fit without an
+ overflow; shortcuts line up in a readable column; long translated names fit; and it is navigable
+ by arrow key and by screen reader, which a radial is not. What it costs: no equidistant flick
+ gesture, and travel to the last row is longer than to the nearest direction. Pairs below —
+ list first, the same state as a ring second.
+
Decision 1 — ring capacity
The same three selections at eight and at twelve. Eight keeps 45° between neighbours, which is
the reliable eyes-free pointing threshold and maps 1:1 to the numpad; twelve buys direct addresses
@@ -64,4 +74,4 @@
Split, Pattern on Curve, Align to, Measure, Mass, Interference.
The states
-
Fresh document, nothing selected — the first-run pictureNothing selectedPlanar faceCylindrical faceCurved faceStraight edgeCircular edgeVertexSolid bodySheet bodyTwo bodiesDatum planeDatum axisCoordinate systemText / imported artClosed sketch loopSketch, nothing pickedSketch lineSketch arc or circleSketch pointTwo sketch entitiesPlanar face · Add material sub-ringPlanar face · Reference sub-ringSketch · Create sub-ring (the overflow case)Solid body · Remove sub-ring8 slots — planar face12 slots — planar face8 slots — sketch line12 slots — sketch line
+
Fresh document, nothing selected — the first-run pictureLIST · fresh document — every family present, the unavailable ones say whyRING · the same state — an empty slot cannot say anythingLIST · planar faceRING · planar faceLIST · sketch Create submenu — all 9 primitives fit, no overflowRING · the same submenu — 2 verbs pushed behind “More”LIST · planar face, Add material submenuRING · planar face, Add material sub-ringLIST · solid bodyLIST · straight edgeLIST · sketch lineRING · Nothing selectedRING · Planar faceRING · Cylindrical faceRING · Curved faceRING · Straight edgeRING · Circular edgeRING · VertexRING · Solid bodyRING · Sheet bodyRING · Two bodiesRING · Datum planeRING · Datum axisRING · Coordinate systemRING · Text / imported artRING · Closed sketch loopRING · Sketch, nothing pickedRING · Sketch lineRING · Sketch arc or circleRING · Sketch pointRING · Two sketch entitiesPlanar face · Add material sub-ringPlanar face · Reference sub-ringSketch · Create sub-ring (the overflow case)Solid body · Remove sub-ring8 slots — planar face12 slots — planar face8 slots — sketch line12 slots — sketch line