From 538789ac36ec450e55a7013031f283317d20e22f Mon Sep 17 00:00:00 2001 From: Ivor Zhou Date: Fri, 23 Oct 2026 10:31:26 -0700 Subject: [PATCH] docs(spec): Tokenizer records the experimental touch sheet presentation (DEC-3) component:Tokenizer gains DEC-3: the touch surface is the presentation option of the optional experimental prop object (popover | bottom-sheet | adaptive), resolved through the shared compact-touch policy, with the single searchable sheet's behavior recorded as FR16-FR24, AR6 and PR6. Omitting experimental keeps the inline combobox. Rows main does not meet yet carry implementation gap notes. --- docs/families/input-fields.md | 6 +- packages/core/src/Tokenizer/Tokenizer.spec.md | 371 ++++++++++++++---- 2 files changed, 304 insertions(+), 73 deletions(-) diff --git a/docs/families/input-fields.md b/docs/families/input-fields.md index f26743fd0f1a4..6874501d7986e 100644 --- a/docs/families/input-fields.md +++ b/docs/families/input-fields.md @@ -176,8 +176,10 @@ another implementation helper. - **FR7 — Component-owned source work keeps family geometry and accessibility.** Typeahead and Tokenizer own asynchronous search state through BaseTypeahead, not through the family `isLoading` meaning. Their visible source-busy feedback - still MUST be named, expose busy semantics on the combobox, and satisfy FR1, - FR2, and FR3 where grouped. + still MUST be named, expose busy semantics on the combobox — or, in a + presentation without a combobox, such as Tokenizer's sheet + (`component:Tokenizer` DEC-3), on the result list the search updates — and + satisfy FR1, FR2, and FR3 where grouped. - **FR8 — Status placement follows member capability.** Every input MUST offer `detached` and `tooltip`. A member supports `attached` only when its direct control is opaque, bordered, fixed-height (a height set by its resolved size, diff --git a/packages/core/src/Tokenizer/Tokenizer.spec.md b/packages/core/src/Tokenizer/Tokenizer.spec.md index a021a59ff0e82..4bad54c7e2306 100644 --- a/packages/core/src/Tokenizer/Tokenizer.spec.md +++ b/packages/core/src/Tokenizer/Tokenizer.spec.md @@ -6,8 +6,8 @@ id: component:Tokenizer authority: current archive_reason: null superseded_by: null -approved_by: cixzhang -approved_at: 2026-09-19 +approved_by: imdreamrunner +approved_at: 2026-10-23 owners: [cixzhang] review_triggers: [public-api, behavior, layout, accessibility] verified_by: @@ -27,20 +27,36 @@ architecture: architecture:public-component-api, ] contributing: [contributing:api-conventions] -system_specs: [spec:AST-002/DEC-1] +system_specs: + [ + spec:AST-002/DEC-1, + spec:AST-003/FR18, + spec:AST-009/FR8, + spec:AST-012/FR9, + spec:AST-017/FR21, + spec:AST-017/FR60, + spec:AST-020/FR1, + spec:AST-056/FR3, + spec:AST-058/FR9, + spec:AST-085/FR1, + spec:AST-085/FR4, + spec:AST-085/FR5, + spec:AST-086/FR14, + spec:AST-094/FR13, + ] --- # Tokenizer component contract -| Area | Contract | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | None. The existing `hasEntriesOnFocus` mode gains a defined post-selection lifecycle; no prop, type, export, or syntax changes. | -| Behavior | A committed empty-query bootstrap choice keeps remaining eligible loaded choices open, filters committed IDs, and advances the active option; terminal states close safely. | -| End-user impact | People selecting several loaded values can continue without reopening the menu, while disabled, loading, exhausted, and dismissed states remain non-interactive. | -| Builder impact | None. Existing opt-in callers need no migration or refetch, and typed-query selection remains unchanged. | -| Compatibility/readiness | The default `hasEntriesOnFocus={false}` path and direct single-select BaseTypeahead remain unchanged. This decision is current and owner-approved; implementation and exact-head interaction evidence remain pending in #6360. | -| Review checks | Reject implementations that filter by label instead of ID, reorder eligible choices, lose focus or a valid active descendant, refetch unnecessarily, or let disabled, exhausted, dismissed, or stale-loading states remain interactive or reopen. | -| Governing rules | This canonical `component:Tokenizer` record: [FR13–FR15](#behavioral-and-layout-contract), [AR5](#accessibility-contract), and [DEC-2](#dec-2--focus-bootstrap-supports-consecutive-committed-selections). | +| Area | Contract | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | One optional `experimental` prop object whose `presentation` option selects the surface (DEC-3); the stable props, exports, and syntax are unchanged, and `TokenizerProps` stays an interface whose `ref` is `Ref`; only with `experimental` passed does the component accept a wider `ref`. The existing `hasEntriesOnFocus` mode has a defined post-selection lifecycle (DEC-2). | +| Behavior | A committed empty-query bootstrap choice keeps remaining eligible loaded choices open, filters committed IDs, and advances the active option; terminal states close safely. The `bottom-sheet` presentation replaces the inline combobox with a trigger that opens one searchable modal sheet of checkbox rows (FR16–FR24). | +| End-user impact | People selecting several loaded values can continue without reopening the menu, while disabled, loading, exhausted, and dismissed states remain non-interactive. On a compact touch screen, an opted-in field offers thumb-sized rows and a search field the keyboard does not cover. | +| Builder impact | None for existing callers. A caller opts into the touch surface with `experimental={{presentation: 'adaptive'}}` or `'bottom-sheet'`, accepting that the option may change or be removed in a patch release. | +| Compatibility/readiness | Omitting `experimental`, and the default `hasEntriesOnFocus={false}` path, keep today's inline combobox and direct single-select BaseTypeahead unchanged. DEC-2 is current and owner-approved with implementation and exact-head evidence pending; DEC-3 is current with the implementation gap recorded on its rows. | +| Review checks | Reject implementations that filter by label instead of ID, reorder eligible choices, lose focus or a valid active descendant, refetch unnecessarily, or let disabled, exhausted, dismissed, or stale-loading states remain interactive or reopen. Reject a sheet presentation that switches without `experimental`, moves an existing row on toggle, or clears every token without confirmation. | +| Governing rules | This canonical `component:Tokenizer` record: [FR13–FR24](#behavioral-and-layout-contract), [AR5–AR6](#accessibility-contract), [DEC-2](#dec-2--focus-bootstrap-supports-consecutive-committed-selections), and [DEC-3](#dec-3--the-touch-sheet-presentation-is-an-experimental-option). | ## Intent @@ -49,16 +65,22 @@ tokens around one stable combobox input. It owns the field surface, token/input layout, suggestion-menu relationship, and the package-internal identity needed to open a related surface from the control where the task began. -This record preserves shipped Tokenizer facts and defines two component-owned -contracts: one package-internal opening-control descriptor for Core peers, and the -post-selection lifecycle of an empty-query bootstrap menu. The descriptor lets -Layer prepare placement without exposing DOM refs, pointer coordinates, or width -policy to consumers. The dependent [PowerSearch draft](https://github.com/facebook/astryx/pull/5804) +This record preserves shipped Tokenizer facts and defines three component-owned +contracts: one package-internal opening-control descriptor for Core peers, the +post-selection lifecycle of an empty-query bootstrap menu, and an experimental +touch presentation in which a trigger opens one searchable modal bottom sheet in +place of the inline combobox. The descriptor lets Layer prepare placement without +exposing DOM refs, pointer coordinates, or width policy to consumers. The +dependent [PowerSearch draft](https://github.com/facebook/astryx/pull/5804) remains evidence for the need, not authority for this record. +Where the shipped Tokenizer does not yet meet a requirement, the row carries a +present-tense implementation gap note. + ## Compatibility and migration -- The public API and the default `hasEntriesOnFocus={false}` path are unchanged. +- The stable public API and the default `hasEntriesOnFocus={false}` path are + unchanged. - Compatibility class: intentional behavior correction for the existing opt-in `hasEntriesOnFocus` mode. A committed selection from its current empty-query bootstrap cohort keeps the remaining eligible loaded choices available instead @@ -66,6 +88,12 @@ remains evidence for the need, not authority for this record. - Controlled/uncontrolled ownership is unchanged. Retention follows committed controlled values; a proposed value that the caller has not accepted remains available. +- Experimental surface (DEC-3): the touch sheet presentation is the + `presentation` option inside the optional `experimental` prop object, outside + the stable compatibility promise (`spec:AST-017/FR21`). Omitting `experimental`, + or omitting `presentation` inside it, keeps the inline combobox and its + anchored suggestion menu on every device. Changes to the option ship as + `[experimental]` patch releases (`spec:AST-017/FR22`). - Migration decision: none. Existing callers that omit `hasEntriesOnFocus`, typed search selection, explicit dismissal, disabled state, maximum-entry state, and source-loading behavior retain their prior boundaries. @@ -88,6 +116,9 @@ Consumer migration instructions belong in consumer docs and release notes. - Tokenizer's current suggestion-menu width and minimum-anchor-width behavior. - Token wrapping and the `none`, `unfocusedInline`, and `unfocusedLayer` overflow presentations. +- The touch sheet presentation: its trigger, the single searchable sheet, the + in-session row order, progressive long-list rendering, the create row, the + confirmed Clear all, and which Tokenizer props the sheet honors. **Does not own / non-goals** @@ -106,12 +137,46 @@ Consumer migration instructions belong in consumer docs and release notes. width whose rendered menu does not shrink below the Tokenizer outer field. - Consumer-provided token content and visuals. Tokenizer owns only the capture boundary needed to identify the control that activated an internal open. +- The compact-touch resolution rule behind `adaptive`, owned by + `spec:AST-094/FR13` (a touch presentation applies under a coarse primary + pointer, after hydration; compact is `spec:AST-012/FR9`). Tokenizer resolves + `adaptive` with that one rule, as the selection controls do; it adds no + query of its own. +- The sheet's dialog, scrim, gestures, snap behavior, and dismissal paths — + owned by `component:BottomSheet` and `family:overlay-dismissal`; its + on-screen keyboard behavior — owned by `spec:AST-099`. ## Public concepts -No public API concept is introduced. Existing consumer props and usage remain in -`Tokenizer.doc.mjs`. The existing `hasEntriesOnFocus` mode now includes the -component-owned post-selection behavior in FR13–FR15. +One public concept is introduced, as an experimental surface: the optional +`experimental` prop object and its `presentation` option, `'popover' | +'bottom-sheet' | 'adaptive'` (DEC-3). The option type is inlined on the prop; +no experimental hook or type is exported. Declarations carry `@experimental` +and consumer docs carry `stability: 'experimental'` metadata. Existing +consumer props and usage remain in `Tokenizer.doc.mjs`. The existing +`hasEntriesOnFocus` mode includes the component-owned post-selection behavior +in FR13–FR15. + +In the sheet presentation the trigger is the control: `ref`, `id`, `data-*`, +`aria-*`, and the other base props reach it (`family:input-fields/FR10`, +`architecture:public-component-api/INV8`), `handleRef.focus()` and `blur()` +act on it, and `onFocus`, `onBlur`, and `onKeyDown` report its events. The +`ref` type follows the opt-in. The exported `TokenizerProps` remains an +interface and keeps `ref?: Ref` as released, so `interface +X extends TokenizerProps` and `Omit, …>` wrappers keep +compiling; the component's own parameter type is where the widening lives: +with `experimental` passed, `ref` accepts `Ref` and receives the trigger button in the sheet presentation +and the combobox input otherwise. Implementation gap: `ref` is typed +`Ref` whether or not `experimental` is passed. The +props that describe the inline combobox have no surface in the sheet +presentation: `renderItem`, `renderToken`, `endContent`, `hasClear` (the +sheet's own Clear all replaces it), `hasEntriesOnFocus` (the sheet always +lists the bootstrap set), `menuWidth`, `tokenOverflowBehavior`, and +`hasAutoFocus`. Passing one of them with the sheet presentation emits one +development-mode warning naming the prop, so it is not silently dropped +(`architecture:public-component-api/INV3`); every other prop keeps its +meaning. The descriptor is package-internal. It must be absent from exported `TokenizerProps`, public barrels, generated declarations, consumer docs, CLI @@ -122,25 +187,36 @@ it does not become an element or geometry escape hatch. Each requirement identifies its basis. This current record defines the approved package-internal contract while implementation and focused verification remain -pending. - -| ID | Invariant | Basis | Status | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------- | -| FR1 | Tokenizer MUST keep one native combobox input mounted across token addition, removal, clear, ordinary wrapping, and maximum-entry states. Reaching `maxEntries` may make the input zero-width, transparent, and out of flow, but MUST NOT replace its identity. | Current source and tests | Verified current behavior | -| FR2 | Tokenizer's suggestion menu MUST remain associated with the outer field/control surface. Existing `menuWidth` behavior remains an exact preferred pixel width plus the outer field's minimum width; it is not a maximum. | Current BaseTypeahead/Tokenizer source and docs | Verified current behavior; preserve | -| FR3 | `unfocusedInline` MAY replace direct token layout with OverflowList while blurred. `unfocusedLayer` MAY keep the real wrapper/input mounted in a context layer while an ordinary-flow placeholder represents the collapsed field. | Current source, tests, and stories | Verified current behavior | -| FR4 | Tokenizer MUST provide Core peers one package-internal descriptor with stable identities for its outer field/control surface and active opening control. Layer consumes the descriptor; public consumers cannot observe or configure it. | Approved package-internal contract; #5804 evidence; `spec:AST-002/DEC-1` | Current contract; implementation pending | -| FR5 | Add/search opens MUST identify the stable inner input as the opening control before the result row disappears. Selection, query clearing, result removal, refocus, or later input collapse MUST NOT substitute the transient result row or a pointer coordinate. | Current selection order plus approved descriptor contract | Current contract; implementation pending | -| FR6 | Edit opens MUST capture the actual activated token/control before state removes or replaces it. The Tokenizer-owned capture path MUST include custom `renderToken` output when activation originates from an identifiable descendant control; it MUST NOT require a new public ref contract. | Current owned wrapper plus approved capture contract | Current contract; implementation pending | -| FR7 | A non-null element is usable only while connected, rendered, and geometrically meaningful for the open being prepared. A disconnected, stale, hidden-layer, zero-width, or otherwise unusable opening control MUST fall back safely to the outer field at logical start before show. | Current max/overflow states plus approved safety rule | Current contract; implementation pending | -| FR8 | The descriptor MUST remain valid across focus movement, token add/remove/clear, wrapping, and `unfocusedLayer` reparenting. It MUST clear on unmount, perform no DOM work during SSR, and never retain an element beyond the open preparation that uses it. | Approved identity/lifetime contract | Current contract; implementation pending | -| FR9 | A consuming surface MUST resolve its preferred width before choosing horizontal placement. A surface at least as wide as the outer field MUST use outer-field logical-start alignment. Only a surface narrower than the outer field may use opening-control geometry. | Approved conditional-placement contract | Current contract; implementation pending | -| FR10 | For a narrower surface, Layer MUST compare the opening control with the outer field's physical start/end geometry, choose the nearer physical edge, map that result to logical start/end, and use logical start on a tie. Viewport collision runs last. | Approved Layer-consumer contract | Current contract; implementation pending | -| FR11 | Opening placement MUST be modality-neutral and latched for one open lifetime. Pointer, keyboard, touch, and programmatic activation of the same control MUST produce the same preferred edge. Pointer movement, later focus movement, resize, or target collapse MUST NOT recompute it. | `architecture:interaction-modality` plus approved stability contract | Current contract; implementation pending | -| FR12 | Placement state MUST commit while the surface is hidden, and Layer MUST show it on the next animation frame only after anchor, alignment, and width styles exist. A stale or invalid descriptor MUST never cause a visible wrong-position or first-frame flash. | Approved no-flash contract | Current contract; implementation pending | -| FR13 | Tokenizer MUST exclude every choice whose `SearchableItem.id` is already present in its committed `value` from displayed bootstrap and search cohorts, including results that settle after the controlled value changes. A caller-rejected proposed value remains eligible because it was not committed. Distinct IDs remain eligible even when their labels match. | Approved Tokenizer multi-selection contract | Current contract; implementation and verification pending | -| FR14 | With `hasEntriesOnFocus` enabled, committing a choice from the current settled empty-query bootstrap cohort MUST keep the input focused and the popup open when another eligible loaded choice remains. The active option advances to the next remaining choice in displayed order; when the committed choice was last, the nearest preceding choice becomes active. `aria-activedescendant` MUST reference that rendered option. | Approved rapid multi-select lifecycle | Current contract; implementation and verification pending | -| FR15 | The retained popup MUST close and clear its active descendant when no eligible loaded choice remains, `maxEntries` is reached, the control becomes disabled, focus leaves, or the person dismisses it. Disabled Tokenizer remains non-interactive. Pending or stale source work MUST NOT reopen a terminal popup or expose stale choices; loading has no selectable placeholder and its busy state clears when work is cancelled or settles. | Approved terminal-state safety boundary | Current contract; implementation and verification pending | +pending. FR1–FR15 describe the `popover` presentation, the inline combobox; +FR16–FR24 describe the `experimental.presentation` option and its sheet +presentation, which has no inline combobox, suggestion menu, or end lane. + +| ID | Invariant | Basis | Status | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| FR1 | Tokenizer MUST keep one native combobox input mounted across token addition, removal, clear, ordinary wrapping, and maximum-entry states. Reaching `maxEntries` may make the input zero-width, transparent, and out of flow, but MUST NOT replace its identity. | Current source and tests | Verified current behavior | +| FR2 | Tokenizer's suggestion menu MUST remain associated with the outer field/control surface. Existing `menuWidth` behavior remains an exact preferred pixel width plus the outer field's minimum width; it is not a maximum. | Current BaseTypeahead/Tokenizer source and docs | Verified current behavior; preserve | +| FR3 | `unfocusedInline` MAY replace direct token layout with OverflowList while blurred. `unfocusedLayer` MAY keep the real wrapper/input mounted in a context layer while an ordinary-flow placeholder represents the collapsed field. | Current source, tests, and stories | Verified current behavior | +| FR4 | Tokenizer MUST provide Core peers one package-internal descriptor with stable identities for its outer field/control surface and active opening control. Layer consumes the descriptor; public consumers cannot observe or configure it. | Approved package-internal contract; #5804 evidence; `spec:AST-002/DEC-1` | Current contract; implementation pending | +| FR5 | Add/search opens MUST identify the stable inner input as the opening control before the result row disappears. Selection, query clearing, result removal, refocus, or later input collapse MUST NOT substitute the transient result row or a pointer coordinate. | Current selection order plus approved descriptor contract | Current contract; implementation pending | +| FR6 | Edit opens MUST capture the actual activated token/control before state removes or replaces it. The Tokenizer-owned capture path MUST include custom `renderToken` output when activation originates from an identifiable descendant control; it MUST NOT require a new public ref contract. | Current owned wrapper plus approved capture contract | Current contract; implementation pending | +| FR7 | A non-null element is usable only while connected, rendered, and geometrically meaningful for the open being prepared. A disconnected, stale, hidden-layer, zero-width, or otherwise unusable opening control MUST fall back safely to the outer field at logical start before show. | Current max/overflow states plus approved safety rule | Current contract; implementation pending | +| FR8 | The descriptor MUST remain valid across focus movement, token add/remove/clear, wrapping, and `unfocusedLayer` reparenting. It MUST clear on unmount, perform no DOM work during SSR, and never retain an element beyond the open preparation that uses it. | Approved identity/lifetime contract | Current contract; implementation pending | +| FR9 | A consuming surface MUST resolve its preferred width before choosing horizontal placement. A surface at least as wide as the outer field MUST use outer-field logical-start alignment. Only a surface narrower than the outer field may use opening-control geometry. | Approved conditional-placement contract | Current contract; implementation pending | +| FR10 | For a narrower surface, Layer MUST compare the opening control with the outer field's physical start/end geometry, choose the nearer physical edge, map that result to logical start/end, and use logical start on a tie. Viewport collision runs last. | Approved Layer-consumer contract | Current contract; implementation pending | +| FR11 | Opening placement MUST be modality-neutral and latched for one open lifetime. Pointer, keyboard, touch, and programmatic activation of the same control MUST produce the same preferred edge. Pointer movement, later focus movement, resize, or target collapse MUST NOT recompute it. | `architecture:interaction-modality` plus approved stability contract | Current contract; implementation pending | +| FR12 | Placement state MUST commit while the surface is hidden, and Layer MUST show it on the next animation frame only after anchor, alignment, and width styles exist. A stale or invalid descriptor MUST never cause a visible wrong-position or first-frame flash. | Approved no-flash contract | Current contract; implementation pending | +| FR13 | Tokenizer MUST exclude every choice whose `SearchableItem.id` is already present in its committed `value` from displayed bootstrap and search cohorts, including results that settle after the controlled value changes. A caller-rejected proposed value remains eligible because it was not committed. Distinct IDs remain eligible even when their labels match. | Approved Tokenizer multi-selection contract | Current contract; implementation and verification pending | +| FR14 | With `hasEntriesOnFocus` enabled, committing a choice from the current settled empty-query bootstrap cohort MUST keep the input focused and the popup open when another eligible loaded choice remains. The active option advances to the next remaining choice in displayed order; when the committed choice was last, the nearest preceding choice becomes active. `aria-activedescendant` MUST reference that rendered option. | Approved rapid multi-select lifecycle | Current contract; implementation and verification pending | +| FR15 | The retained popup MUST close and clear its active descendant when no eligible loaded choice remains, `maxEntries` is reached, the control becomes disabled, focus leaves, or the person dismisses it. Disabled Tokenizer remains non-interactive. Pending or stale source work MUST NOT reopen a terminal popup or expose stale choices; loading has no selectable placeholder and its busy state clears when work is cancelled or settles. | Approved terminal-state safety boundary | Current contract; implementation and verification pending | +| FR16 | `experimental.presentation` selects the surface. `popover`, the value when `experimental` or the option is omitted, is the inline combobox with its anchored suggestion menu, unchanged. `bottom-sheet` is the trigger and modal sheet of FR17–FR24 on every device. `adaptive` resolves to `bottom-sheet` on compact coarse-pointer screens and to `popover` otherwise, through the one touch-presentation rule `spec:AST-094/FR13` owns, as the selection controls do. `adaptive` renders on the server, and hydrates, as the inline field, because the resolver answers `popover` where there is no `matchMedia` and in the hydrating render; on a compact coarse-pointer client the field swaps to the trigger in the first render after hydration; nothing is focused before interaction, so focus is undisturbed (`document.activeElement` stays the body), except with `hasAutoFocus`, whose server-rendered `autofocus` focuses the inline input on load: then the swap moves that focus to the trigger — the same element and visit for the caller, so no `onBlur`/`onFocus` pair is reported. The trigger keeps the field's box — the same width, top, label, and `size` block size — except the inline input's own row: when the tokens leave the input no room on their row, the inline field wraps the input onto a row of its own, and the trigger, which has no input, drops that row (one `md` row, 27px, on a 390 and a 414px phone with five tokens); with room for the input beside the tokens, nothing shifts. A resolution change while the sheet is open takes effect after it closes and focus has returned to the trigger — the presentation hold `architecture:layer-runtime/INV14` assigns to the layer runtime; INV14 gap: the runtime does not yet provide one (its record names INV14 as ownership not yet met), so until it does the field keeps its own hold, to be replaced by the runtime's when it exists. Whenever the presentation changes while its control holds focus — then, or while the sheet is closed — focus moves to the new presentation's control, the combobox input or the trigger, so it never lands on the page body (`spec:AST-085/FR4`). | DEC-3; touch-flow evidence from the Lab prototype | Current contract. Implementation gap: the shipped Tokenizer has no `experimental` prop and renders the inline combobox on every device. | +| FR17 | In the sheet presentation the control surface is one button, named by the field label, with `aria-haspopup="dialog"` and `aria-expanded`. It shows the selected tokens without remove controls, or the placeholder when empty, wraps tokens onto further lines, and keeps the inline field's block size for its `size` step. Activating it opens one tall modal BottomSheet named by the field label; the suggestion popover, inline input, and end lane do not render. While disabled the trigger stays focusable with `aria-disabled="true"`, does not open, and shows `disabledMessage` as the inline field does (`spec:AST-085/FR1`); a field that becomes disabled while its sheet is open closes the sheet and returns focus to the trigger (`spec:AST-085/FR2`). | DEC-3; Lab touch-flow evidence; `spec:AST-085` | Current contract. Implementation gap: no trigger exists. | +| FR18 | The sheet is a single surface: a search row at the top using the panel search field the selection controls share, a scrolling list of rows, and, while the query is empty, a footer with Clear all and Done. There is no second sheet. On open, focus moves to the sheet's panel, not the search field, so the reading order starts at the top of the sheet and the on-screen keyboard rises only when the person reaches for the search (WCAG 2.2 SC 2.4.3 Focus Order and 3.2.1 On Focus through `spec:AST-020/FR1`). Done closes the sheet; swipe, scrim, and Escape dismissal follow BottomSheet `purpose="info"`; each of those closes clears the query and reports it through `onChangeQuery`, so the next open lists the bootstrap set, while a close the field forces (becoming disabled, FR17) keeps the query, since nothing the person did changed it; focus returns to the trigger after the sheet closes. | DEC-3; Lab touch-flow evidence; `component:BottomSheet` | Current contract. Implementation gap: no sheet exists. | +| FR19 | Each row is a `checkbox` named by the item label whose `aria-checked` mirrors membership in `value`. Activating an unchecked row commits an `add` change; a checked row commits a `remove` change; both apply immediately and are announced with the inline field's words through the sheet's own live region (AR6). Row order is stable while the sheet is open: the items selected when it opened, in `value` order, then selected items absent from the results, then the results in source order; a toggle never moves an existing row. The next open takes a fresh snapshot. The query filters the selected rows by case-insensitive label substring. A row renders an item without its `action` and warns once in development that the sheet does not render it, as the inline panel does (`spec:AST-058/FR9`). | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no rows exist. | +| FR20 | An empty query lists the source's bootstrap set, capped at `maxMenuItems` as the inline field caps its focus bootstrap (the prop keeps its meaning in the sheet), requested at once on open and on clearing the query, so an instant source shows no busy feedback and announces no busy start; a query shorter than `minQueryLength` is not searched: it lists only the selected rows it matches and, with `hasCreate`, the create row, with no busy feedback, no empty state, and no outcome announcement, as the inline field shows nothing for such a query; otherwise the source is searched `debounceMs` after the query change and the results are capped at `maxMenuItems`. `onChangeQuery` fires on every query change. From open, and from each query change, until that search settles, the list is pending: it shows named busy feedback above any rows still listed from the previous search, the list element carries the busy semantics (`aria-busy`), since the sheet has no combobox (`family:input-fields/FR7`, which places busy semantics on the list a presentation without a combobox updates), and no empty state shows; when the settled search removes the row that holds focus, focus moves to the search field (`spec:AST-085/FR4`). Once settled, the outcome is announced through the sheet's live region (AR6): the result count for a searched query, or the rendered text of the empty state when it shows (`spec:AST-056/FR3`). A source that rejects settles the search to no results, as the inline field does. With no rows and no create row, a searched query shows the `emptySearchText` empty state (its default message when omitted); an empty query shows no list, no message, and announces nothing — only the search field, above the footer — as the inline field opens no menu and reports nothing for a bootstrap set that is empty: nothing has failed, and typing is what finds items (a search-only source, `bootstrap: () => []`, is such a source). | DEC-3; Lab touch-flow evidence; `family:input-fields` | Current contract. Implementation gap: no sheet search exists. | +| FR21 | Long lists render progressively: 50 rows at a time, the next batch when the list scrolls within 200px of its end, with the window reset to the first batch on open and on each query change. The rendered and total counts are exposed as `data-rendered-count` and `data-total-count` on the list, and the rows sit in a `list` whose `listitem` wrappers carry `aria-setsize` with the total and `aria-posinset` with the row's position — the `checkbox` role itself admits neither — so a person moving through the rows can learn that more exist before the list has scrolled; whether a screen reader speaks the position ("n of total") from the focused checkbox depends on the screen reader (`spec:AST-009/FR8`), and the accessibility tree proves the `list`, `listitem`, `aria-setsize`, and `aria-posinset` exposure; focus reaching the last rendered row renders the next batch (WCAG 2.2 SC 1.3.1 Info and Relationships and 4.1.2 Name, Role, Value through `spec:AST-020/FR1`). | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no list exists. | +| FR22 | With `hasCreate`, a non-empty trimmed query that matches no selected id, no selected label, and no result label (case-insensitive) adds a create row above the list showing `Create "{text}"` with a trailing Add action named `Add {text}`. Activating it commits a `create` change whose item has the text as both id and label, clears the query, and moves focus to the sheet's search field, since the row it was on is gone. The create row is absent at `maxEntries`. | DEC-3; Lab touch-flow evidence; `hasCreate` contract of the inline field | Current contract. Implementation gap: no create row exists. | +| FR23 | At `maxEntries`, unchecked rows carry `aria-disabled="true"`, stay focusable, and ignore activation; checked rows remain toggleable so the person can make room (`spec:AST-085/FR1`). | DEC-3; Lab touch-flow evidence; `spec:AST-085` | Current contract. Implementation gap: no rows exist. | +| FR24 | Clear all is disabled while `value` is empty: `aria-disabled`, still in the focus order, with a tooltip saying nothing is selected, and a click opens nothing (`spec:AST-085/FR1`). Otherwise it opens an AlertDialog confirmation naming the field; confirming commits `[]` with a `remove` change naming the last selected item, closes the confirmation, and — once the confirmation has closed — moves focus to the sheet's search field, the destination this record names (`spec:AST-085/FR4`): the clear is spent, and the search field is where the next selection starts (WCAG 2.2 SC 2.4.3 through `spec:AST-020/FR1`), and announces "All items removed" inside the sheet (AR6); cancelling changes nothing and focus returns to Clear all. | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no footer exists. | ### Allowed variation @@ -163,26 +239,45 @@ pending. project, or otherwise coordinate the settled cohort through a package-private mechanism. That mechanism must preserve FR13–FR15, must not become public API, and must not change direct single-select BaseTypeahead behavior. +- **AV7 — Sheet surface composition.** The sheet presentation may compose + BottomSheet, the shared panel search field, EmptyState, AlertDialog, and the + checkbox indicator in any internal structure, and may load that surface on + demand, provided FR16–FR24, AR6, and PR6 hold and the structure stays within + `architecture:component-style-authoring` INV7 — minimal DOM, content + rendered once; at rest, a pointer-specific presentation is a change of + attributes and styles on the same elements; open, in INV7's words, "the + open layer under a coarse pointer has at most as many elements as under a + fine pointer (beyond that, only by the open-layer allowance `spec:AST-017` + FR60 and DEC-29 set)". The closed `adaptive` trigger (a different element, + which no allowance covers) and the open sheet (more than the five extra + elements FR60 allows) are owner exceptions DEC-3 records, beyond that + allowance, with their before and after counts. ### Representative states -| State | Required invariant | Allowed variation | -| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -| Empty field | The mounted inner input is the opening control; a full-width menu stays aligned to the outer field. | Placeholder and bootstrap results may vary. | -| Focus bootstrap, choices remain | A committed selection is removed from the displayed cohort; the input and popup remain active on the next valid remaining choice. | Internal cohort storage and projection may vary. | -| Focus bootstrap, selected choice was last | The nearest preceding remaining choice becomes active without changing the displayed order. | Option identity representation may vary. | -| Focus bootstrap, no choices or max reached | The popup closes and no active descendant remains; pending work cannot reopen it. | Source cancellation mechanism may vary. | -| Source pending or replaced | Busy semantics identify pending work; stale work cannot add choices or reopen a terminal popup. | A settled current cohort may be retained internally. | -| Disabled | No choice can be committed; an open popup closes and clears its active descendant while disabled-reason focusability remains available where configured. | The disabled representation follows `spec:AST-085`. | -| One or many tokens | The same input remains after tokens and moves with wrapping; full-width placement does not follow that movement. | Token labels, wrapping rows, and custom content may vary. | -| At `maxEntries` | The input identity remains mounted but its zero-width/hidden box is unusable as geometry; opening falls back to the outer field. | Search remains unavailable under current behavior. | -| Token removal or clear | Input identity survives and current refocus behavior remains. | Token count and input position change. | -| `unfocusedInline` | Blurred OverflowList and focused direct-token layouts resolve the same live input identity; a collapsed input is not used as geometry. | Visible token count may vary with width. | -| `unfocusedLayer`, focused | The live wrapper/input may be reparented into the context layer without descriptor churn. | Corrective portal location may vary. | -| `unfocusedLayer`, unfocused | The hidden-layer input is unusable for a new open; the ordinary-flow outer-field fallback is used. | Placeholder token count may vary. | -| Default or custom token edit | The actual activated token/control is captured before edit state changes; if no usable descendant control exists, input then outer-field fallback applies. | Custom token markup may vary. | -| LTR or RTL | Physical proximity selects an edge only for a narrower surface; logical start wins ties and full-width placement. | Collision may move the final rendered surface. | -| SSR, hydration, or unmount | No server geometry read occurs; first client open prepares normally; unmount clears identities and pending show work. | Hydration timing may vary. | +| State | Required invariant | Allowed variation | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| Empty field | The mounted inner input is the opening control; a full-width menu stays aligned to the outer field. | Placeholder and bootstrap results may vary. | +| Focus bootstrap, choices remain | A committed selection is removed from the displayed cohort; the input and popup remain active on the next valid remaining choice. | Internal cohort storage and projection may vary. | +| Focus bootstrap, selected choice was last | The nearest preceding remaining choice becomes active without changing the displayed order. | Option identity representation may vary. | +| Focus bootstrap, no choices or max reached | The popup closes and no active descendant remains; pending work cannot reopen it. | Source cancellation mechanism may vary. | +| Source pending or replaced | Busy semantics identify pending work; stale work cannot add choices or reopen a terminal popup. | A settled current cohort may be retained internally. | +| Disabled | No choice can be committed; an open popup closes and clears its active descendant while disabled-reason focusability remains available where configured. | The disabled representation follows `spec:AST-085`. | +| One or many tokens | The same input remains after tokens and moves with wrapping; full-width placement does not follow that movement. | Token labels, wrapping rows, and custom content may vary. | +| At `maxEntries` | The input identity remains mounted but its zero-width/hidden box is unusable as geometry; opening falls back to the outer field. | Search remains unavailable under current behavior. | +| Token removal or clear | Input identity survives and current refocus behavior remains. | Token count and input position change. | +| `unfocusedInline` | Blurred OverflowList and focused direct-token layouts resolve the same live input identity; a collapsed input is not used as geometry. | Visible token count may vary with width. | +| `unfocusedLayer`, focused | The live wrapper/input may be reparented into the context layer without descriptor churn. | Corrective portal location may vary. | +| `unfocusedLayer`, unfocused | The hidden-layer input is unusable for a new open; the ordinary-flow outer-field fallback is used. | Placeholder token count may vary. | +| Default or custom token edit | The actual activated token/control is captured before edit state changes; if no usable descendant control exists, input then outer-field fallback applies. | Custom token markup may vary. | +| LTR or RTL | Physical proximity selects an edge only for a narrower surface; logical start wins ties and full-width placement. | Collision may move the final rendered surface. | +| SSR, hydration, or unmount | No server geometry read occurs; first client open prepares normally; unmount clears identities and pending show work. | Hydration timing may vary. | +| Sheet presentation, closed | The trigger shows the tokens or placeholder at the inline field's height for its `size`, `aria-expanded="false"`; no sheet content is interactive. | Token wrapping and placeholder text may vary. | +| Sheet presentation, open | One tall modal sheet named by the label: search on top, stable-order checkbox rows, Clear all and Done while the query is empty. Focus is on the sheet's panel, not the search field. | Row count, batches, and empty states may vary. | +| Sheet presentation, searching | The footer is absent; the list is busy until the search settles; rows filter to the query; the create row leads when `hasCreate` applies; a dead end shows the no-results empty state. | Debounce and result cap follow the props. | +| Sheet presentation, at `maxEntries` | Unchecked rows are `aria-disabled` and inert; checked rows toggle; no create row. | The disabled representation follows `spec:AST-085`. | +| Sheet presentation, dismissed | After a swipe, scrim, Escape, or Done close the query is empty and reported, so the next open lists the bootstrap set; focus is back on the trigger. A close forced by disabling keeps the query. | A dismissal that keeps a typed query for the next open, or that leaves focus on the page body, fails. | +| Sheet presentation, `adaptive` resolution | Server and hydrating renders are the inline field; a compact coarse-pointer client swaps to the trigger in the first render after hydration, focus undisturbed, the box kept except an inline input row the tokens had wrapped. While closed the surface follows the shared compact-touch query; an open surface keeps its presentation until it closes. | The query is shared, not Tokenizer-owned. | ### Transformation and precedence order @@ -217,9 +312,17 @@ pending. - **PR5 — CSS owns ongoing geometry.** Container resize, outer-field movement, writing-direction layout, and final collision remain CSS/Layer work after the one-time semantic choice. +- **PR6 — The sheet surface is opt-in cost.** A field in the `popover` + presentation does not load the sheet surface's code; the surface loads on + demand only while a field resolves to `bottom-sheet`, renders nothing on the + server or during the hydrating render, and renders its long lists in batches + (FR21). Implementation gap: no sheet surface exists. ## Accessibility contract +AR1–AR5 describe the `popover` presentation; AR6 describes the sheet +presentation. + - **AR1 — Modality equivalence.** The same input or token/control produces the same preferred placement for pointer, touch, keyboard, and programmatic activation. No pointer coordinate participates. @@ -236,9 +339,49 @@ pending. remain true, and `aria-activedescendant` MUST name the rendered next valid option. Closing for dismissal, disabled, maximum, or exhaustion MUST remove the active descendant. Selected and disabled options are never interactive. +- **AR6 — Sheet semantics.** In the sheet presentation the trigger is a button + named by the field label with `aria-haspopup="dialog"` and `aria-expanded`, + and its accessible description is everything the inline input's is, in + the inline input's order — the caller's `aria-describedby` ids, the field + `description`, the status message, and the `disabledMessage` reason when + disabled (a disabled control exposes its description and its reason on + itself, `spec:AST-085/FR5`) — followed by the selected count and the + selected token labels, so the selection the sighted person sees is exposed + to assistive technology as the trigger's description; the labels are the + rendered tokens themselves, each referenced by id from `aria-describedby` + after a hidden count, so no label renders twice + (`architecture:component-style-authoring` INV7); whether a screen reader + reads a description with the button depends on the screen reader + (`spec:AST-009/FR8`), and the DOM and accessibility tree prove the + `aria-describedby` exposure (WCAG 2.2 SC 1.3.1 and 4.1.2 through + `spec:AST-020/FR1`); + the sheet is a modal dialog named by the field label; the search field is + named `Search {label}` with a named clear action; each row is a `checkbox` + named by its item label with `aria-checked`, toggled by tap or click and by + Space and Enter, the activation keys of the native button it is rendered on + (`spec:AST-020/FR15`; WCAG 2.2 SC 2.1.1 and 4.1.2); disabled rows and a + disabled trigger use `aria-disabled` and keep their focus order; while the + sheet is + open, additions, removals, a confirmed Clear all ("All items removed", made + once the confirmation has closed, since a message sent while the + confirmation covers the sheet is not heard — `spec:AST-086/FR14`), the + search's busy start, and how each search + ended — the result count for a non-empty query, or the empty state's text + when it shows — are announced once per occasion from inside the sheet + through `useAnnounce`, which `spec:AST-086/FR14` routes into the persistent + polite container of the sheet's own dialog while it is open, because the + shared page-body channel is not exposed behind a native modal; the + container follows FR3, FR5, and FR6 of that record (WCAG 2.2 SC 4.1.3 + through `spec:AST-020/FR1`). After the sheet closes focus returns to the + trigger and the field's announcements follow the same FR14 route from + wherever the field renders. + Implementation gap: no sheet exists. ## Design relationships +The first six rows describe the `popover` presentation; the sheet rows describe +the sheet presentation. + | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | -------------- | ------------------- | | Outer field/control surface | Contains tokens, input, and end controls; remains the full-width placement and safe fallback anchor. | Current Tokenizer source | Prominent | FR1–FR4, FR7, FR9 | @@ -247,8 +390,16 @@ pending. | Suggestion menu | Retains current outer-field anchoring and exact preferred `menuWidth`; focus bootstrap keeps only eligible choices and a valid active option. | Current BaseTypeahead source | Prominent | FR2, FR13–FR15, AR5 | | Overflow placeholder/layer | Preserves a usable outer-field fallback while live content may reparent or collapse. | Current Tokenizer/Layer composition | Supporting | FR3, FR7–FR8 | | Dependent narrower surface | Uses the descriptor only after its owning component resolves a width narrower than the field. | Consuming component plus Layer | Prominent | FR9–FR12 | +| Sheet trigger | The field's control surface in the sheet presentation: tokens or placeholder, the inline field's size step, opens the sheet. | Tokenizer source; Token | Prominent | FR17, AR6 | +| Sheet search row | The shared panel search field at the top of the sheet; filters rows and the create row. | Shared panel search field | Prominent | FR18, FR20, FR22 | +| Sheet rows | Full-width checkbox rows with a trailing indicator; stable order while open; disabled at the limit. | Tokenizer source; checkbox indicator | Prominent | FR19, FR21, FR23 | +| Sheet footer | Equal-width large Clear all and Done while the query is empty; Clear all confirms through an AlertDialog. | Button; AlertDialog | Supporting | FR18, FR24 | -This contract adds no target, state, or visual styling contract. +This contract adds no target, state, or visual styling contract. The sheet +presentation exposes the field's existing `tokenizer` target on the trigger; +the sheet's own parts carry no Tokenizer theming targets while the option is +experimental (DEC-3), and are themed through the targets of the components +they compose. ## Family and system relationships @@ -297,15 +448,16 @@ become Tokenizer policy. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | -| FR1–FR3 | Existing Tokenizer/BaseTypeahead unit tests plus source inspection | empty, tokens, remove, clear, max, wrapping, both overflow modes | Input remounts, suggestion menu changes anchor/width semantics, or overflow ownership is misstated. | `audit:Tokenizer/current` | -| FR4–FR8 | Focused descriptor identity/capture tests when implemented | add, default/custom token edit, max, hidden layer, reparent, stale node, unmount, SSR | Descriptor leaks publicly, captures a transient result/pointer, misses custom controls, or uses unusable geometry. | `audit:Tokenizer/opening-anchor` | -| FR9–FR12 | Layer unit tests and real-Chromium geometry checks when implemented | full/equal/narrow surface, start/end/tie, LTR/RTL, collision, stale fallback | Full-width follows inner input, tie is physical, collision runs early, or stale identity flashes. | `audit:Tokenizer/placement` | -| FR13–FR15, AR5 | Focused Tokenizer/BaseTypeahead tests and exact-head Chromium interaction evidence | pointer/keyboard commit, first/middle/last choice, rejected/deferred control, loading, disabled, max, exhaustion, Escape, Tab, outside focus | Selected values remain available, order changes, focus leaves the combobox, active descendant names no option, stale work reopens, or a terminal state stays interactive. | `audit:Tokenizer/multi-select` | -| PR1–PR5 | Instrumented reads, pending-frame cleanup tests, and first-frame Chromium capture | open, close before frame, reopen opposite side, resize while open | Render-time/repeated reads, observer/pointer tracking, uncancelled frame, or first-frame jump occurs. | `audit:Tokenizer/performance` | -| AR1–AR5 | Existing interaction suites plus focused pointer/keyboard/browser checks; native-disabled assertions pending conformance (`spec:AST-085`) | add/edit, focus/refocus, overflow, disabled, dismissal, retained bootstrap selection | Modality changes placement, semantics/focus regress, active descendant becomes invalid, or visible placement is wrong before fallback. | `audit:Tokenizer/accessibility` | -| Record structure | `scripts/check-knowledge.mjs` | Template-v3 current record and current-only frontmatter links | Invalid metadata, authority, or relationship references fail repository validation. | `audit:Tokenizer/knowledge` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| FR1–FR3 | Existing Tokenizer/BaseTypeahead unit tests plus source inspection | empty, tokens, remove, clear, max, wrapping, both overflow modes | Input remounts, suggestion menu changes anchor/width semantics, or overflow ownership is misstated. | `audit:Tokenizer/current` | +| FR4–FR8 | Focused descriptor identity/capture tests when implemented | add, default/custom token edit, max, hidden layer, reparent, stale node, unmount, SSR | Descriptor leaks publicly, captures a transient result/pointer, misses custom controls, or uses unusable geometry. | `audit:Tokenizer/opening-anchor` | +| FR9–FR12 | Layer unit tests and real-Chromium geometry checks when implemented | full/equal/narrow surface, start/end/tie, LTR/RTL, collision, stale fallback | Full-width follows inner input, tie is physical, collision runs early, or stale identity flashes. | `audit:Tokenizer/placement` | +| FR13–FR15, AR5 | Focused Tokenizer/BaseTypeahead tests and exact-head Chromium interaction evidence | pointer/keyboard commit, first/middle/last choice, rejected/deferred control, loading, disabled, max, exhaustion, Escape, Tab, outside focus | Selected values remain available, order changes, focus leaves the combobox, active descendant names no option, stale work reopens, or a terminal state stays interactive. | `audit:Tokenizer/multi-select` | +| PR1–PR5 | Instrumented reads, pending-frame cleanup tests, and first-frame Chromium capture | open, close before frame, reopen opposite side, resize while open | Render-time/repeated reads, observer/pointer tracking, uncancelled frame, or first-frame jump occurs. | `audit:Tokenizer/performance` | +| AR1–AR5 | Existing interaction suites plus focused pointer/keyboard/browser checks; native-disabled assertions pending conformance (`spec:AST-085`) | add/edit, focus/refocus, overflow, disabled, dismissal, retained bootstrap selection | Modality changes placement, semantics/focus regress, active descendant becomes invalid, or visible placement is wrong before fallback. | `audit:Tokenizer/accessibility` | +| FR16–FR24, AR6, PR6 | Focused Tokenizer sheet tests with the compact-touch query stubbed both ways, a server-render-then-hydrate test, plus real-engine touch evidence (implementation gap: not yet in the tree) | default popover, forced sheet, adaptive both ways, `adaptive` server-rendered as the combobox and hydrated without a recoverable error, then the trigger on a coarse-pointer client with the body still active, with `hasAutoFocus` the autofocused input's focus on the trigger after the swap and no `onBlur`/`onFocus` reported, the trigger's box against the hydrating field's at 390 and 414px, base props and ref on the trigger, the trigger's description in the inline input's order — a disabled trigger with `disabledMessage`, `description` and a status exposing the caller's ids, the description, the status message and the reason before the count and the tokens —, inert-prop warning, pending search, in-sheet announcements of toggles, busy start, result count and the rendered empty outcome, the confirmed Clear all announced once the confirmation has closed, a below-threshold query, a rejecting source, focus on the panel at open, the query cleared by a swipe, scrim, or Escape close, focus after a presentation change with the sheet closed, focus after the create row, focus after a confirmed Clear all, a spent Clear all focusable with `aria-disabled` and no action, focus after a settled search removes the focused row, no busy feedback or busy-start announcement on open with an instant source, only the search field — no list, no message, no announcement — for an empty query with nothing to list (a search-only source, nothing selected), and the list once a query finds items, element counts under a fine and a coarse pointer at rest and open (Chromium, Experimental stories: adaptive 154/120 at rest — the closed trigger in place of the inline field, a different element that stays under the fine count — and 157/165 open at the query "e" with no footer, the coarse total reconciled as rest − the re-hosted surfaces host + the dialog of 47 by part, forced sheet 114/114 and 165/165; the two owner exceptions DEC-3 records beyond the FR60 allowance, with before and after counts), no confirmation dialog in the DOM while none is pending, the query kept across a disabled-forced close, `aria-setsize`/`aria-posinset` on each row's `listitem` and the next batch on focus reaching the last rendered row, open/toggle/reopen order, create, batches, limit, Clear all, disabled while open, focus return | A field without `experimental` changes, a toggle moves a row, Clear all skips confirmation, the limit disables checked rows, or the popover bundle carries the sheet. | `audit:Tokenizer/sheet` | +| Record structure | `scripts/check-knowledge.mjs` | Template-v3 current record and current-only frontmatter links | Invalid metadata, authority, or relationship references fail repository validation. | `audit:Tokenizer/knowledge` | ## Decision log @@ -347,14 +499,91 @@ popup or make stale choices interactive. This decision adds no public API, does not require a refetch, and does not change typed-query selection or direct single-select BaseTypeahead behavior. +### DEC-3 — The touch sheet presentation is an experimental option + +**Reference:** `component:Tokenizer/DEC-3` +**Decider:** `imdreamrunner`, `2026-10-23` + +Tokenizer offers its touch surface through the `presentation` option of the +optional `experimental` prop object, with the values `popover`, `bottom-sheet`, +and `adaptive` that the selection controls use. The option is marked +`@experimental` in declarations and `stability: 'experimental'` in authored +docs, so it may change or be removed in a patch release (`spec:AST-017/FR21`). +Omitting `experimental` keeps the inline combobox on every device. `adaptive` +resolves through the shared compact coarse-pointer policy, so a Tokenizer and a +Selector on the same page switch together. The value set is the selection +controls' `AdaptivePresentation`, because Tokenizer picks several items from a +searchable list as MultiSelector does. Rejected: the date and time fields' +`PickerPresentation` value set with `adaptive-bottom-sheet` (its native-picker +axis has no counterpart here). The sheet's parts expose no Tokenizer theming +targets while the option is experimental (`spec:AST-017/FR27`); sheet targets +follow the option's promotion. + +The sheet surface is one tall modal BottomSheet opened by a trigger that shows +the selected tokens: a search row on top, checkbox rows whose order is fixed +while the sheet is open and regrouped selected-first on the next open, +progressive rendering of long lists, a create row with a trailing Add action, +and a footer with a confirmed Clear all and Done. The sheet is a standalone +BottomSheet driven by `isOpen`. Rejected: switching surfaces automatically on +`pointer: coarse` without an opt-in (changes stable behavior). Rejected: a +top-level stable `presentation` prop before the surface has consumer evidence. +Rejected: separate manage and add sheets (two near-duplicate lists; the person +loses their place). Rejected: hosting the one sheet in a BottomSheetSwitcher. + +**INV7 owner exception** (`architecture:component-style-authoring` INV7; +`spec:AST-017/FR60`), in two parts, both beyond the FR60/DEC-29 allowance — +which admits an open layer five extra elements and a closed control none — +and recorded here with their measured counts as FR60 asks. _The closed field under `adaptive`._ INV7's +surface budget says a pointer-specific presentation is a change of attributes +and styles on the same elements; under `adaptive` on a compact coarse-pointer +client the closed field instead renders a different element — the trigger +button showing the selected tokens — in place of the inline combobox input, +its token remove actions, and its overflow handling, because the touch surface +is a sheet opened by a button, not a menu anchored to an input (DEC-3, FR16). +The swap removes elements rather than adding them: at rest 154 elements under a +fine pointer against 120 under a coarse one, measured as below, so the coarse +pointer stays under the fine count at rest. _The open layer._ The open sheet +has more elements than the open popover menu it replaces under a fine pointer, +because it is a different surface, not the same one restyled: a full-height modal with a +panel and handle, a search field with its clear action, a list whose rows are +checkbox rows with an indicator and a position, a footer with Clear all and +Done, and the sheet's own live region, in place of an anchored listbox of +plain options; the Clear all confirmation is in the DOM only from the Clear +all press until it closes. Measured in Chromium 149 on the +Experimental: adaptive story (five tokens selected), elements under `body`, +fine = desktop 1280×800 `pointer: fine`, coarse = iPhone 13 profile +`pointer: coarse`. At rest: 154 fine / 120 coarse (the trigger with its tokens +is smaller than the inline field with its token remove actions and input). +Open, at the query "e" — a searching state, so the footer is not rendered +(FR18): 157 fine — the inline field with its anchored menu open on the one +story item matching "e", plus the two shared page live regions the first +announcement creates — and 165 coarse. The coarse total reconciles exactly: +120 at rest, minus the 2 elements of the app-global surfaces host (the +toast host the open modal re-hosts inside itself, `spec:AST-003/FR18`), plus +the sheet's dialog of 47 — BottomSheet's dialog, panel, handle and edge tint +7; the sheet body 2; the search row with its clear action 19; the list 16 +(the list element, its scroll host, and two checkbox rows of 7 with indicator +and position); the sheet's live region 1; the re-hosted surfaces host 2. A +pending Clear all confirmation adds 20 while it is open; the footer, shown +only while the query is empty, adds 9. The forced `bottom-sheet` presentation +renders the same elements under either pointer (114 at rest, 165 open at "e", +its dialog 53 with the story's create row of 6). At rest the coarse pointer +never has more elements than the fine one: nothing of the sheet is in the DOM +until a press. The exception covers the closed `adaptive` trigger and the open sheet; its +counts are re-measured when either surface changes. + ## Open questions -None. The opening-control descriptor implementation remains pending; the -post-selection lifecycle has no unresolved decision. +- Should the sheet rows and trigger honor `renderItem` and `renderToken`, or + stay with the default row and token rendering they have now? Today they stay + with the defaults, as the Public concepts section records. +- The opening-control descriptor implementation remains pending; the + post-selection lifecycle has no unresolved decision. ## Content boundary This file does not duplicate consumer prop tables, usage examples, search algorithms, implementation code, current audit results, PowerSearch editor sizing, -or shared Layer mechanics. Consumer docs and release notes project only the -approved component behavior. +shared Layer mechanics, BottomSheet's own dialog and gesture contract, or the +sheet's on-screen keyboard behavior (`spec:AST-099`). Consumer docs and release +notes project only the approved component behavior.