docs/ux: draw the offer as a vertical list too, and it wins

Tommaso was not sure about the ring and proposed a vertical list: left-click
selects, right-click exposes icon / name / shortcut. Drawn, it is better, and
the reasons are visible in the renders rather than arguable.

THE DISABLED ROW CAN SPEAK. This is the one that decides it. A ring slot that
does not apply is an empty circle: it says nothing, and on a fresh document six
of the eight are empty. A list row that does not apply is greyed IN PLACE with
its own name and its own reason — "Create a sketch, or pick a solid face,
first", "Create a solid body to pattern first" — which are strings the product
already ships and which tool_atlas.json already carries. The first-run picture
stops being a mostly-empty ring and becomes a map of what the product does and
what you must do first. For the audience section 2 puts first, that is the
whole ballgame.

THE OVERFLOW DISAPPEARS. Sketch Create needs nine addresses; a ring of eight
pushed Polygon and Point behind a "More" slot. Nine rows is just nine rows. The
one measured defect in the ring design is not a defect in this one.

SHORTCUTS READ AS A COLUMN. Right-aligned in a list they stack into something
the eye learns passively, which is exactly the graduation path 4.1 claims —
and it is the mechanism by which the power user Tommaso describes stops opening
the menu at all. Around a ring the same keys are eight loose chips.

Also, unglamorously: long translated names fit, arrow keys and screen readers
work natively where a radial needs special handling, and a 324px box costs the
1366x768 machine far less than a 380px disc over the model.

What the ring keeps: equidistant targets and a future flick gesture. Since the
brief is that power users live on the keyboard, that buys less than it looks.

The invariant is untouched — same eight families, same fixed order, nothing
re-sorted, nothing compacted. Only the geometry changed, which is the point:
the map survived a change of form factor, so it was a real map.

Both forms are now rendered side by side for the same states, and the atlas
opens with the pairs.

snaporca-96r.
This commit is contained in:
Tommaso Bianchi
2026-07-31 11:50:40 +02:00
parent eb2fc986a4
commit c4990ee956
10 changed files with 177 additions and 2 deletions
+149 -1
View File
@@ -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'<rect x="{x+3}" y="{y+4}" width="{RW}" height="{h}" rx="12" fill="#000" opacity="0.35"/>',
f'<rect x="{x}" y="{y}" width="{RW}" height="{h}" rx="12" fill="{C["chrome"]}" '
f'stroke="{C["chip_line"]}"/>',
f'<text x="{x+16}" y="{y+24}" font-family="Inter,DejaVu Sans,sans-serif" font-size="11.5" '
f'letter-spacing="1.1" fill="{C["dim"]}">{header.upper()}</text>',
f'<line x1="{x+1}" y1="{y+HD-6}" x2="{x+RW-1}" y2="{y+HD-6}" stroke="{C["line"]}"/>']
# a leader from the pick point to the menu, so the list is visibly ABOUT that geometry
g.insert(0, f'<path d="M{cx} {cy} L{x} {y+HD+16}" stroke="{C["chip_line"]}" '
f'stroke-dasharray="2 3" fill="none"/>')
g.insert(0, f'<circle cx="{cx}" cy="{cy}" r="5" fill="none" stroke="{C["hi"]}" stroke-width="2"/>')
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'<rect x="{x+5}" y="{ry+2}" width="{RW-10}" height="{RH-4}" rx="7" '
f'fill="{C["chip"]}"/>')
g.append(f'<g opacity="{op}">')
g.append(glyph(gl, x + 26, ry + RH / 2, C["text"], 0.72, 1.7))
g.append(f'<text x="{x+48}" y="{ry+RH/2+4.5}" font-family="Inter,DejaVu Sans,sans-serif" '
f'font-size="13.5" fill="{C["text"]}">{name}</text>')
if key:
kw = 13 + len(key) * 6.6
g.append(f'<rect x="{x+RW-18-kw:.1f}" y="{ry+RH/2-9:.1f}" width="{kw:.1f}" height="18" '
f'rx="4.5" fill="{C["key"]}"/>'
f'<text x="{x+RW-18-kw/2:.1f}" y="{ry+RH/2+4:.1f}" text-anchor="middle" '
f'font-family="Inter,DejaVu Sans,sans-serif" font-size="10.5" '
f'fill="{C["text"]}">{key}</text>')
elif count and count > 1:
g.append(f'<path d="M{x+RW-24} {ry+RH/2-5} L{x+RW-19} {ry+RH/2} L{x+RW-24} {ry+RH/2+5}" '
f'fill="none" stroke="{C["muted"]}" stroke-width="1.6" stroke-linecap="round"/>')
g.append(f'<text x="{x+RW-38}" y="{ry+RH/2+4}" text-anchor="end" '
f'font-family="Inter,DejaVu Sans,sans-serif" font-size="11.5" '
f'fill="{C["dim"]}">{count}</text>')
g.append('</g>')
if not on and reason:
r = reason if len(reason) <= fit else reason[:fit - 1].rstrip(" ,—-") + ""
g.append(f'<text x="{x+48}" y="{ry+RH/2+16}" font-family="Inter,DejaVu Sans,sans-serif" '
f'font-size="10" font-style="italic" fill="{C["dim"]}">{r}</text>')
if submenu:
sy = y + HD + (sub_at or 0) * RH - 6
sh = 12 + len(submenu) * RH
sx = x + RW + 8
g.append(f'<rect x="{sx+3}" y="{sy+4}" width="{RW-30}" height="{sh}" rx="12" fill="#000" opacity="0.35"/>')
g.append(f'<rect x="{sx}" y="{sy}" width="{RW-30}" height="{sh}" rx="12" '
f'fill="{C["chrome"]}" stroke="{C["chip_line"]}"/>')
for i, (gl, name, key, _c, on, _r) in enumerate(submenu):
ry = sy + 6 + i * RH
g.append(f'<g opacity="{"1" if on else "0.34"}">')
g.append(glyph(gl, sx + 24, ry + RH / 2, C["text"], 0.72, 1.7))
g.append(f'<text x="{sx+44}" y="{ry+RH/2+4.5}" font-family="Inter,DejaVu Sans,sans-serif" '
f'font-size="13.5" fill="{C["text"]}">{name}</text>')
if key:
kw = 13 + len(key) * 6.6
g.append(f'<rect x="{sx+RW-48-kw:.1f}" y="{ry+RH/2-9:.1f}" width="{kw:.1f}" height="18" '
f'rx="4.5" fill="{C["key"]}"/>'
f'<text x="{sx+RW-48-kw/2:.1f}" y="{ry+RH/2+4:.1f}" text-anchor="middle" '
f'font-family="Inter,DejaVu Sans,sans-serif" font-size="10.5" '
f'fill="{C["text"]}">{key}</text>')
g.append('</g>')
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}</svg>')
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):
</div>
</header>
<main>
<h2>Decision 0 — ring or vertical list</h2>
<p>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.</p>
<h2>Decision 1 — ring capacity</h2>
<p>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("<svg ", '<svg style="width:100%;height:auto;display:block" ', 1)
picks = [(f'rich__{s["id"]}.svg', s["name"]) for s in A["selections"]]
# Form factor first: this is the live decision, so it opens the page.
picks = [
("list__none.svg", "LIST · fresh document — every family present, the unavailable ones say why"),
("fresh__none.svg", "RING · the same state — an empty slot cannot say anything"),
("list__face_planar.svg", "LIST · planar face"),
("rich__face_planar.svg", "RING · planar face"),
("list__sk_none__create.svg", "LIST · sketch Create submenu — all 9 primitives fit, no overflow"),
("rich__sk_none__create.svg", "RING · the same submenu — 2 verbs pushed behind “More”"),
("list__face_planar__add.svg", "LIST · planar face, Add material submenu"),
("rich__face_planar__add.svg", "RING · planar face, Add material sub-ring"),
("list__body_solid.svg", "LIST · solid body"),
("list__edge_str.svg", "LIST · straight edge"),
("list__sk_line.svg", "LIST · sketch line"),
]
picks += [(f'rich__{s["id"]}.svg', "RING · " + s["name"]) for s in A["selections"]]
picks.insert(0, ("fresh__none.svg", "Fresh document, nothing selected — the first-run picture"))
picks += [("rich__face_planar__add.svg", "Planar face · Add material sub-ring"),
("rich__face_planar__reference.svg", "Planar face · Reference sub-ring"),
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

+10
View File
@@ -36,6 +36,16 @@
</div>
</header>
<main>
<h2>Decision 0 — ring or vertical list</h2>
<p>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.</p>
<h2>Decision 1 — ring capacity</h2>
<p>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
File diff suppressed because one or more lines are too long