Radial menus — stable targets and measured wedge typography¶
Use this competency for a pie, marking, or radial menu whose commands combine a glyph with a short label and optional status, cost, or shortcut. It joins two proven implementations:
- Psychodeli consumerUI contributes config-driven main/sub-pies, split rings, two-tone 24-grid glyphs, live-state accents, nested Back, and theme tokens.
- Pot Luck contributes invariant target geometry, radial keyboard movement, direct touch activation plus hold-to-preview, captions for consequences, and shape-aware type that gives the label and cost priority over ornament.
This is a visual and interaction competency, not a standalone engine. Keep each product's state, commands, gating, and side effects in its canonical owner. A future shared library may implement this contract; the contract does not require either product to adopt a speculative common runtime.
This is also one specialization of the broader
annular-interfaces.md contract. Use the broader
reference when angle or radius encodes bearing, range, time, magnitude,
hierarchy, or targeting rather than a directional command. A circular silhouette
does not make those instruments menus.
Use gate¶
Choose a radial menu when spatial recall or rapid directional selection is part of the value, and the set is small enough to remain distinguishable. A useful default range is three to eight primary wedges. Test beyond eight rather than assuming that more radius creates more comprehension.
Prefer a linear menu when commands are unfamiliar, descriptions carry the decision, order or comparison matters more than direction, or zoom and text scaling would push the labels below the physical size floor. Do not hide a destructive, legal, financial, or permission consequence inside an unlabeled glyph or a gesture-only path.
Shared contract¶
State and architecture¶
- Accept config and emit intent. The renderer does not read application globals or mutate product state.
- Keep
open, active wedge, submenu path, and input modality local to the menu instance. Command availability and on/off state remain canonical product data. - Preserve one geometry primitive for every annular sector. Main rings, sub-rings, split bands, and decorative rails derive from it.
- Treat visual composition as a projection. Pretext handles measurement and line breaking; ordinary DOM, SVG, or a hybrid of both remains the semantic, focus, hit-test, and theme layer. Pretext does not require a canvas renderer.
Target geometry¶
- Wedge boundaries do not move on hover, focus, selection, or live-state change. Emphasis changes paint, type, or an interior rail, never the target under the pointer. This preserves motor plans and prevents neighbor motion while aiming.
- Make every pointer target at least 40 × 40 physical px; prefer 44 × 44 for touch. Measure the narrowest useful part of the sector, not only its outer arc.
- Keep visual gaps smaller than hit gaps when safe: the interactive path owns the full stable sector while an inset paint path supplies the groove.
- Recompute composition after a real size, font, locale, item, or ring change. Do not poll layout in an animation loop.
Information priority¶
Use this admission order:
- command label;
- exact cost, quantity, shortcut, or consequence when it changes the decision;
- status glyph;
- command glyph;
- decorative material.
Status is shape, not wedge copy. Model it as a semantic token such as on,
off, live, ready, warning, or locked, render it through a consistent
status-glyph family, and preserve a natural-language statusLabel in the
accessible name and consequence caption. Do not print implementation phrases
such as INPUT OFF, or routine state words such as ON, OFF, LIVE, and
READY, inside the wedge.
Do not turn an exact decision value into a pictogram. $350, 1 AP, 12.0 oz,
inventory counts, heat changes, and shortcuts remain text because the magnitude
matters. A status glyph must differ by silhouette as well as color so the state
survives color-vision differences and monochrome rendering.
The accessible name and consequence caption keep the full source copy even when the visible surface needs a shorter display label. If no legal glyph pocket remains, hide the glyph. If no legal label clears the 16 px physical floor, move the full choice to a linear sheet or another larger surface; do not silently shrink it.
Composition rhythm¶
Treat every peer wedge as one member of a ring, not as an isolated packing problem. Select one label-size tier that every peer can legally render. A short label does not become larger merely because it has spare chord width, and a status marker does not make its neighboring label smaller. Wrap the longer label, lower the shared tier, or move the command to a larger surface.
Keep a stable semantic order from the outer side of the stack inward:
- optional status rail;
- command-glyph rail;
- one- or two-line command-label rail;
- optional exact-value rail.
An absent optional rail may collapse, but the remaining rails never reorder or run inline to exploit a local gap. In particular, do not append a status glyph to the label or append an inventory count to the command name. The rail gap is a measured constraint, not a literal space character or an accidental flex gap. This stable grammar is more important than maximizing the mean font size.
Input parity¶
- Pointer: moving over a wedge previews it; click or tap commits once.
- Touch/pen: a short tap commits. A hold may preview detail, but consume the synthetic click so preview cannot also trigger the action.
- Keyboard: Left/Right rotate around the current ring; Up/Down move between outer ring, inner ring, and hub; Home/End reach ring endpoints; Enter/Space commit; Escape backs out one submenu before closing.
- Keep one predictable tab entry into the instrument, then expose the active descendant or a roving focus item to assistive technology. Every actionable sector has a role, accessible name, availability, and current/pressed state.
- A submenu transition hands focus to the new ring; Back restores the wedge that opened it. Repeated input cannot land on a replacement wedge after the DOM swaps under the pointer.
Branches: a submenu on the next orbit¶
When a ring is a fan of round targets on an orbit (Protoplasm's tool arc round
the hue ring, show-corners.css) rather than a pie of wedges, a submenu is a
branch off that ring, not a replacement ring. The replacement version fails
in two ways that only show once the parent is not at the end of the arc: the
second level looks like the first (same plates, nearly the same orbit), and
nothing says which parent it came from. The branch contract, implemented in
Protoplasm's pen-tools.ts:
- The parent stays lit where it was tapped. Its target does not move,
hide, or change size (the stable-target rule above);
aria-expandedlights it. Tapping it again closes the branch and keeps it selected, so Back is the parent, not a leaf of the branch. Escape returns focus to the parent; opening moves focus to the child that is currently on. While the branch is open the parent shows its own glyph: the lit child already says which choice is on, and repeating it on the parent puts two identical lit plates on the screen. Closed, the parent may summarise the choice (the Pen shows its stamp). - Siblings recede and go inert. Same treatment as the other corners while a
branch is open (opacity,
pointer-events: none), so a tap on one falls through to the outside-tap rule, which only backs out and consumes the gesture. Keeping them visible is what makes the levels distinguishable: the dim ring is the level above, the lit parent and its children are the level below. - Children take the next orbit out, centred on the parent's angle. Radius is
depth, as in
annular-interfaces.md. Measure the parent's radius and angle from the rendered ring (getBoundingClientRect), so every media query and safe-area inset is already in the numbers: child orbitR = parent orbit + target + channel, step2·asin((target + gap) / 2R). Protoplasm uses a 44 px target, a 12 px channel and a 4 px gap. - Clamp the fan to the screen, then let the connector absorb the slide. Compute the window of angles whose plates stay inside the ring's own inset and clear of the nearest neighbouring control (the dish corner's Clear button on a landscape phone), and slide the fan's centre along the orbit until it fits. The parent does not move; the rail reaches back to it.
- Connect with a bracket, not spokes. A stem from the parent's plate to a
rail at mid-channel, and the rail spanning the children (and the stem, when
the fan slid). Spokes from the parent to each child fail at six or more
children: the outer spokes pass within a few pixels of the neighbouring
plates and read as crossings. The rail groups the children by uniform
connectedness (Palmer & Rock 1994, a perceptual-grouping result with
empirical support) and the stem assigns the group to the parent. Keep it
below the plates and their glyphs in the hierarchy: a 1.5 px line at mid
lightness, not the plate's cream, over a plate-black halo so it survives a
bright projection behind it. It is a connector, so the text-contrast floor
does not apply. Protoplasm colours it with the complement of the current
dye's hue (
hsl(hue + 180, 55 %, 52 %)), a palette colour, so the branch belongs to the colour corner it grew from and changes with the dye. Recompute on resize while open; the connector is a projection of the same measured geometry as the targets, never a second layout.
What this establishes and what it does not: the rendered checks prove target size, on-screen placement at phone portrait, phone landscape and desktop, one commit per tap, and focus return. That the bracket shortens level recognition or reduces wrong-level taps is the design hypothesis; it needs a task observation (wrong-level entries, acquisition time) before it counts as more than precedent.
Pretext wedge compositors¶
Pretext earns its cost when labels, command glyphs, exact values, and status
glyphs need to share irregular or variable-width sectors. It is a layout engine,
not an output medium: use its
measurements and line ranges to render ordinary DOM, SVG, Canvas, or another
host-native surface. Native CSS/SVG alone is still better for a fixed one-word
label at a known size, and SVG textPath is better for a decorative group name
that intentionally follows an arc.
Renderer choice¶
Choose the renderer from the host rather than from Pretext:
- DOM content over SVG targets — recommended for the current Psychodeli and
Pot Luck carriers. Keep the stable SVG wedge paths as semantic and hit-test
geometry. Place an absolutely positioned,
pointer-events: noneDOM layer above the wheel, then materialize the selected line fragments as spans and inline SVG glyphs. This keeps CSS typography, localization, selection, and browser inspection available without duplicating command state. - DOM-native wedges. When the host already uses buttons, apply the same
generated path or polygon as CSS
clip-pathand render the Pretext fragments inside each button. Verify clipped pointer hit-testing, focus indication, and non-overlapping tab order in every target browser before choosing this path. - SVG-native content. Materialize fragments as SVG
<text>and<svg>when the wheel already owns all visual content, exact viewBox scaling is valuable, or a single exportable vector artifact is the requirement.
Do not use foreignObject merely to claim DOM support. A normal positioned DOM
overlay is easier to theme and test, while the shared geometry can still map
each slot between viewBox coordinates and CSS pixels.
Pipeline¶
- Load a named font before measurement. Await
document.fonts.load()for every weight and size tier. Do not usesystem-ui; canvas and DOM can resolve it differently on macOS. - Declare rails before measuring copy. Reserve separate status, command, label, and exact-value line boxes in that order. The compositor may collapse an absent optional rail; it may not place a glyph or value beside the label.
- Turn text rails into planning slots. For horizontal copy, intersect each candidate label or value line box with the padded annular sector and keep a continuous interval whose corners and edge midpoints remain inside the wedge. For an arc-follow probe, use the guarded arc length at that label rail's radius. Do not wrap against a horizontal chord and bend the result afterward; that creates false line breaks and crowds the inner rails.
- Prepare text once per style tier. Give Pretext the label words at their real named font and the exact value as its own unbreakable item. Command and status glyphs are fixed SVG pockets, not rich-inline text items.
- Search ring-wide tiers. At each candidate physical label size, compose every wedge with one or two label lines. Accept the first tier for which the entire peer ring passes; never retain a larger local tier for an easier wedge.
- Apply hard gates before scoring position. Reject overflow, missing label or value content, physical text below 16 px, text contrast below 8:1, a glyph below its legible minimum, or a rail gap below its declared minimum. Among survivors at the shared tier, score vertical balance and chord use.
- Materialize only the ring winner.
materializeRichInlineLineRange()provides fragments, widths, gaps, and source-item indices for the text rails. Turn those into DOM spans, SVG text, or Canvas draws. An arc-follow renderer may segment the winning fragments into graphemes only at this stage, then position each grapheme along the planned rail. Place the 24-grid glyphs in their fixed rails and keep the original item data foraria-labeland captions. - Close the loop against rendered truth. After fonts and paint settle,
measure the actual DOM or SVG fragments with
getBoundingClientRect(). Reject a candidate whose rendered bbox crosses the padded wedge even when Pretext's predictive canvas measurement fitted. Also audit peer type variance, the minimum rendered gap between glyph and text rails, and the gap between a curved label and its exact-value rail.
Pin Pretext and preload fonts. The runnable
radial-menu-pretext.html example vendors
@chenglou/pretext 0.0.5 locally and compares a fixed midpoint stack with the
shape-flow compositor.
Composition sketch¶
const labelItems = label.split(/\s+/).map((word, index) => ({
text: `${index ? ' ' : ''}${word}`,
font: `800 ${sharedLabelSize}px Arial`,
break: 'never',
}))
const prepared = prepareRichInline(labelItems)
let cursor = { itemIndex: 0, segmentIndex: 0, graphemeIndex: 0 }
for (const slot of labelRailSlots) {
const range = layoutNextRichInlineLineRange(prepared, slot.width, cursor)
if (!range) break
render(materializeRichInlineLineRange(prepared, range), slot)
cursor = range.end
}
The command and status glyphs are placed independently in their declared rails. Pretext decides line breaks inside the label and exact-value rails; it does not decide semantic order or use spare label width to pull glyphs inline.
Pretext permits a break: 'never' item to overrun an otherwise empty line. That
is useful for pills and unbreakable tokens, but an annular sector is a hard
boundary. After each range, reject the candidate when line.width > slot.width
and continue the search at a smaller type tier. Split a multi-word command into
atomic word items so wrapping can occur between words without producing
Harves / t or another mid-command fracture.
Experimental per-grapheme bows and carrier selection¶
Curved command labels are a probe, not the radial-menu default. Use a shallow bow only when a short label underuses the outer part of a sector. Value-dense rings and multiword commands default to upright word lines; a literal circle is not an upgrade when it makes the reader rotate the word mentally.
- Keep the complete command and state in the semantic SVG/DOM target. The
positioned letter layer is visual projection and may remain
aria-hidden. - Let Pretext choose the shared type tier and word breaks against guarded arc
length. Then segment the winning fragments with
Intl.Segmenterand place graphemes individually; never rotate one rigid string around its midpoint. - Preserve word spacing when materializing letters. Measure cumulative prefixes
so font kerning survives, and reparameterize a line-to-arc blend by its local
path speed. Naively interpolating Cartesian glyph centers contracts side-wedge
words and makes pairs such as
MoandCocollide. - Separate path follow from letter turn. The first blends positions from a horizontal rail to a circular rail. The second independently blends each letter from upright toward the local tangent. This makes restrained hybrids testable instead of coupling curvature to an unreadable 90-degree turn.
- Reverse traversal where necessary to keep lower-half letters upright. Start a command probe near 30% path bow and 10% orientation follow, then verify the actual production font. The reference wheel resolves this to about 2.4 degrees of letter turn; the earlier 42-degree result failed visual reading.
- Keep command glyphs and routine-status glyphs upright on their own outer rails. Keep decision-changing exact values horizontal on a separate inner rail unless a product test establishes a better grammar.
- Compute label capacity from the arc before Pretext wraps. After DOM paint, reject any glyph bbox outside the wedge or any label/value collision. A legal horizontal endpoint may use two word lines; an illegal endpoint hard-stops rather than disappearing through clipping.
- Select the carrier from the content before styling it. Short command rings may
use the shallow DOM bow. A ring where at least half the wedges carry exact
values uses an upright stack. Preserve the full command in
aria-labeland the consequence caption; a shorter unambiguousdisplayLabelmay protect the wedge, asConnectionsdoes forWork Connections, $350.
An arc variant expands only after real-use evidence shows equal or better command recognition, acquisition time, and error rate than the horizontal carrier. Fit and novelty alone do not establish the upgrade.
iBlipper DOM precedent¶
iBlipper's PretextRenderer.tsx demonstrates the DOM path at production scale:
it waits for the named font, uses prepareWithSegments() and
layoutWithLines() to choose wrapping and font sizes, then renders normal React
.pretext-chunk and .pretext-line elements. After two animation frames it
reads each line's getBoundingClientRect() and records fill, visible fill, and
truncation from the actual DOM.
Carry its measurement-parity lessons into wedge work:
- measure the transformed text that CSS will paint (
uppercasein iBlipper), not the pre-transform source string; - budget for CSS
letter-spacing, stroke, per-character wrappers, and other effects that Pretext's canvas measurement does not see; - treat emoji and platform-substituted glyphs as a canary because their DOM extent can materially exceed canvas metrics;
- distinguish raw overflow from visible clipped area; clipping can protect the surface without proving that the composition is well fitted;
- keep the post-render audit even when the predictive fit usually agrees.
For a command wedge, prefer rejection and the next smaller legal composition to clipping readable content. Clipping is appropriate for decorative trails, not for a label, cost, shortcut, availability reason, or glyph needed to decide.
Glyph grammar¶
- Author on a 24 × 24 grid with round joins/caps unless the product has another established family.
- Use one primary structural color and at most one live/accent element. The second tone carries state or meaning, not decoration.
- Verify at the real 18–24 px glyph size. Large-source beauty is irrelevant if the mark collapses in its wedge.
- Do not use emoji as the primary command grammar. Platform substitution changes weight, metrics, color, and sometimes meaning.
- Keep command and status glyphs upright. Horizontal command labels remain the default. An explicit per-grapheme arc probe may turn individual letters within a tested ceiling; never rotate the icon or a whole command string as one unit.
Verification matrix¶
Exercise the real content, not lorem ipsum:
| Dimension | Minimum proof |
|---|---|
| Density | 3, 5, and 8 wedges; primary and nested/split ring |
| Copy | shortest label, longest production label, state/cost, disabled reason |
| Status grammar | no printed routine state words; glyph has shape + accessible name |
| Size | desktop plus phone-sized same-origin iframe at the target CSS width |
| Script | English plus one CJK or RTL canary when localization is in scope |
| Input | pointer preview/commit, short touch, hold preview, keyboard ring + depth |
| State | default, focus, selected/on, unavailable, locked, submenu, Back |
| Geometry | every visual bbox inside its sector; target paths byte-stable across focus |
| Type | named fonts loaded; every readable line at least 16 physical px; 0 px peer-label variance unless hierarchy is explicit |
| Rhythm | declared rail order preserved; measured glyph/text and label/value gaps do not collapse |
| Carrier | short-label bow and value-dense upright stack both exercised with the same semantic targets |
| Arc probe | path follow and letter turn tested independently; kerning preserved; path contraction prevented; per-letter rotation recorded |
| Contrast | in-browser effective foreground/background audit at least 8:1 |
| Behavior | one commit per activation; focus survives submenu swap and return |
For optimization work, measure more than fit. Log task-local acquisition time, pointer corrections, wrong-wedge entries, hold cancellations, and keyboard step count. Expand the Pretext path only if it improves fit or comprehension without regressing acquisition or input parity.
Stop conditions and fallbacks¶
Stop the radial approach or fall back to a linear surface when:
- any production label requires text below 16 physical px;
- more than one wedge needs a caption to identify the command itself;
- localization produces routine four-line labels or unstable abbreviations;
- narrow sectors fail 40 × 40 target geometry;
- focus order and visible radial order cannot be made to agree;
- the menu contains heterogeneous controls whose interaction cannot be expressed as inspect, commit, enter submenu, or Back.
Do not add animation, fisheye expansion, or arc-follow command text to compensate for an information-architecture failure. An arc probe must still satisfy every label, consequence, input, and fallback requirement above.