From 80098e107083aad1d0f916cfc0a07ade89d2df07 Mon Sep 17 00:00:00 2001 From: Ivor Zhou Date: Fri, 23 Oct 2026 12:11:01 -0700 Subject: [PATCH] docs(spec): PowerSearch records the experimental touch sheet presentation (DEC-3) --- .../core/src/PowerSearch/PowerSearch.spec.md | 476 ++++++++++++++++-- 1 file changed, 423 insertions(+), 53 deletions(-) diff --git a/packages/core/src/PowerSearch/PowerSearch.spec.md b/packages/core/src/PowerSearch/PowerSearch.spec.md index ee340bbb1e177..e665ffa5e667c 100644 --- a/packages/core/src/PowerSearch/PowerSearch.spec.md +++ b/packages/core/src/PowerSearch/PowerSearch.spec.md @@ -6,8 +6,8 @@ id: component:PowerSearch authority: current archive_reason: null superseded_by: null -approved_by: cixzhang -approved_at: 2026-08-31 +approved_by: imdreamrunner +approved_at: 2026-10-23 owners: [cixzhang] review_triggers: [public-api, behavior, layout, accessibility] verified_by: @@ -29,7 +29,18 @@ architecture: architecture:public-component-api, ] contributing: [] -system_specs: [] +system_specs: + [ + spec:AST-017/FR21, + spec:AST-017/FR27, + spec:AST-020/FR15, + spec:AST-085/FR1, + spec:AST-085/FR2, + spec:AST-085/FR4, + spec:AST-086/FR3, + spec:AST-086/FR4, + spec:AST-086/FR14, + ] --- # PowerSearch component contract @@ -43,15 +54,40 @@ aligned to that outer surface. When the 720 CSS px readability cap makes the edi narrower, it stays near the stable control that opened it instead of stretching across the page or depending on input modality. +PowerSearch also offers an experimental touch presentation (DEC-3): the whole +field becomes one trigger showing display-only filter tokens, and the filter +work moves into a flow of tall modal bottom sheets — Filters, Add filter, and a +value step — that reads on a compact touch screen without a software keyboard +covering the editor. Omitting the option keeps the tokenizer and editor popover +on every device. + +Where the shipped PowerSearch does not yet meet a requirement, the row carries a +present-tense implementation gap note. + ## Compatibility and migration - Released default preserved: `no` for editor popovers anchored to a PowerSearch wider than 720 CSS px; other widths preserve their current sizing behavior. -- Compatibility class: intentional default layout and placement change with no - public API change. +- Compatibility class (DEC-1, DEC-2): intentional default layout and placement + change with no public API change. DEC-3 adds the `experimental.presentation` + option, outside the stable compatibility promise, as the Experimental surface + bullet states. - Controlled/uncontrolled behavior: unchanged. - Migration decisions: `component:PowerSearch/DEC-1` and `component:PowerSearch/DEC-2`. +- 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 tokenizer field and its editor + popover on every device. Changes to the option ship as `[experimental]` patch + releases (`spec:AST-017/FR22`). While the option is experimental the sheet + presentation adds no theming target (`spec:AST-017/FR27`). +- One stable fix alongside DEC-3, outside the experimental exception + (`spec:AST-017/FR22`): the result count is not announced on mount under + StrictMode (FR19). It qualifies as a `[fix]` under `spec:AST-017/FR5`: it + restores the documented contract (only a change after mount is spoken), no + `onChange` value, prop, or rendered text changes, and no valid consumer + must change. It ships as a `[fix]` patch release of its own. Consumer migration instructions belong in consumer docs and release notes. @@ -66,6 +102,10 @@ Consumer migration instructions belong in consumer docs and release notes. opening control's geometry. - Preserving the editing flow, content, and focus behavior while the editor's width and horizontal alignment change. +- The touch sheet presentation: its trigger, the Filters, Add filter, and value + sheets, the order of steps, which control receives focus after each step, + the staging of a value until the step confirms it, the `onChange` metadata of + each commit, and which PowerSearch props the sheets honor. **Does not own / non-goals** @@ -78,13 +118,54 @@ Consumer migration instructions belong in consumer docs and release notes. - A public prop for overriding editor width or placement. - Pointer-coordinate placement, pointer-versus-keyboard branches, continuous pointer tracking, or an always-on geometry observer. +- The compact-touch resolution rule behind `adaptive`. PowerSearch resolves + `adaptive` with the same shared policy the selection controls use + (`component:Selector/FR4`); it adds no query of its own. +- The sheets' dialog, the one scrim of the stack of open modal sheets, gestures, + snap behavior, keyboard accommodation, which sheet is on top and so takes a + dismissal, and the handoff between steps — owned by `component:BottomSheet` + (its sheet stack and same-update replace) and `family:overlay-dismissal`; + `component:BottomSheetSwitcher`, the deprecated predecessor of standalone + step sheets (its DEP-0015), only while the shipped flow still hosts its + steps in it. +- The list-value sheet a `string_list` or `entity_list` value opens inside the + value step — owned by `component:Tokenizer` (its DEC-3 sheet presentation). ## Public concepts -No public concept or prop is introduced. The inclusive 400–720 CSS px range and -conditional alignment are internal PowerSearch readability policy, not callsite -options. Existing `menuWidth` remains an exact pixel width for only the main -field/search menu. +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. + +The inclusive 400–720 CSS px range and conditional alignment of the editor +popover are internal PowerSearch readability policy, not callsite options. +Existing `menuWidth` remains an exact pixel width for only the main field/search +menu of the `popover` presentation. + +In the sheet presentation the props that describe the tokenizer's typeahead +popover have no surface to act on: `menuWidth` and `tokenOverflowBehavior`. +Passing either while a field resolves to `bottom-sheet` warns once in +development; neither is silently dropped. `ref` reaches the root element in +both presentations, the element this record promises +(`architecture:public-component-api/INV8`); `xstyle`, `className`, and `style` +reach the field container in both. `data-testid` reaches the field's primary +control: in the `popover` presentation the combobox input, where it is today, +and in the sheet presentation the trigger button. `handleRef.focusTypeahead()` and +`blurTypeahead()` act on the trigger. Every other prop keeps its meaning: `label` names the +trigger and `placeholder` is its empty text and the content-search placeholder; +`isDisabled`, `disabledMessage`, `startIcon`, `endContent`, `resultCount`, +`status`, `statusVariant`, `size`, `hasAutoFocus`, `onFocus`, and `onBlur` +apply to the trigger as FR11 states; `hasClear` governs Clear all (FR12); +`isReadOnly` governs the rows, Add filter, and the value step (FR12, FR13, +FR15); `maxSearchResults` and `maxOperatorMenuItems` apply to the Add filter +and value steps (FR13, FR17); `popoverSaveButtonLabel` and `components` apply +to the value step and the trigger (FR11, FR14); `maxTokenLength` applies to the +trigger's tokens as to the popover's (FR11). `timezoneID` changes nothing in +the sheet presentation: the trigger's tokens, the Filters rows, the custom +Editor's props, and the date editors read and format exactly as the popover +presentation's do (FR11, FR12, FR14, FR17). ## Behavioral and layout contract @@ -92,17 +173,34 @@ Current requirements identify their basis and distinguish accepted contract from implementation status. The contract is authoritative; the behavior remains unshipped until its implementation and verification complete. -| ID | Invariant | Basis | Status | -| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------- | -| FR1 | The filter-editor popover MUST have a rendered border-box width no greater than 720 CSS px. | Owner direction; legibility issue demonstrated by wide PowerSearch adopters | Accepted contract; implementation pending | -| FR2 | Before viewport collision handling, the editor's preferred width MUST be the outer PowerSearch width clamped to the inclusive 400–720 CSS px range. The 400 px floor MUST yield when the available viewport width cannot fit it. | Existing 400 px editing floor plus `component:PowerSearch/DEC-1` | Accepted contract; implementation pending | -| FR3 | When the 720 CSS px cap does not make the editor narrower than the outer PowerSearch, including when the 400 px floor is active, the editor MUST preserve current logical-start alignment to the outer PowerSearch. | Compatibility requirement | Accepted contract; implementation pending | -| FR4 | When the 720 CSS px cap makes the editor narrower, PowerSearch MUST resolve one stable opening control for that open: the inner typeahead input for the add path, or the actual activated token/control for the edit path. | Owner direction plus current composition analysis | Accepted contract; implementation pending | -| FR5 | For a capped editor, PowerSearch MUST compare the opening control's border-box distance from the outer PowerSearch's physical left and right edges, select the nearer outer edge, and align the editor to that edge. Equal distances MUST fall back to the outer PowerSearch's logical-start edge. The result MUST depend on control geometry, not pointer coordinates or pointer/keyboard modality. | `component:PowerSearch/DEC-2` | Accepted contract; implementation pending | -| FR6 | Opening-control identity and the selected edge MUST be latched for the open lifetime. Reopening from a control on the opposite side MUST recalculate both; movement after open MUST NOT move the editor. | Stable interaction and performance requirement | Accepted contract; implementation pending | -| FR7 | Width and preferred-edge selection MUST still yield to the layer runtime's viewport collision handling so the editor remains horizontally visible within the runtime's existing safe area. | `architecture:layer-runtime` | Accepted contract; implementation pending | -| FR8 | The width and alignment change MUST NOT alter editor content, field/operator/value state, vertical placement, focus movement, selection, dismissal, or screen-reader semantics. | Compatibility and accessibility requirement | Accepted contract; implementation pending | -| FR9 | The opening-control descriptor owned by `component:Tokenizer/DEC-1` and consumed by PowerSearch MUST remain package-internal. PowerSearch MUST NOT add a public width, maximum-width, input-ref, or positioning prop for this behavior. | `component:Tokenizer/DEC-1`, `architecture:public-component-api`, and `component:PowerSearch/DEC-1` | Accepted contract; implementation pending | +FR1–FR9 govern the `popover` presentation — the tokenizer field and its editor +popover, rendered when `experimental` is omitted or resolves to `popover`. The +editor popover does not render in the `bottom-sheet` presentation, whose surface +FR10–FR21 govern. + +| ID | Invariant | Basis | Status | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| FR1 | In the `popover` presentation, the filter-editor popover MUST have a rendered border-box width no greater than 720 CSS px. | Owner direction; legibility issue demonstrated by wide PowerSearch adopters | Accepted contract; implementation pending | +| FR2 | In the `popover` presentation, before viewport collision handling, the editor's preferred width MUST be the outer PowerSearch width clamped to the inclusive 400–720 CSS px range. The 400 px floor MUST yield when the available viewport width cannot fit it. | Existing 400 px editing floor plus `component:PowerSearch/DEC-1` | Accepted contract; implementation pending | +| FR3 | In the `popover` presentation, when the 720 CSS px cap does not make the editor narrower than the outer PowerSearch, including when the 400 px floor is active, the editor MUST preserve current logical-start alignment to the outer PowerSearch. | Compatibility requirement | Accepted contract; implementation pending | +| FR4 | In the `popover` presentation, when the 720 CSS px cap makes the editor narrower, PowerSearch MUST resolve one stable opening control for that open: the inner typeahead input for the add path, or the actual activated token/control for the edit path. | Owner direction plus current composition analysis | Accepted contract; implementation pending | +| FR5 | In the `popover` presentation, for a capped editor, PowerSearch MUST compare the opening control's border-box distance from the outer PowerSearch's physical left and right edges, select the nearer outer edge, and align the editor to that edge. Equal distances MUST fall back to the outer PowerSearch's logical-start edge. The result MUST depend on control geometry, not pointer coordinates or pointer/keyboard modality. | `component:PowerSearch/DEC-2` | Accepted contract; implementation pending | +| FR6 | In the `popover` presentation, opening-control identity and the selected edge MUST be latched for the open lifetime. Reopening from a control on the opposite side MUST recalculate both; movement after open MUST NOT move the editor. | Stable interaction and performance requirement | Accepted contract; implementation pending | +| FR7 | In the `popover` presentation, width and preferred-edge selection MUST still yield to the layer runtime's viewport collision handling so the editor remains horizontally visible within the runtime's existing safe area. | `architecture:layer-runtime` | Accepted contract; implementation pending | +| FR8 | In the `popover` presentation, the width and alignment change MUST NOT alter editor content, field/operator/value state, vertical placement, focus movement, selection, dismissal, or screen-reader semantics. | Compatibility and accessibility requirement | Accepted contract; implementation pending | +| FR9 | In the `popover` presentation, the opening-control descriptor owned by `component:Tokenizer/DEC-1` and consumed by PowerSearch MUST remain package-internal. PowerSearch MUST NOT add a public width, maximum-width, input-ref, or positioning prop for this behavior. | `component:Tokenizer/DEC-1`, `architecture:public-component-api`, and `component:PowerSearch/DEC-1` | Accepted contract; implementation pending | +| FR10 | `experimental.presentation` selects the surface. `popover`, the value when `experimental` or the option is omitted, is the tokenizer field with its typeahead and editor popover, unchanged except as FR19 (no mount announcement) states. `bottom-sheet` is the trigger and sheet flow of FR11–FR21 on every device. `adaptive` resolves to `bottom-sheet` on compact coarse-pointer screens and to `popover` otherwise, through the same shared resolution the selection controls use (`component:Selector/FR4`). That resolution answers `popover` on the server and during the hydrating render, so an `adaptive` field server-renders as, and hydrates without a mismatch as, the popover presentation's tokenizer field; on a compact coarse-pointer screen the first client render after hydration swaps it to the trigger. Focus follows the resolution rule below: before any interaction nothing in the field has focus and nothing moves, except that with `hasAutoFocus` the server markup's `autofocus` has focused the combobox input, and the swap then moves focus to the trigger within the same visit — the caller is told of neither a blur nor a new focus. The two closed fields share the field's wrapper, border, and minimum height for the size, and show the same tokens, but their row counts may differ: the popover field's editable tokens each carry a 16 px remove control the trigger's display-only tokens do not (FR11), so the trigger's tokens can wrap into fewer rows (seven filters in a 326 px field take seven rows editable and six as display-only tokens), and the popover field also carries its inline combobox input after the tokens, which may take a row of its own — the swap then changes the field's height by those rows (none on the measured story at 390 px, one token row at 414 px). A resolution change while the flow is open takes effect after it closes and focus has returned to the trigger (the focus the close reports, FR11) — 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; when the new presentation is `popover`, focus then moves to the tokenizer's combobox input as the same visit — no `onBlur` and no second `onFocus` reaches the caller. A resolution change while the flow is closed takes effect at once; when focus is anywhere inside the closed field — the trigger, the combobox input, a token or its remove control, the clear control, or an interactive `endContent` — and the focused element does not survive the switch, focus moves to the other presentation's primary control (the combobox input, or the trigger) as a continuation of the same visit — no `onBlur` and no `onFocus` reaches the caller for the hand-off; when the focused element survives, or focus is outside the field, focus stays where it is. When the switch to the sheet presentation happens while the popover presentation's editor popover is open, the popover and its focused control unmount with the tokenizer field, and focus moves to the trigger. Focus never lands on the page body. | DEC-3; touch-flow evidence from the Lab prototype | Current contract. Implementation gap: the shipped PowerSearch has no `experimental` prop and renders the tokenizer field on every device. | +| FR11 | In the sheet presentation the field's control is one trigger: a button with `aria-haspopup="dialog"` and `aria-expanded`, whose accessible name is the field label followed by the trigger's visible text — the placeholder while no filter exists, otherwise the text of the filter tokens — so the name contains what is visible and exposes the selected filters. The tokens are display-only — each is the token's static structure (`component:Token/FR2`): no remove control, no click of its own, no inner control inside the trigger button — and the tokens, the placeholder, and the empty space around them belong to the trigger: a tap on any of them opens the flow. Tokens wrap onto further lines; the trigger does not reserve a row of its own once a token exists. The trigger's tokens are the popover presentation's tokens: the same component, the same words for a value, and the same zone handling, so a value reads on the trigger exactly as it reads on the tokenizer field. A `components[type].Token` override renders on the trigger in place of PowerSearch's token, receiving the props the popover presentation hands it — `config`, `filter`, `field`, `operator`, `maxLength`, `isDisabled` — with `onClick` and `onRemove` undefined, as the popover hands them for a read-only filter: the trigger's tokens are display-only, and the override formats its own value. `startIcon` renders in the trigger's lane. The result count and `endContent` render in the field's end lane, outside the trigger button and after it: an interactive `endContent` keeps its own tab stop, its own taps, and its own accessible name, and a tap on it does not open the flow, so a caller's control is never nested inside the component's button and the end lane's text is not part of the trigger's name. `size` sizes the trigger field and its tokens as it sizes the tokenizer field; `statusVariant` places `status.message` as the popover presentation places it; `hasAutoFocus` focuses the trigger on mount. `onFocus` fires when focus enters the field from outside and `onBlur` when it leaves the field: opening the flow moves focus into a sheet, outside the field, so `onBlur` fires, and the close that returns focus to the trigger fires `onFocus` — as the popover presentation fires them around its editor. While disabled the trigger stays focusable with `aria-disabled="true"`, never carries the native `disabled` attribute, does not open, and shows `disabledMessage` as the field does (`spec:AST-085/FR1`, `FR2`). When the component becomes disabled with the flow open, the flow closes and focus moves to the trigger, the destination this record names (`spec:AST-085/FR4`). | DEC-3; Lab touch-flow evidence; `spec:AST-085`; WCAG 2.5.3 | Current contract. Implementation gap: no trigger exists. | +| FR12 | Opening the flow from a field with at least one filter, or from a read-only field, shows the Filters sheet, a tall modal sheet named "Filters": a heading, an Add filter action in the header while the field is interactive, one row per filter showing "{field label} {operator label}" (one catalog message, so a locale can reorder it) and, in bold, the value as the popover presentation's token shows it — the same function, so the same words and the same zone handling, with the token's width budget lifted: the row never truncates a single value and never collapses a list to its "{n} items" count, both of which the token does once a value's text outgrows its width — with the token's summaries resolved from the catalog (`@astryx.powersearch.valueEditor.dateRange`, `itemsCount`, `filtersCount`, ICU plurals), so in English a row reads as the token does and another locale translates it: an `integer` or `float` value reads as the token's own text (`String(value)`: `2.3456`, `1e+21`, `1e-7`, and `0` for negative zero) wherever the locale writes numbers with the token's numerals — Latin digits and a `.` decimal mark — and elsewhere with the locale's digits and decimal mark, no grouping and no rounding, so that it differs from the token only in notation (`0,0000001`, `-0`); a `string_list`, `enum_list`, or `entity_list` value reads as all of its items joined by the locale's narrow conjunction list (`Intl.ListFormat`, `{type: 'conjunction', style: 'narrow'}` — "Open, Closed" in English, "高、中" in Simplified Chinese and Japanese, "A, B und C" in German): a conjunction list keeps every item separated in every shipped locale, where the `unit` type runs some together, and of the conjunction styles the narrow one is the one that reads in English exactly as the token's ", " join does; a `date_absolute` value reads as a day with no time, a `date_range` as "date range", a `nested` value as "{n} filters", and a `date_relative` value as the value step's choice row reads it ("3 days ago", FR20) when a choice matches the stored value, otherwise as stored (the value is any string; the token always shows it as stored) — and a footer with Done and, while `hasClear` is set, the field is interactive, and at least one filter exists, Clear all. A row's label and value are one catalog message, "{label} {value}", the same message the removal announcement of AR6 uses, with the value segment bold, so a locale that reorders it reorders both. A filter the field can edit is a row button with a trailing chevron; a read-only filter, or every filter while the field is read-only, is a plain row, as the popover gives such a token no remove control. A `nested` filter's row is a row button like any other; its step edits the group with the popover presentation's built-in nested editor (FR14). An editable filter whose field or operator the config no longer has — which the trigger still shows as a token — lists as a row button reading the field's label when the config still has the field and the raw key for whatever is gone — "{field key} {operator key}" when both are, "{field label} {operator key}" when only the operator is — that opens the step's "This filter is no longer available" state, where Remove is the way out, as the popover's token removal is; it is a plain row while the filter, or the field, is read-only. With no filter the body shows "No filters selected". Done closes the flow. Clear all commits `onChange([], 'remove', )` — the same change the popover presentation's clear control commits, read-only filters included — and closes the flow. After the flow closes focus returns to the trigger. | DEC-3; Lab touch-flow evidence; current popover clear semantics | Current contract. Implementation gap: no Filters sheet exists. | +| FR13 | Add filter — from the Filters sheet's header, or directly when an interactive field with no filter opens — is a tall modal sheet named "Add filter" with a Back action. Without a content-search field it lists the visible fields grouped by `group` (ungrouped first, each group a named list), each a row button with the field's icon and a trailing chevron, and, once the field count reaches eight, a "Search filters" field above the list that matches labels and `typeaheadAliases` case-insensitively, caps a non-empty query's matches at `maxSearchResults`, and shows "No filters match your search" when nothing matches. With a `contentSearchFieldKey` whose default operator takes a `string` value it shows instead a "Search" searchbox using `placeholder`, focused on arrival, with the same suggestions the popover presentation's typeahead offers listed inline as row buttons ("Search results"): fields, field-and-operator pairs, value matches, and a leading free-text item for the content field; Enter commits the first suggestion, ArrowDown and ArrowUp move between the search and the rows — arrow movement across independently focusable controls, recorded here as this record's exception to `spec:AST-020/FR15`, which adopts arrow keys for a single composite only: the searchbox-to-list continuity is what the popover presentation's combobox gives the same suggestions, and the rows stay in the Tab order as well — an IME-composing Enter does nothing, and a query with no suggestion shows "No results found" — the searchbox's `aria-controls` names the results list, or that message while nothing lists, so it always names an element; the field list's "No filters match your search" likewise shows only for a non-empty query that matches nothing, never for a config with no field. A suggestion that carries a value commits `onChange([...filters, filter], 'add', filters.length)`, announces `Added {label}` through the flow's own polite region (AR6), and returns to the Filters sheet; any other suggestion or field row opens the value step with that field and operator. Back returns to the Filters sheet, or closes the flow when the sheet was the entry point. Every visible field lists, a field whose every operator is `nested` included: the value step edits every operator's value (FR14). | DEC-3; Lab touch-flow evidence; current typeahead source | Current contract. Implementation gap: no Add filter sheet exists. | +| FR14 | The value step is a tall modal `form` sheet that edits every operator's value; its title is the dialog's name (AR5), and for a filter whose field or operator the config no longer has it reads as the Filters row does (FR12): the field's label when the config still has the field, the raw key for whatever is gone. When the field offers more than one operator the body starts with an "Operator" radio group whose options read "{field label} {operator label}" and list the field's operators, with the operator the step opened with checked — the filter's operator while editing, and while creating the operator the suggestion named (FR13) or else the field's default operator, else its first; otherwise there is no group and the title is "{field label} {operator label}" naming that operator. When no operator label applies the title is the field label. The body below the group is the editor for the checked operator's value type: the sheet-native controls of FR17, or for a `nested` value the popover presentation's built-in nested editor — the sub-filter tree with its Add action, each sub-filter's field, operator, and value controls, and removal — rendered as the popover renders it, with one difference: the step's Operator group takes the place of the editor's root group-operator picker, which is not shown, and the tree root reads the checked operator's label; a sub-filter's controls are the popover editor's own, in their default presentation (a Selector's menu, the Tokenizer's inline field), not the sheet's choice rows (FR17) or Tokenizer sheet (FR21), which govern the step's top-level value only, because the sheet passes no `experimental` option down to them; the tree is its own width container, so below 400 CSS px its rows wrap instead of overflowing the sheet, as they wrap in a narrow popover. A group is created and edited the same way in both presentations; switching the operator swaps the body. A value chosen in the body is staged, not committed: no `onChange` fires until the footer confirms it. Changing the operator keeps a staged value only when the new operator reads the same value type; an `empty` operator stages `{type: 'empty'}`. The footer holds Cancel and the confirm action — `popoverSaveButtonLabel` when given, otherwise "Add filter" while creating and "Apply" while editing — disabled until the staged value is complete, plus Remove while editing. When `components[type].Editor` is set for the checked operator's value type it replaces the body below the Operator group and the footer — the group stays outside it, so switching operators still swaps the body — receiving the props the popover presentation hands it, with the same meaning: the caller's `config`, the partial `filter`, `mode`, `saveButtonLabel`, `isReadOnly`, `onSave`, and `onCancel`, and nothing the popover does not pass; `onSave(filter)` commits it, `onSave(null)` removes the filter while editing and returns to Filters unchanged while creating. A controlled update that removes the field or operator being edited replaces the body with "This filter is no longer available", announced from its rendered element inside the sheet (AR6), and keeps Remove (while editing) and Cancel as the ways out; when focus was on a control of the replaced body it moves to Remove, else to Cancel, so it never falls to the page body inside the open sheet (FR16). | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no value step exists. | +| FR15 | Confirming a created filter commits `onChange([...filters, filter], 'add', filters.length)`. Tapping an editable Filters row opens the value step on that filter in edit mode; Apply commits `onChange(updated, 'edit', index)` and Remove commits `onChange(rest, 'remove', index)`, where `index` is the filter's position at commit time: a controlled parent that reorders or clones its filters while the step is open is followed by object identity, then by a structurally equal filter in the original slot, then by a unique structural match; when the target is gone or ambiguous nothing is committed and the flow returns to the Filters sheet, focus landing as FR16 states for a row that no longer exists. Every commit returns the flow to the Filters sheet. While the field is read-only, or disabled, no commit happens. | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no edit path exists. | +| FR16 | Focus follows the step: after a commit from the value step while creating, Add filter in the Filters header; after a content-search suggestion commits a filter directly, that filter's row; after Apply or Cancel from editing, the edited filter's row — followed across a controlled reorder or clone, and, when a controlled update removed it or left two filters it could be, the nearest remaining row that is a button around its former position (the following one on a tie), else Add filter; after Remove, the nearest remaining row that is a button (the following one on a tie) — a read-only filter's row, and any row while the field is read-only, are plain rows and are passed over — or Add filter when none remains; after Cancel from creation, the field row that opened the step, or the content search when a suggestion opened it — and when a controlled update has removed that field, or the content-search field itself, the Add filter sheet's first control: its search field when one shows, else its first field row, else Back; after Back from Add filter, the Filters header's Add filter; after a caller's `Editor` override calls `onSave(null)` while creating, the Filters header's Add filter (the flow returns to Filters unchanged, FR14); and, as one rule for the Filters and Add filter sheets, when a controlled update unmounts the control that holds focus — a filter's row, a dropped field's row, Clear all gone with the last filter — the nearest remaining row that is a button, counted among the button rows by the removed one's index (the following one, else the last) — a plain row (a read-only filter, or every filter while the field is read-only) is passed over, as the Remove and Apply/Cancel rules pass it over — else the sheet's first control (the Add filter sheet's search field, Add filter in the Filters header), else the sheet's panel, so focus never lands on the page body inside the open sheet; when a controlled update replaces the value step's body with the unavailable state while focus was on one of its controls, Remove, or Cancel when there is no Remove (FR14); after the flow closes, the trigger. | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no flow exists. | +| FR17 | The value step edits every value type with the same editor the popover presentation uses (`nested` through its built-in nested editor, FR14), with three sheet-native differences: `enum`, `enum_list`, and `date_relative` render as full-width choice rows with a trailing indicator instead of a dropdown that would stack a second layer on the sheet — `radio` rows in a `radiogroup` named by the field label for `enum` and `date_relative` (a tap selects the row; the group is one tab stop — the checked row, else the first — and the arrow keys move focus and check the row they land on, wrapping, Home and End move to and check the first and last row, and Space or Enter checks the focused row — `spec:AST-020/FR15`), `checkbox` rows in a `group` named by the field label for `enum_list` (a tap toggles the row; each row is its own tab stop, toggled with Space or Enter), each carrying its state as `aria-checked`, as the Tokenizer's sheet rows do; `integer` and `float` stage every parseable keystroke, so the confirm action enables while the person is still typing, and unstage when the field is cleared or stops parsing, so it disables again over an empty field; and a `string_list` or `entity_list` value edits through the Tokenizer's own sheet presentation (`component:Tokenizer/FR16–FR24`), its trigger showing the staged tokens in the step and its sheet titled with the field label. `date_absolute` and `date_range` values are read and written exactly as the popover presentation's editors read and write them — a picked day as its UTC midnight, a stored instant as its UTC day — so within one `timezoneID` and device the sheet agrees with the popover everywhere (see the known gap on date-only days). `maxOperatorMenuItems` caps the string and entity suggestions as in the popover presentation. | DEC-3; Lab touch-flow evidence; current value editors | Current contract. Implementation gap: no sheet editors exist. | +| FR18 | Every list in the sheets derives synchronously from the config — the field list, its search, the content-search suggestions, and the choice rows — so no sheet shows an empty state before results arrive: "No filters match your search" and "No results found" render only for a non-empty query whose synchronous result set is empty. The value typeaheads and tokenizers inside the value step own their own pending states. | DEC-3; `architecture:react-component-runtime/INV1` | Current contract. Implementation gap: no sheets exist. | +| FR19 | The result count renders at the end of the field in both presentations and is announced only when it changes after mount; the first render announces nothing, including under StrictMode's effect replay. The announcement takes the route `spec:AST-086/FR14` names for where the field is at that moment: inside the open sheet's dialog while a sheet of the flow is open (AR6), otherwise inside the nearest open native modal that holds the field (a Dialog, a modal Drawer), and the shared polite channel only when no modal does — never a channel the record names itself. | Current popover behavior; `architecture:react-component-runtime/INV3` | Current contract. Implementation gap: the shipped guard announces the initial count under StrictMode. | +| FR20 | Every string the sheets introduce comes from the `@astryx.powersearch.*` catalog, reusing the existing editor keys where their meaning fits (Cancel, Apply, Operator, Value, Values, Entities, the placeholders, the result count). Content the popover presentation already renders keeps its source — field, operator, and value text from the config and its formatters — except that every label the sheets render for a generated choice comes from the catalog as one ICU message with a plural: the relative-date choice rows (`@astryx.powersearch.sheet.relative{Days,Weeks,Months}{Ago,FromNow}`, the same choices and, in English, the same words as the popover's editor lists) and the value summaries of FR12. | DEC-3; catalog contract | Current contract. Implementation gap: no sheet strings exist. | +| FR21 | A `string_list` or `entity_list` value inside the value step opens the Tokenizer's sheet above the value step. Escape and swipe dismiss only that topmost sheet; closing it returns focus to its trigger inside the value step; the value step keeps its staged state. | DEC-3; `family:overlay-dismissal`; `component:Tokenizer/FR18` | Current contract. Implementation gap: no nested sheet exists. | ### Allowed variation @@ -116,19 +214,49 @@ until its implementation and verification complete. - **AV4 — Internal representation.** The shared opening-control descriptor may use refs, elements, or an equivalent package-internal value. Its observable geometry, lifetime, and precedence must satisfy FR3–FR9. +- **AV5 — Sheet flow composition.** The sheet presentation hosts its steps as + standalone BottomSheets driven by one step state — a step that closes in the + update another opens is replaced by it in place (`component:BottomSheet` + FR10), the pattern `component:BottomSheetSwitcher` DEP-0015 names as the + deprecated switcher's replacement — or, until that replace is shipped, in a + BottomSheetSwitcher; it may compose List, RadioList, TextInput, Button, Heading, and Text in any + internal structure, and may load the whole surface on demand, provided + FR10–FR21, AR4–AR6, and PR5 hold and the composition stays within + `architecture:component-style-authoring/INV7`: one presentation mounted per + open layer, no content mounted twice, and the open flow under a coarse + pointer with at most as many elements as the popover presentation's open + editor under a fine pointer — measured on the Experimental Adaptive story at + 390×844 as `document.body.querySelectorAll('*').length`: 166 at rest and 334 + with the editor popover open under a fine pointer; 123 at rest, 296 with the + Filters sheet open, 296 with the Add filter sheet open, 318 with the value + step open on a choice-row value, 328 on a list value, and 401 with the + Tokenizer's list-value sheet open above the step under a coarse pointer — + the last by the owner exception DEC-3 records. The Tokenizer's list-value sheet opens as a + standalone sheet above the step in either composition. +- **AV6 — Sheet layout.** Sheet padding, the field-list search threshold's + visual placement, row density, and the footer's button widths may vary; the + 44px minimum size of every sheet action and the edge-to-edge rows on the + body's inset are the constants. ### Representative states -| State | Required invariant | Allowed variation | -| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| Outer PowerSearch narrower than 400 px | Editor prefers 400 px and logical-start alignment to the outer surface, but yields to available viewport width. | Layer runtime may shift or shrink it to remain visible. | -| Outer PowerSearch from 400 through 720 px | Editor matches the outer surface and remains logical-start aligned. | Existing vertical collision behavior may flip. | -| Wider than 720 px; add from inner input near left | Editor is 720 px wide and prefers the outer surface's physical left edge. | Viewport collision handling may shift it. | -| Wider than 720 px; add from inner input near right | Editor is 720 px wide and prefers the outer surface's physical right edge. | Viewport collision handling may shift it. | -| Wider than 720 px; edit a token/control near either end | Editor is 720 px wide and prefers the outer edge nearest that activated control. | Custom token markup may vary; identity remains exact. | -| Wider than 720 px; opening control is equidistant | Editor is 720 px wide and uses the outer surface's logical-start edge. | Direction determines the physical start edge. | -| Pointer, keyboard, or programmatic activation | The same stable opening control produces the same preferred edge regardless of activation modality. | Focus behavior remains unchanged. | -| Reopen from a control on the opposite side | Opening-control identity and preferred edge are recalculated for the new open, then remain latched until close. | Later layout changes do not move the open editor. | +| State | Required invariant | Allowed variation | +| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| Outer PowerSearch narrower than 400 px | Editor prefers 400 px and logical-start alignment to the outer surface, but yields to available viewport width. | Layer runtime may shift or shrink it to remain visible. | +| Outer PowerSearch from 400 through 720 px | Editor matches the outer surface and remains logical-start aligned. | Existing vertical collision behavior may flip. | +| Wider than 720 px; add from inner input near left | Editor is 720 px wide and prefers the outer surface's physical left edge. | Viewport collision handling may shift it. | +| Wider than 720 px; add from inner input near right | Editor is 720 px wide and prefers the outer surface's physical right edge. | Viewport collision handling may shift it. | +| Wider than 720 px; edit a token/control near either end | Editor is 720 px wide and prefers the outer edge nearest that activated control. | Custom token markup may vary; identity remains exact. | +| Wider than 720 px; opening control is equidistant | Editor is 720 px wide and uses the outer surface's logical-start edge. | Direction determines the physical start edge. | +| Pointer, keyboard, or programmatic activation | The same stable opening control produces the same preferred edge regardless of activation modality. | Focus behavior remains unchanged. | +| Reopen from a control on the opposite side | Opening-control identity and preferred edge are recalculated for the new open, then remain latched until close. | Later layout changes do not move the open editor. | +| Sheet presentation, closed | The trigger shows the tokens or placeholder, `aria-expanded="false"`; its name is the label plus the visible text. | Token wrapping and placeholder text may vary. | +| Sheet presentation, Filters | One tall modal sheet named "Filters": heading, Add filter, one row per filter, Done and Clear all. | Row count and empty state may vary. | +| Sheet presentation, Add filter | Grouped field rows, or the content search with inline suggestions; Back returns or closes. | Field search appears from eight fields. | +| Sheet presentation, value step | Title, operator radios when needed, the staged editor, Cancel and the confirm action, Remove while editing. | Editor per value type follows FR17. | +| Sheet presentation, list value | The Tokenizer's sheet opens above the step, titled with the field label, as the top sheet of the stack under its one scrim; Escape closes it alone. | One backdrop per stack, dismissal at the top sheet (`component:BottomSheet` FR6, FR7). | +| Sheet presentation, disabled while open | The flow closes; focus returns to the trigger; the trigger stays focusable. | Exit motion may vary. | +| Sheet presentation, `adaptive` resolution | The surface follows the shared compact-touch query while closed; an open flow keeps its presentation until it closes. | The query is shared, not PowerSearch-owned. | ### Transformation and precedence order @@ -159,6 +287,16 @@ until its implementation and verification complete. MUST own the 400–720 width policy and collision behavior. JavaScript may select the per-open anchor/alignment descriptor but MUST NOT continuously calculate popup width or collision offsets. +- **PR5 — The sheet surface is opt-in cost.** A field in the `popover` + presentation does not load the sheet flow's code; the flow's chunk loads on + demand while a field resolves to `bottom-sheet`, so the first press does not + wait for the network, and renders nothing on the server or during the + hydrating render. At rest the field renders none of the flow: its three + dialogs, rows, search field and editors are mounted only from the press until + the exit has completed and focus is back on the trigger + (`architecture:component-style-authoring/INV7`); the trigger, its end lane + (the result count, `endContent`) and the field chrome of FR11 render as the + field does. Implementation gap: no sheet surface exists. ## Accessibility contract @@ -170,19 +308,80 @@ until its implementation and verification complete. combobox, listbox, option, menu, or editor semantics and accessible names. - **AR3 — Visible content.** Viewport clamping MUST keep all editor controls horizontally reachable without requiring page-level horizontal scrolling. +- **AR4 — Trigger semantics.** In the sheet presentation the trigger is a button + named by the field label plus its visible text, with `aria-haspopup="dialog"` + and `aria-expanded`; `status.message` and `disabledMessage` describe it; a + disabled trigger uses `aria-disabled` and keeps its focus order; an + interactive `endContent` is a sibling of the trigger with its own name and + tab stop, never a descendant of it. Implementation + gap: no trigger exists. +- **AR5 — Sheet semantics.** Each step is a modal dialog named by its title + ("Filters", "Add filter", the value step's title); the Filters rows, field rows, and + suggestion rows are buttons named by their visible text; a choice row is a + `radio` (single choice) or a `checkbox` (`enum_list`) named by its visible + text and checked through `aria-checked`, in a `radiogroup` or `group` named + by the field label — the radio group one tab stop with arrow-key movement + that checks and Space or Enter checking the focused row, the checkbox rows + one tab stop each toggled by Space or Enter (FR17, `spec:AST-020/FR15`); + the operator step is a radio group named "Operator" of native radio inputs, + which the arrow keys move and check and Space checks; the content search is a searchbox named "Search" + controlling the suggestion list; the field-list search is a textbox named + "Search filters"; every sheet action is at least 44px tall. Implementation gap: + no sheets exist. +- **AR6 — Announcements and focus.** While a sheet is open, every announcement + the flow makes — `Added {label}` for a committed suggestion, `Removed {label}` + for a filter removed from the value step (the label is the Filters row's + text — the same "{label} {value}" catalog message the row renders, its + summaries and relative-date values resolved from the catalog as FR12 + states; the popover presentation's removal is the Tokenizer's `tokenRemoved` + with the filter's `formatFilterValue` label, "{field}: {operator} {value}" + — not the token's text: `1500` with `units: 'ms'` shows "1500" on the token + and is announced "1,500 ms" — and the sheet's is the Filters row's text, so + the two readings differ wherever FR12's row and `formatFilterValue` differ), + the result count of + FR19, the Add filter sheet's empty-result messages ("No results found", + "No filters match your search") and the value step's "This filter is no + longer available" (FR14), which are plain rendered text, not an EmptyState, + read from the rendered element and spoken once per query that matches + nothing, or once per opened filter, by the shared own-content module + (`architecture:react-component-runtime/INV10`), so nothing speaks them a + second time — is delivered by a persistent polite live region inside the + open sheet: an element that stays mounted while + its text changes and never mounts already holding the text to announce + (`spec:AST-086/FR3`), that delivers the content added inside it at its own + polite urgency (`spec:AST-086/FR4`), and that is the route an owner inside an + open native modal takes (`spec:AST-086/FR14`), so the announcement reaches + assistive technology behind the native modal dialog where a channel outside + it does not. Clear all closes the flow and announces "All filters + removed" once the sheet's dialog has finished its exit and focus is back on + the trigger — not before, since the closing dialog stays modal through its + exit and hides both its own region and the channel outside it — through the + route `spec:AST-086/FR14` names for the field's position then: the modal + that holds the field, or the shared channel when none does. A result count that changes while the flow is + closing (Clear all's commit) waits the same way and the two are spoken as one + catalog message, "All filters removed. {result count}", so a locale orders + and punctuates the pair; while the flow is closed the result count takes the + same FR14 route (FR19). Focus moves as FR16 states and returns to the + trigger after the flow closes. Implementation gap: no flow exists. ## Design relationships -| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------ | -------------- | ----------------------- | -| Search field and tokens | Continue presenting the structured query and its current interaction states. | Current source and public docs | Prominent | FR8 | -| Main field/search menu | Retain its exact `menuWidth` surface and current selection behavior; it does not inherit the editor's 720 px cap. | Current source and public docs | Prominent | Ownership boundary, FR9 | -| Filter-editor popover | Stay within the readable 400–720 px range before viewport clamping. | `component:PowerSearch/DEC-1` | Prominent | FR1–FR3, FR7 | -| Width-capped editor | Prefer the outer edge nearest the stable opening control, independent of activation modality. | `component:PowerSearch/DEC-2` | Prominent | FR4–FR8 | -| Keyboard-opened editor | Use the same control-geometry rule and preserve current focus behavior. | Accessibility contract | Prominent | FR5, FR8, AR1 | - -This proposal changes editor geometry only. It does not introduce a new visual -state, theme target, or public configuration concept. +| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -------------- | ----------------------- | +| Search field and tokens | Continue presenting the structured query and its current interaction states. | Current source and public docs | Prominent | FR8 | +| Main field/search menu | Retain its exact `menuWidth` surface and current selection behavior; it does not inherit the editor's 720 px cap. | Current source and public docs | Prominent | Ownership boundary, FR9 | +| Filter-editor popover | Stay within the readable 400–720 px range before viewport clamping. | `component:PowerSearch/DEC-1` | Prominent | FR1–FR3, FR7 | +| Width-capped editor | Prefer the outer edge nearest the stable opening control, independent of activation modality. | `component:PowerSearch/DEC-2` | Prominent | FR4–FR8 | +| Keyboard-opened editor | Use the same control-geometry rule and preserve current focus behavior. | Accessibility contract | Prominent | FR5, FR8, AR1 | +| Sheet trigger | The field's control surface in the sheet presentation: display-only tokens or the placeholder; `endContent` beside it. | PowerSearch and Token source | Prominent | FR11, AR4 | +| Filters sheet | Heading, Add filter, divided edge-to-edge rows with bold values, sticky Done and Clear all. | PowerSearch source; `component:List` | Prominent | FR12, FR16 | +| Add filter sheet | Back, grouped field rows or the content search with inline suggestion rows. | PowerSearch source; `component:List` | Prominent | FR13 | +| Value step | Title, operator radios, the staged editor, Cancel, the confirm action, Remove. | PowerSearch source; current editors | Prominent | FR14, FR15, FR17 | + +DEC-1 and DEC-2 change editor geometry only. The sheet presentation (DEC-3) adds +no theming target or theme state while it is experimental +(`spec:AST-017/FR27`); the root element keeps the existing `power-search` +target in both presentations. ## Family and system relationships @@ -193,13 +392,48 @@ state, theme target, or public configuration concept. the editor placement result intentionally does not branch on modality. - `architecture:public-component-api` owns the public-surface bar. Width and placement remain internal because they are component usability invariants rather - than adopter policy. + than adopter policy. `architecture:public-component-api/INV16` governs the + touch presentation DEC-3 adds: the fine-pointer surface — the tokenizer field + and its editor popover, `popover` — stays reachable on every device through + the presentation value; every touch mode is reachable by callers, as + `bottom-sheet` on every device and `adaptive` on compact coarse-pointer + screens; and the caller's explicit value wins over any default. DEC-3's + default is `popover`, not the touch surface, because the option is + experimental (`spec:AST-017/FR21`): INV16 makes touch the default "where it + serves touch users" once a surface is stable, so an `adaptive` default is the + stable-promotion decision, recorded then, not a departure now; until then + touch users are served by the caller opting in. - `family:overlay-dismissal` continues to own shared Escape and platform-close - ordering; this proposal changes no dismissal behavior. + ordering; DEC-1 and DEC-2 change no dismissal behavior, and DEC-3's sheets + take the family's dismissal paths through `component:BottomSheet`, as the + "Does not own" bullet on the sheets' dialog, scrim, gestures, snap behavior, + keyboard accommodation, and dismissal paths states. - `component:Tokenizer/DEC-1` is current authority for the package-internal stable outer-field/opening-control descriptor and its safe fallback semantics. PowerSearch consumes that identity only after resolving an editor narrower than the outer field; Tokenizer contributes no PowerSearch width policy. +- `component:Tokenizer/DEC-3` owns the sheet presentation a `string_list` or + `entity_list` value opens inside the value step; PowerSearch passes the field + label as that sheet's title and claims nothing of its rows, search, or Clear all. +- `component:BottomSheet` owns the sheets' dialogs, the stack's one scrim, + gestures, keyboard accommodation, and the handoff between steps (a step that + closes in the update another opens is replaced by it); the Tokenizer's sheet + above the value step is the top sheet of that stack, so Escape, a scrim tap, + a swipe, and a platform close request reach it alone (`component:BottomSheet` + FR7), with `family:overlay-dismissal` owning the Escape and platform-close + ordering. `component:BottomSheetSwitcher` is deprecated (DEP-0015) and owns + only the shipped flow's step hosting until the steps become standalone + sheets. +- `architecture:public-component-api/INV8` governs where `ref` lands (the root + element this record promises, in both presentations); `data-testid` reaches + the primary control of each presentation. PowerSearch is a composition that consumes `family:input-fields` + members and is not itself a member of that family. +- `spec:AST-086/FR3`, `FR4`, and `FR14` govern the flow's own polite live + region: a persistent container inside the open sheet that delivers the + content added to it, the route an owner rendered inside an open native modal + takes because the shared page-body channel is not exposed there. +- `spec:AST-017/FR21`, `FR22`, and `FR27` govern the experimental boundary, its + patch-level changes, and the absence of new stable theming targets. ### Current dependency @@ -223,13 +457,14 @@ mark implementation and focused real-Chromium verification as pending. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| ---------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------- | -| FR1–FR3, FR7 | PowerSearch unit tests plus real-Chromium geometry assertions | 399/400, 720/721, wide outer surface, narrow viewport, LTR/RTL, collision | Removing either clamp, capping the main menu, or allowing viewport overflow fails. | `audit:PowerSearch/layout` | -| FR4–FR6, FR9 | Opening-path tests plus real-Chromium geometry assertions | Add from inner input; edit actual left/right/equidistant token/control; pointer/keyboard; reopen opposite side | Using pointer coordinates, modality, wrapper-only identity, stale identity, or public props fails. | `audit:PowerSearch/interaction` | -| PR1–PR4 | Instrumented unit tests plus first-frame real-Chromium capture | Open, close, reopen opposite side; hidden placement commit; first painted frame | Render-time reads, repeated reads, observers, pointer tracking, or a default-position flash fails. | `audit:PowerSearch/performance` | -| FR8, AR1–AR3 | Existing PowerSearch/editor suites plus focused browser checks | Pointer and keyboard edit, apply, cancel, Escape, light dismiss, focus return, selection, semantics | Focus, semantics, selection, dismissal, or reachability changes fail. | `audit:PowerSearch/accessibility` | -| Record structure | `scripts/check-knowledge.mjs` | Component-contract schema and current-authority dependency | Missing or invalid metadata and sections fail repository validation. | `audit:PowerSearch/knowledge` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| FR1–FR3, FR7 | PowerSearch unit tests plus real-Chromium geometry assertions | 399/400, 720/721, wide outer surface, narrow viewport, LTR/RTL, collision | Removing either clamp, capping the main menu, or allowing viewport overflow fails. | `audit:PowerSearch/layout` | +| FR4–FR6, FR9 | Opening-path tests plus real-Chromium geometry assertions | Add from inner input; edit actual left/right/equidistant token/control; pointer/keyboard; reopen opposite side | Using pointer coordinates, modality, wrapper-only identity, stale identity, or public props fails. | `audit:PowerSearch/interaction` | +| PR1–PR4 | Instrumented unit tests plus first-frame real-Chromium capture | Open, close, reopen opposite side; hidden placement commit; first painted frame | Render-time reads, repeated reads, observers, pointer tracking, or a default-position flash fails. | `audit:PowerSearch/performance` | +| FR8, AR1–AR3 | Existing PowerSearch/editor suites plus focused browser checks | Pointer and keyboard edit, apply, cancel, Escape, light dismiss, focus return, selection, semantics | Focus, semantics, selection, dismissal, or reachability changes fail. | `audit:PowerSearch/accessibility` | +| FR10–FR21, AR4–AR6, PR5 | Focused PowerSearch sheet tests with the compact-touch query stubbed both ways, plus real-engine touch evidence (implementation gap: not yet in the tree) | default popover, forced sheet, adaptive both ways (server markup and hydration as the popover field on a coarse-pointer client, then the trigger, no recoverable error, focus on the body; with `hasAutoFocus`, the autofocused input's focus ending on the trigger with no `onBlur` or `onFocus` to the caller), trigger name, Filters/Add filter/value steps, each value type, list value through the Tokenizer sheet, Clear all, read-only, disabled while open, focus after each step, focus after an `adaptive` switch from the trigger, the input, a removed control, an open editor popover, and from outside the field, a field inside an open Dialog announcing the count, Clear all, and in-sheet messages inside that Dialog and never on the page body, StrictMode result count, in-sheet live region while a sheet is open (added, removed, count, empty results), Clear all announced only after the dialog's exit, row, token and Removed text equal to the popover token's value text (UTC, Tokyo, Honolulu, unset), summaries and relative-date labels and values from the catalog, the row and Removed through one reorderable message, an unmatched relative-date value as stored, a filter the config no longer knows listed and removable, a cleared number unstaged, choice rows as radios and checkboxes with `aria-checked`, a stale read-only filter as a plain row, a removal next to a nested row focusing that row, a nested filter removed from its step, a list value joined by the locale's narrow conjunction list in English, Simplified Chinese, Japanese, and German, an empty-result message spoken once per query from its rendered element, a controlled update that removes the edited field moving focus from the replaced body to Remove and announcing the unavailable state in the sheet, Apply and Cancel after a controlled update removed the edited filter focusing the nearest row, else Add filter, a stale filter's step titled as its row reads (raw keys for what is gone, the field's label when the field remains), Cancel from creation after the opening field was removed focusing the Add filter sheet's first control, a removed focused Filters row, a removed Clear all, and a dropped focused field row each handing focus to the nearest remaining row or the sheet's first control, element counts at rest and with each of the Filters, Add filter and value sheets open, and with the Tokenizer's list-value sheet stacked above the value step, under fine and coarse pointers, nothing of the flow in the DOM at rest before a press and after the exit, a nested filter edited and created through the built-in nested editor (its sub-filter rows wrapping inside a 390 and a 414 px sheet; its root operator picker absent on a mixed field, the Operator group in its place), a nested-only field and its suggestion offered, a suggestion's operator checked over the field's default, the Operator group outside a caller's Editor override on a mixed field, a count changed during the exit spoken with Clear all's message as one catalog message after the close, a radio group's arrow keys, Home, and End moving and checking, Space and Enter checking a focused radio row and toggling a checkbox row, right-to-left layout with the row chevrons and Back glyph mirrored (`design:bidirectionality/DR4`) | A field without `experimental` changes beyond what FR19 states, a value commits before the step confirms, Clear all skips a read-only filter, the trigger carries native `disabled`, focus lands elsewhere, an announcement reaches a channel outside the modal that holds the field at that moment, or the popover bundle carries the sheets. | `audit:PowerSearch/sheet` | +| Record structure | `scripts/check-knowledge.mjs` | Component-contract schema and current-authority dependency | Missing or invalid metadata and sections fail repository validation. | `audit:PowerSearch/knowledge` | Current-source Chromium evidence establishes the geometry gap, not implementation completion: @@ -279,13 +514,148 @@ Rejected: pointer-coordinate placement, pointer-versus-keyboard branches, always using one edge, anchoring every width to the inner control, continuous tracking, render-time geometry reads, and showing before hidden placement state commits. +### DEC-3 — The touch sheet presentation is an experimental option + +**Reference:** `component:PowerSearch/DEC-3` +**Decider:** `imdreamrunner`, 2026-10-23 + +PowerSearch 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 and Tokenizer 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 tokenizer field and its +editor popover on every device. `adaptive` resolves through the shared compact +coarse-pointer policy, so a PowerSearch, a Tokenizer, and a Selector on the same +page switch together. + +The sheet surface is the Lab touch flow: the whole field is one trigger with +display-only tokens; a Filters sheet lists the filters as rows with Add filter, +Done, and Clear all; Add filter lists the fields, or carries the content search +with inline suggestions; the value step stages field → operator → value and +confirms from its footer, with Remove while editing. Closed vocabularies are +rows; list values edit through the Tokenizer's sheet presentation titled with +the field label; a `nested` value uses the popover presentation's built-in +nested editor; every other value type uses the editor the popover presentation +uses, so a filter reads the same in both presentations of one field. Clear all +keeps the popover presentation's semantics (every filter, `remove` at the last +index). 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: a separate touch +component chosen by the caller (two components, two draft states, an import swap +per breakpoint). Measured against `architecture:component-style-authoring/INV7` on the +Experimental Adaptive story (390×844, `document.body.querySelectorAll('*')`): +fine pointer 166 at rest and 334 with the editor popover open; coarse pointer +123 at rest, 296 with the Filters sheet open, 296 with the Add filter sheet +open, 318 with the value step open on a choice-row value, 328 with it open on a +list value (its Tokenizer trigger), and 401 with the Tokenizer's list-value +sheet open above the step (FR21) — every single-sheet state stays under the +open popover's 334. The stacked state does not, and exceeds it by more than +the 5-element open-layer allowance (`spec:AST-017/FR60`, `DEC-29`), so it is +this record's owner exception to INV7's layer budget, with its counts: before +the Tokenizer's sheet opens, 328 (the value step open on a list value, the +Tokenizer's trigger inside it); after, 401 — the step's dialog stays mounted +as a covered sheet of the stack (`component:BottomSheet` FR6: receded, blocked, +its state kept so the staged value survives) and the Tokenizer's sheet adds +73 above it, its own dialog with search field, checkbox rows, footer and live +region — a second surface the popover presentation never has, since its list +value edits inline in the one editor popover. Existing elements cannot do the +job: the covered step must stay mounted to keep its staged state and take +focus back when the top sheet closes (FR21), and the top sheet is the +Tokenizer's own surface. The counts are re-measured when either surface +changes. Owner exception to INV7's surface budget, which asks that a +pointer-specific presentation be a change of attributes and styles on the same +elements: under `adaptive` the closed field swaps its elements by pointer — the +tokenizer field with its combobox input and token remove controls for one +trigger button holding display-only tokens (FR11) — because the two surfaces +differ in role (a combobox editing inline against a button opening a modal +flow), and a combobox cannot be restyled into a dialog trigger without lying +about its role. The swap renders fewer elements, not more (123 against 166 at +rest), and happens only on resolution changes (FR10); the Tokenizer's own +`adaptive` trigger records the same exception. +Rejected: sheet-only Clear all semantics that keep read-only +filters (the two presentations of one field would clear differently). Rejected: +sheet-only date editors that read dates in `timezoneID` while the popover's read +them in UTC (one filter would show two days). + ## Open questions -None. The contract is accepted and current. Implementation and focused real-Chromium -verification remain pending. +- Known gap, pre-existing in the popover presentation, against + `architecture:internationalization/INV1a`: its token writes the list, + date-range, and nested summaries ("{n} items", "date range", "{n} filters") + as English literals, joins the items of a list value with a literal ", " + where FR12's rows use the locale's list format, and shows a relative-date + value as the stored key (`3d_ago`), and its relative-date editor writes the + choice labels ("3 days ago") as English literals. `formatFilterValue` resolves its + summaries from the catalog but differs from the token in five places: it + formats a `date_absolute` value with a time of day in `timezoneID` where the + token shows a date only in the device's zone; it says "{n} entities" for an + entity list where the token says "{n} items"; it formats an `integer` or + `float` through the locale's number format and appends the operator's + `units` where the token prints the raw number; it collapses a list to a + count when the joined text exceeds the width budget, where the token + compares the labels' total length, so the two collapse at different + lengths; and it cuts a single value at a different length and with a + different mark — the token's budget is `maxTokenLength` less the field and + operator label lengths, at least 10, with 3 characters of slack and "..." + (three periods), where `formatFilterValue` cuts at `maxTokenLength` with "…" + (the ellipsis character) — (both show a relative-date value as stored). The sheet presentation + resolves all of these from the catalog, or the locale's formats, on its own + surfaces (FR12, FR20) and shows the popover token as it is on the trigger + (FR11); changing the + token's and the editor's own text is a separate stable change for the + owner. +- Known gap, pre-existing in the popover presentation and inherited by the + sheet: the date editors store a picked day as UTC midnight, while the + tokens format `date_absolute` in the device's zone and `formatFilterValue` + in `timezoneID` or the device's zone, so in a zone offset from UTC a filter + can read as a neighbouring day on its token or in `formatFilterValue`'s + output. Changing what the editor stores changes the `unixSeconds` that + `onChange` reports and how saved filters reopen (`spec:AST-017/FR5`, + `FR7`), so the fix is a separate stable decision for the owner, not part of + DEC-3; the sheet matches the popover as it is. +- Known gap against `family:input-fields/FR10`, opened by DEC-3: the sheet + presentation's trigger composes Field for the box the tokenizer field has, + so PowerSearch is now a component that composes Field, which FR10 binds + even outside the family's membership, and this record departs from FR10 in + two places. Item 1: `ref`, `id`, `data-*`, `aria-*`, `hidden`, `inert`, + `dir`, and DOM event handlers go to the primary semantic control under + FR10, while this record keeps `ref` on the root in both presentations (the + INV8 promise above), routes only `data-testid` to the primary control, and + forwards no `id`, `aria-*`, other `data-*`, `hidden`, `inert`, `dir`, or + handler at all — the stable component destructures its props with no rest. + Items 2–4: PowerSearch's stable props carry root `xstyle`, `className`, and + `style`, which both presentations apply to the field box — FR10's + `container*` target — so the styling lands where FR10 puts it, but the + props type still accepts the root names and offers no `container*` or + `control*` parts. Moving `ref` or removing the root names is a stable API + change for existing callers (`spec:AST-017/FR5`), so the conversion is a + separate stable decision with its own migration, not part of DEC-3; the + implementation of DEC-3 adds PowerSearch to the family coverage check's + pending list, where it stays until that decision or an amendment of the + family record resolves which rule governs a composition that consumes + member behavior. +- Known gap against `spec:AST-086/FR14`, a present-tense implementation gap + note as this record carries where the shipped PowerSearch does not yet meet + a requirement: FR19 routes the closed field's result count, and AR6 Clear all's + after-close message, to the modal that holds the field. On the shipped + `useAnnounce` those messages write only to the page-body regions, so a + PowerSearch inside an open Dialog or modal Drawer does not yet meet that + route; the AST-086 record lists the same gap for every owner that announces + through the shared hook, and owns its closing: `useAnnounce` delivering + inside the open Astryx modal that encloses its caller closes it for every + owner at once, PowerSearch's calls unchanged. +- Should `date_absolute` with `isDateOnly: false` get a date-and-time editor in + both presentations? Today both offer a date-only editor. +- Should Clear all leave read-only filters in place in both presentations? Today + both clear every filter. +- The editor geometry contract (DEC-1, DEC-2) is accepted and current; its + implementation and focused real-Chromium verification remain pending. ## Content boundary This file does not duplicate consumer prop tables, configuration examples, -operator/value editor internals, current audit results, implementation steps, or -shared layer positioning and dismissal rules. It links to their owners. +operator/value editor internals, current audit results, implementation steps, +shared layer positioning and dismissal rules, BottomSheet's dialog and gesture +contract, or the Tokenizer sheet's row, search, and Clear all behavior. It links +to their owners.