diff --git a/docs/studies/README.md b/docs/studies/README.md index 76fb305..6a01d21 100644 --- a/docs/studies/README.md +++ b/docs/studies/README.md @@ -92,6 +92,9 @@ language authority. feedback from exercising that topology. - [Referential example data and read-only preview provenance](referential-example-data-and-read-only-provenance.md) records one preview-model experiment. +- [Carousel and the existing pager](carousel-and-pager.md) collects the + accessibility and scroll-snap prior art for the pager/carousel widget + candidate and proposes candidate answers to its open ownership questions. These entries are an inventory, not a roadmap. The index remains as a stable router even when its contents change. Removing a leaf means only that the diff --git a/docs/studies/carousel-and-pager.md b/docs/studies/carousel-and-pager.md new file mode 100644 index 0000000..3a06ec2 --- /dev/null +++ b/docs/studies/carousel-and-pager.md @@ -0,0 +1,313 @@ +# Carousel and the existing pager + +- **Status:** Non-normative candidate study for the future `ui` sub-specification +- **Lifetime:** Disposable study +- **Method:** Primary-source reading of the referenced specifications retrieved + July 20, 2026, plus direct verification against the code both before and + after the 0.4 replacement landed on `main` (#24, merge `896aa6b`) +- **Origin:** The "Carousel and existing pager" candidate in the widget + candidate landscape ([#19](https://github.com/gridaco/uhura/issues/19)) +- **Doctrine:** [Mission and identity](../doctrine/mission.md), + [authoring ergonomics](../doctrine/authoring.md), and + [design principles](../doctrine/principles.md) +- **Related study:** [Machine-first language, opt-in UI, and explicit framework + features](machine-first-language-and-opt-in-ui.md) +- **Authority:** Research only; this document reserves no syntax, accepts no + contract, and creates no obligation for the `ui` sub-specification + +## 1. Problem and non-goals + +Uhura ships a `pager` element whose observable contract has never been +settled. The candidate landscape asks five questions about it: the smallest +renderer-neutral contract; who owns the current page; how keys, inactive +slides, direction, page change, and static preview work; whether carousel is a +Pattern over pager, an opt-in compound widget, or a separate Element; and how +real controls, interactive indicators, autoplay, focus/hover stopping, reduced +motion, and non-drag alternatives are guaranteed. + +This study collects the external prior art, records what the implementations +actually do today, applies the machine/UI state-ownership boundary to the +contested state, and proposes candidate answers. + +Non-goals: + +- deciding `ui` grammar or attribute spelling; +- specifying autoplay scheduling or animation implementation; +- settling the Pattern/compound/Element taxonomy itself — this study only + places carousel within whichever tiers the `ui` sub-specification defines; +- touching the deterministic core language. + +## 2. What exists today + +### 2.1 The v0 spike contract + +The 0.3 spike catalog (`examples/instagram/client/catalog/base.toml`, +removed from `main` when #24 landed and preserved in git history) declares `pager` as a layout viewport with keyed children, an +`indicator` token (`none | dots`), a required `label`, and a `page-change` +event of kind `observe`. The catalog is self-describing about its own gaps: +the current page is "uncontrolled in the spike", and `page-change` is +"Declared for controlled use; the spike never binds it". + +### 2.2 Uhura 0.4 (merged as `main` by #24) + +On `main` after the replacement: + +- The checked vocabulary keeps `pager` as a native element with attributes + restricted to `indicator` (`none | dots`) and a required `label`, a + `page-change` event, and **no current-page, page-size, or direction + attribute** (`crates/uhura-check/src/ui_catalog/elements.rs` at the current + head; earlier revisions carried the same rules inside + `crates/uhura-check/src/checker.rs`). +- The web renderer projects `pager` to `role="group"` plus an `aria-label` + taken from `label`, builds a scroll-snap track + (`scroll-snap-type: x mandatory`; each child `flex: 0 0 100%; + scroll-snap-align: center`), and derives the active page purely inside the + renderer as `round(track.scrollLeft / track.clientWidth)` + (`web/src/renderer/primitives/pager.ts` after the primitive-adapter + refactor). Dots are non-interactive and marked `aria-hidden`. +- Since the primitive-adapter refactor that landed with #24, the renderer + **does** dispatch + `page-change`: the pager primitive fires it whenever its rounded active + index changes during scrolling. The catalog types the payload as `Unit` + (`crates/uhura-check/src/ui_catalog/elements.rs`), so the machine is told + *that* the page changed but not *which* page is current. Earlier states of + both v0 and 0.4 declared the event while binding it in zero renderers; no + example binds it yet. + +So the notification half of the contract has just been realized, but the +position half remains renderer-owned: with a `Unit` payload there is still no +way for a program to know or control which page is current, and dispatch +tracks rounded-index crossings during the gesture rather than resting snap +positions. Which page the machine may own, and in what vocabulary, is exactly +the question this candidate leaves open. + +### 2.3 ARIA APG carousel pattern + +The [APG Carousel pattern](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/) +supplies the accessibility half of the prior art: + +- The container takes `role="region"` or `role="group"` with + `aria-roledescription="carousel"` and a required accessible name. Each slide + takes `role="group"` with `aria-roledescription="slide"` (the tabbed variant + uses `tabpanel`), named individually — a "3 of 10" name is explicitly + admitted because `group` supports neither `aria-setsize` nor + `aria-posinset`. +- Previous- and next-slide **buttons are listed unconditionally as needed + features** (APG's wording is "needed", not "required"). A slide picker is + explicitly optional and comes in two shapes: a tabs-pattern picker (single tab stop) or + a grouped button picker where the current slide's button is + `aria-disabled="true"` (kept in the tab sequence deliberately). +- A rotation stop/start button is required **only if** the carousel + auto-rotates, must be first in the carousel's tab sequence, and carries no + toggle state — its label changes with the action instead. +- Auto-rotation must stop when keyboard focus enters the carousel (and must + not resume without an explicit request) and must stop while the pointer + hovers the carousel. A live-region wrapper is optional: `aria-live="off"` + while auto-rotating, `"polite"` otherwise. +- Activating rotation/previous/next does not move focus. +- The pattern says **nothing** about touch or swipe gestures and nothing about + reduced motion. The gesture half of a carousel is simply outside APG's + scope. + +### 2.4 CSS Scroll Snap + +[CSS Scroll Snap Level 1](https://www.w3.org/TR/css-scroll-snap-1/) supplies +the paging-mechanics half, and its own boundary is instructive: + +- The normative surface is geometric: snap positions are alignments of a + child's snap area within the container's snapport; `mandatory` obliges the + container to rest at a snap position when scrolling terminates; + `proximity` leaves even that to UA discretion; `scroll-snap-stop: always` + forbids passing over a snap position mid-gesture. +- Everything kinetic is deliberately unspecified: "The CSS Scroll Snap Module + intentionally does not specify nor mandate any precise animations or physics + used to enforce snap positions; this is left up to the user agent." +- After content changes, the UA must re-snap, and "if the scroll container + was snapped before the content change and that same snap position still + exists … the scroll container must be re-snapped to that same snap + position". The UA therefore already tracks *which element* is current — it + just never exposes it: Level 1 defines no event and no script- or + accessibility-visible notion of the snapped element. (Snap events are being + drafted in Scroll Snap Level 2; that attribution is from general knowledge, + not this document's references.) +- Axis and alignment resolve logically against the snap container's writing + mode, so an inline-axis `start` alignment is the right edge under RTL. + +The design lesson both sources agree on: specify the *resting states* and the +*semantic controls*, and leave the *motion between them* to the realization. + +## 3. Ownership analysis + +Apply the state-ownership boundary articulated for 0.4 (PR #24 discussion): +durable, program-observable state belongs to the machine; the UI may own +ephemeral interaction mechanics; observations flow down; semantic intent +returns through explicit, typed machine inputs; the UI must not silently +retain or mutate state on which program behavior depends. + +For a pager this splits cleanly: + +| Concern | Owner | Reasoning | +| --- | --- | --- | +| Current page, when anything observes it | Machine | Conditional content, analytics, guards, or persistence make it program-observable by definition | +| Current page, when nothing observes it | Renderer | Equivalent to the caret/IME allowance for `textfield`: pure interaction mechanics | +| Scroll physics, momentum, in-flight gesture offset | Renderer | Exactly what CSS Scroll Snap reserves for the UA | +| Page set, order, and identity (keys) | Machine (via markup projection) | The keyed children are projected data; re-snap identity depends on stable keys | +| Indicator rendering (dots) | Renderer | Mechanics today, and correct as mechanics — until indicators become interactive, at which point activation is semantic intent and must dispatch a typed input | +| Autoplay *policy* (whether/interval) | Declaration | An accessibility-governed behavior, not free renderer choice | +| Autoplay *scheduling* (timers, pause on hover/focus) | Renderer | Kinetics, like snapping physics | + +The one genuinely contested cell is the current page, and the textfield +precedent already resolves it: renderer-owned while unobserved, machine-owned +(controlled) the moment the program depends on it. The failure mode of leaving +it uncontrolled while pretending otherwise is precisely the declared but +never-bound `page-change` we have now. + +## 4. A small operational model + +A pager, renderer-neutrally, is: + +```text +pages : ordered list of keyed slides (keys unique, order meaningful) +current : key into pages (not an index; see re-snap rule) +axis : inline | block, resolved logically (RTL flips inline) +events : PageChanged(key) — semantic intent, emitted at rest, not per frame +invariant : current ∈ keys(pages); pages nonempty ⇒ current defined +``` + +Consequences worth making explicit: + +- **Key, not index.** CSS Scroll Snap's re-snap rule is element-identity + based: if the snapped element survives a mutation, the container re-snaps to + *it*, not to its old offset. An index-based contract cannot express that; a + key-based one gets it for free, and deleting the current slide becomes the + same well-defined question as deleting any keyed row. +- **Rest, not motion.** `PageChanged` fires when a scroll terminates on a new + snap position — mirroring `mandatory`'s end-state obligation — never during + the gesture. In-flight offset is renderer mechanics and is not observable. +- **Deterministic replay.** Because the event carries the resting key only, + a trace replays identically regardless of gesture physics, which keeps the + self-verifying preview property intact. + +## 5. Adversarial cases + +- **Current slide deleted.** Machine updates `pages`; the invariant forces a + new `current`. Candidate rule mirroring the CSS ambiguity clause: nearest + following key, else nearest preceding. A controlled pager makes this a + checkable machine decision instead of UA-defined drift. +- **Stale page intent.** User swipes to a slide the machine has meanwhile + removed: `PageChanged(key)` arrives with a dead key. This is exactly the + 0.4 result taxonomy's `stale` outcome; no new machinery is needed. +- **Single page.** Previous/next controls have nothing to do; the APG + expectation that they exist collides with a degenerate page set. Candidate: + controls render disabled rather than vanish, keeping the tab order stable. +- **Autoplay under reduced motion.** APG is silent on reduced motion; CSS + leaves motion to the UA. If the platform signals reduced motion, candidate + rule: declared autoplay is suppressed and the optional live region behaves + as "not automatically rotating" (`polite`). This must be a conformance + rule, not renderer goodwill, precisely because neither upstream source + covers it. +- **RTL.** If `axis` resolves logically, "next" under RTL moves left. The + contract must say whether previous/next controls are logical (recommended, + matching Scroll Snap's writing-mode resolution) or physical. +- **Mid-gesture machine write.** The machine sets `current` while a drag is in + flight. Renderer owns the gesture; candidate rule: the write wins at gesture + end unless the gesture itself terminated on a different snap position after + the write — the same last-writer question every controlled input has, and + it should be answered once, in the `ui` sub-specification, for all + controlled mechanics. + +## 6. Static checking and runtime consequences + +Checkable today, with no new machinery: + +- keyed slides with a checked key type (the 0.4 checker's ui `each` key + rules already exist); +- required `label` (already enforced in the 0.4 checker); +- `page-change` bound ⇒ payload type matches the declared machine input; +- **the reverse obligation**: an element must not declare an event in the + checked vocabulary that no conforming renderer realizes — the v0 pager + fails this, and the 0.4 primitive-adapter refactor now satisfies it, which + demonstrates the obligation is realistic to enforce. + +Checkable once controlled current-page exists: + +- controlled/uncontrolled consistency: a pager whose current page is read + anywhere must bind `page-change` (the analogue of the controlled + `textfield` spelling); +- autoplay declared ⇒ rotation control present in the realization contract. + +Runtime: stale keys map to `stale`, malformed payloads to `invalid`, guarded +refusals to `blocked` — the existing result vocabulary covers the pager +without extension. + +## 7. Renderer and boundary effects + +- **Web realization** keeps exactly what it has: scroll-snap track, physics, + in-flight offset, dot synchronization, and the newly wired unit + `page-change`. The candidate changes the dispatch to a position-bearing + payload emitted at rest, and adds the option of driving position from a + controlled `current`. +- **Static preview pose.** The candidate landscape's framing lists "a + scrolled viewport" as a pose problem. A key-based `current` doubles as the + declarative pose: a static preview of a pager at slide `k` is well-defined + without any gesture machinery. The same shape should generalize to + `scroll`'s preview pose. +- **Accessibility projection.** The APG mapping is mechanical from the model: + container name from `label`; per-slide names ("n of m" admitted); controls + as real buttons. Today's realization stops at `role="group"` + + `aria-label` with no roledescription, no slide names, and no controls — + the gap between the two is a candidate conformance checklist, not a design + question. +- **Editor.** The editor canvas needs the pose, the key list, and the + indicator declaration — all present in the model; nothing editor-specific + leaks into the contract. + +## 8. Migration + +- v0/0.4 markup (`label`, `indicator`, keyed children) is forward-compatible + unchanged; every addition (controlled `current`, bound `page-change`, + `axis`) is opt-in. +- The observe-kind `page-change` of the spike maps onto a typed machine input + under the 0.4 event model, following whatever general observe→input mapping + the `ui` sub-specification adopts for the other declared events. +- No existing example binds `page-change`, so nothing breaks by defining it + properly; the instagram spike gains the option of making story/media paging + observable. + +## 9. Candidate answers to the issue's questions + +1. **Smallest renderer-neutral contract:** keyed slides + resting current + page (key) + logical axis + required label + indicator token. Everything + kinetic excluded, mirroring the Scroll Snap normative split. +2. **Current page ownership:** renderer-owned while unobserved; + machine-owned (controlled, with `PageChanged` at rest) the moment any + program behavior depends on it — the textfield rule applied to paging. +3. **Keys, direction, preview:** keys mandatory and identity-bearing + (re-snap follows the element, so must the contract); direction logical + with RTL resolved as in CSS writing modes; static preview = declared + current key, which also answers the pose problem. +4. **Carousel's tier:** carousel adds only *semantic controls and rotation + policy* on top of pager mechanics — prev/next buttons, optional picker, + rotation control — all of which are ordinary buttons plus one declaration. + The evidence therefore favors **composition over a new Element**: a + Pattern (or compound) over `pager` + `button`, in whichever tier the `ui` + sub-specification provides for named compositions. A separate Element is + justified only if autoplay policy cannot be declared compositionally. +5. **Guarantees:** APG's baseline features (real prev/next buttons; rotation + control iff autoplay; focus/hover stopping; no focus theft) become + conformance obligations of the carousel composition. Reduced motion and + touch alternatives are **not covered by either upstream source** and are + exactly where Uhura's contract must go beyond prior art: candidate rules — + reduced motion suppresses declared autoplay; every gesture-reachable page + is control-reachable (the required buttons already guarantee this). + +## 10. Open questions + +- The general observe→typed-input mapping for `ui` events (this study assumes + it; the sub-specification owns it). +- The last-writer rule for machine writes during in-flight gestures, shared + with all controlled mechanics. +- Whether the composition tier ("Pattern") the answer to question 4 relies on + is itself part of the `ui` sub-specification's initial scope. +- Whether `indicator` grows an interactive form (picker), which would move + indicator activation from mechanics to semantic intent.