0010 — A grouping-unit screen in the Surface Experience
Status: candidate (decided the fast path — auto-descend to the first activity; a real grouping screen parked here) Opened: 2026-07-25 · Area: learner-web (surface experience) Reopen trigger: product decides a grouping deserves a browsable page of its own; a customer or tester reports the auto-descend as surprising (a tile/crumb that “skips” the grouping they clicked); or a corpus arrives whose groupings carry their own presentational content (an overview, a description, media) that has nowhere to render today.
The problem
Section titled “The problem”ADR-0050’s screen roles are dashboard and activity, and the activity screen
(/experience/$offeringId/$unitId) assumes $unitId is an activity unit. Nothing enforces that,
and the English Planet corpus tree is three levels deep inside an Offering (the importer maps that
corpus’s own Level to the Offering, its two intermediate levels to grouping units, and its leaves to
activity units — tools/scripts/import-english-planet/plan.ts), so a grouping reaches the
activity screen by three ordinary paths:
dashboard-tiles— its tiles are the top-level groupings (topLevelGroupings), and clicking one callsgoToUnit(grouping.id);breadcrumb— every ancestor crumb is a grouping, and crumbs are clickable;- deep links — the selection is URL state, so any unit id can be pasted or bookmarked.
What the learner got before the fix was a dead frame: the viewport resolved the grouping’s own
content (zero entries → a borrowed “No additional materials…” empty state) and both activity rails
rendered nothing, because a grouping’s children in this corpus are sub-groupings, not activities.
Decision taken (option A — auto-descend)
Section titled “Decision taken (option A — auto-descend)”The activity screen resolves a grouping to a concrete activity instead of rendering it:
resolveGroupingUnit(tree, unitId)inapps/learner-web/src/features/experience/lib/structure-tree.ts— pure and headless-tested. An activity or an unknown id →not-grouping(the screen’s existing behaviour, including its own fail-open, stays in charge). A grouping → the firstactivityin a depth-first preorder walk of its subtree, i.e. document order, so a direct activity child beats a deeper one under a later sibling. A grouping with no activity anywhere beneath it →no-activities.- The route effect in
apps/learner-web/src/routes/experience.$offeringId.$unitId.tsx— fires only once the sync store is hydrated (isReady && lastRefreshedAt !== null, the same gate and the same cold-deep-link reason asExperienceScreenView; an empty tree must not be read as an unknown unit).descend→navigate(..., { replace: true })to the activity, so Back still leaves the experience rather than bouncing through the grouping.no-activities→replaceto the experience dashboard with aconsole.warnnaming the grouping (loud, not blank). While a redirect is in flight the route renders the aria-busy skeleton placeholder instead of the screen, so the dead frame never flashes. The redirect cannot loop: the target is always an activity, which resolvesnot-grouping.
That is the whole seam — a future grouping screen deletes the route effect and the helper (and its tests), and nothing else in the experience rail knows about it.
Parked: option B — a genuine grouping screen
Section titled “Parked: option B — a genuine grouping screen”A grouping unit gets a page rather than a redirect: child-unit tiles, or a scoped mini-dashboard, in
the main slot — “here are the six activities in this grouping unit” rather than jumping into the
first one.
Two plausible mechanisms, neither chosen:
- Reuse
dashboard-tileswith a scope/root prop. The widget currently renderstopLevelGroupingsof the whole tree unconditionally; a prop (e.g.root: "current-unit") would make it tile the current unit’s children instead, which also means teaching it to tile activity children, not just groupings. Cheapest: no schema-shape change beyond one optional prop, and existing documents keep their meaning. But it loads a second job onto a widget whose current contract is “the offering’s top level”. - A new widget type (e.g.
child-units). Cleaner semantics, at the cost of a layout-document schema addition — additive and precedented (activity-rail’s optionalsegmentprop shipped that way, andwidgetInstanceSchemais a discriminated union plus a curated catalog), plus a registry entry, render-plan policy, and authoring/preview support.
Either mechanism also needs a screen role decision: does the grouping page reuse the dashboard
role’s layout, or does ADR-0050’s role set grow a third role that every experience must then author?
Open product questions (must be answered before this is scoped)
Section titled “Open product questions (must be answered before this is scoped)”- What does a grouping-unit page actually show? Child tiles alone, or progress/completion, a description, a resume point (“continue where you left off”)? Nothing today stores presentational content on a grouping unit, so an interesting page may imply new authoring surface, not just a new widget.
- Should breadcrumb grouping crumbs land there instead of auto-descending? A crumb is a deliberate “go up” gesture, so auto-descending it is arguably the most surprising of the three entry paths — it can behave differently from a dashboard tile. Deciding that also decides whether option A survives alongside option B for some paths (e.g. tiles descend, crumbs land) or is removed wholesale.