Skip to content

feat(core): PowerSearch touch sheet flow behind experimental.presentation - #8001

Open
imdreamrunner wants to merge 1 commit into
mainfrom
feat/powersearch-experimental-bottom-sheet
Open

imdreamrunner wants to merge 1 commit into
mainfrom
feat/powersearch-experimental-bottom-sheet

Conversation

@imdreamrunner

@imdreamrunner imdreamrunner commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Targets main directly: one commit. Everything it builds on is merged — #7977 (Tokenizer's experimental sheet presentation, which list values use), #7648 (BottomSheet's stack and same-update replace), #8009 (useAnnounce inside the enclosing modal), #8000 (the component:PowerSearch DEC-3 record) and #8650 (the StrictMode result-count [fix], extracted from this PR and shipped on its own).

Built on the merged Tokenizer sheet presentation (#7977), whose sheet the list-value editors here open; on #7648, whose standalone-sheet stack and same-update replace host the three steps; and on #8009, which makes the public useAnnounce deliver inside the open Astryx modal that encloses its caller (spec:AST-086 FR14) — the route the sheets here announce through. The record this implements, component:PowerSearch DEC-3, FR10–FR21, AR4–AR6, PR5, AV5–AV6, is merged (#8000) and current on main; this PR's only record change deletes its implementation-gap notes and the AST-086 gap note the merged useAnnounce fix closes, and adds PowerSearchBottomSheet.test.tsx and PowerSearch.ssr.test.tsx to verified_by.

On the size. The diff is one capability: 17 files, +8,159 / −353 against main. Of its lines, ≈4,100 are the sheet test file, ≈2,100 the sheet flow, ≈680 the trigger and lazy host in PowerSearch.tsx, ≈200 each the token-value module and the value editor's presentation branches, ≈180 the sheet helpers and the stories, ≈170 the SSR test, and the rest docs, strings, the record's gap-note removal and the one Changeset. It is not split, on the owner's call: "i don't mind large PR. i can bypass [the review size] limit". The StrictMode result-count [fix] was split out and merged first (#8650), so this PR no longer carries it. The two pieces a further split would carve out are not separable from the flow in practice: the formatTokenValue.ts extraction exists so the sheet's rows and Removed … text can call the token's own value function — alone it is a no-op refactor with no reader; and the two-line story-decorator change only matters because the touch stories need a phone-width column. The rest is one flow that cannot ship missing a step.

User need

On a compact touch screen the PowerSearch field fights the software keyboard: focusing the tokenizer raises the keyboard over the half of the screen where the field menu and the editor popover open, the editor's Selector and Tokenizer open further anchored layers inside an already-cramped popover, and each filter token grows the field under the thumb. Builders shipping PowerSearch on phones have had no supported surface for it; the Lab MobilePowerSearch prototype validated one (a Filters sheet, an Add filter sheet, and a value step that stages field → operator → value), but Lab publishes only canary builds, the prototype is a separate component with its own draft state, and its strings, date handling, and trigger naming were open review findings. Without a Core surface, every product either keeps the popover on touch or rebuilds the flow itself.

  • Storybook: the Core/PowerSearch stories' decorator is a minmax(0, 600px) grid column instead of a fixed width: 600, so every PowerSearch story (the touch presentations above all) fits a 390px phone canvas and stays 600px on desktop.

Problem and solution fit

  • experimental.presentation on PowerSearch: 'popover' (default; the tokenizer field and editor popover, unchanged), 'bottom-sheet' (the touch surface on every device), 'adaptive' (the sheet on compact coarse-pointer screens, the popover otherwise, through useAdaptivePresentation — the policy Selector, MultiSelector, DropdownMenu and Tokenizer share, so a PowerSearch and a Tokenizer on one page switch together). Inside the experimental object per spec:AST-017 FR21, with @experimental on the declarations and stability: 'experimental' in the doc, as Drawer's experimental.isFullWidthOnMobile and Tokenizer's experimental.presentation are.
  • Trigger in PowerSearch.tsx: the field's control box with display-only tokens and one button as its only tab stop; a tap anywhere on the field opens the flow. The button's name is aria-labelledby the field label plus the field's visible content — the placeholder, or the tokens — so speech input can say what it sees (WCAG 2.5.3) and assistive technology hears the selected filters on the trigger. startIcon, endContent, the result count, status, statusVariant, size, disabledMessage, hasAutoFocus, onFocus/onBlur, handleRef, ref, data-testid, xstyle/className/style and the power-search theming target carry over. Disabled is aria-disabled and focusable, never native disabled (spec:AST-085 FR1); becoming disabled with the flow open closes it and returns focus to the trigger (FR2).
  • PowerSearchBottomSheet.tsx (new, lazy): the Lab touch flow ported behavior for behavior — the Filters sheet (heading, Add filter, one row per filter with the value in bold, Done, Clear all), the Add filter sheet (Back; grouped field rows with chevrons; a "Search filters" box from eight fields capped by maxSearchResults; or, with a string content-search field, a "Search" searchbox with the field typeahead's own suggestions listed inline, Enter/ArrowDown/ArrowUp, an IME guard, and Added {label} on a value suggestion), and the value step (title, "Operator" radios when a field has several, a staged editor, Cancel and the confirm action — popoverSaveButtonLabel, else "Add filter"/"Apply" — Remove while editing; components[type].Editor replaces the body). Exact onChange metadata for add/edit/remove, identity following across controlled reorders, and focus after each step are the prototype's. Every visible field lists, nested-only fields included: the value step edits a nested value with the popover's built-in nested editor (below).
  • powerSearchSheetUtils.ts (new): which operators the sheet's own controls edit (every type but nested, which the built-in nested editor takes), field matching, filter identity across controlled updates, the focus target after a removal, and the content search's suggestion source over the field typeahead. The suggestions themselves come from usePowerSearchSource, the popover presentation's own source, so the two presentations suggest the same things.
  • Value editors reuse Core's private PowerSearchValueEditor instead of the prototype's rebuild from public inputs. It gains two internal props, presentation and fieldLabel: in the bottom-sheet presentation the inputs fill the sheet's width, the number editors stage every parseable keystroke through the existing resolveNumberInputCommit (the sheet confirms from its footer, so the action must enable while typing), the date range shows one month, and string_list/entity_list render the Tokenizer with experimental={{presentation: 'bottom-sheet'}} and the field label as its name — so the Tokenizer's sheet opens above the step, titled with the field (the Core port of what the Lab now does with MobileTokenizer). enum, enum_list and date_relative render as full-width rows in the sheet file (a Selector or Tokenizer popover inside a sheet stacks a second layer); buildRelativeDateOptions is shared so both presentations list the same relative dates.
  • Lazy surface, mounted only while open or closing (PR5, architecture:component-style-authoring INV7): the flow's chunk loads with React.lazy behind a host that renders nothing on the server or in the hydrating render, and is preloaded while the sheet presentation is active (one shared import promise, as Tokenizer r35 does) so the first press does not wait for the network — but the host itself is mounted only from the press until the exit has completed and focus is back on the trigger (isSheetOpen || isSheetPresented). At rest the field renders its trigger alone: no closed <dialog>s, rows, search field or editors in the DOM (a first cut mounted all three steps unpresented at rest: 296 elements on a phone against 167 with a mouse). Test: no dialog and no sheet content before a press, dialogs while open and through the exit, none again once focus is back on the trigger (fails without the change). The flow loads the way DropdownMenu and the Tokenizer load their sheets, so a PowerSearch that does not opt in never requests it (component:PowerSearch PR5). Two import paths; no behavior change.
  • Result count: the StrictMode-safe guard (compare against the last announced text, FR19) is on main since fix(PowerSearch): the result count is announced once under StrictMode #8650; this PR keeps it and re-routes the same effect through the sheet's in-dialog region while a sheet is open (AR6), holding a change that lands during the flow's exit for the after-close messages.
  • Announcements inside the open sheets (spec:AST-086 FR3/FR4/FR14; AR6, FR19): while a native modal dialog is open the page-body live channel is not exposed to assistive technology, so every announcement the flow makes while a sheet is open — Added {label} after a content-search suggestion commits, Removed {label}, the result count, the empty-result messages — is spoken through plain useAnnounce from a component rendered inside that step's dialog (SheetAnnouncer, see "Announcements route by location" below); FR14, which fix(core): useAnnounce delivers inside the open Astryx modal that encloses its caller #8009 implements in useAnnounce, routes a message to the nearest open Astryx modal enclosing its caller, so the message lands in the sheet's own persistent polite region. The suggestion and removal announcements are queued until the Filters sheet is the active step, so they land in the dialog that stays rather than the one about to close. The Tokenizer's own sheet announces its additions the same way.
  • Presentation latch (FR10, mirroring Tokenizer FR16): the sheet presentation stays presented after the flow closes until the sheets have run their exit and handed focus back to the trigger; if adaptive resolved to popover meanwhile (a phone turned to landscape), the tokenizer input takes the focus over once it mounts, so focus never drops to the page body.
  • Sticky header and footer in the sheets: the sheet content wrapper is isolation: isolate and the sticky header and footer sit at z-index: 1 inside it, so the List rows (position: relative) that scroll under the header no longer paint over it, while the panel's grab handle (z-index 1 outside the wrapper) still paints above — the same fix the Lab prototype received.
  • Filters rows format a date-only date_absolute value as a day (Mar 9, 2024), as its token does, instead of formatFilterValue's date-and-time; formatDateAbsoluteCompact gains an optional timezoneID so the row and the token agree on the day.
  • data-testid lands on the trigger button in the sheet presentation — the field's primary control, as the combobox input is in the popover presentation, which is unchanged — so a test id does not change element when an adaptive field switches.
  • endContent beside the trigger (FR11; spec:AST-055 FR3): the field's end lane — the result count and endContent — renders outside the trigger button, after it. A caller's action button there keeps its own tab stop, taps and accessible name; a tap on it no longer opens the flow (the field-wide tap handler skips the end lane), and the end lane's text is not part of the trigger's name.
  • Suggestion-commit focus: a content-search suggestion that commits a filter directly focuses that filter's row in the Filters sheet (the person sees what they just added, and the row is the way to edit it), while a commit from the value step focuses Add filter; FR16 states both. The record and the code agree at this head.
  • Focus when adaptive flips while the closed field holds it (FR10): rotating the phone or attaching a mouse with focus anywhere in the field — the trigger, the combobox input, a token or its remove control, the clear control, an interactive endContent — may remove the focused control; focus then moves to the other presentation's primary control unless the focused element survived the switch (the Effect checks whether the active element is still inside the root), and stays put when focus was outside the field. The field tracks focus-within through its own onFocus/onBlur (Tokenizer's already fire for focus-within; a removed element fires no blur), and one Effect on the presentation swap hands focus over — the same Effect that completes the latch hand-off. Each presentation mounts its own endContent element, so a focused endContent does not survive the switch either and the primary control takes focus.
  • Custom Editor props (FR14): the sheet hands a components[type].Editor exactly what the popover hands it — config, filter, mode, saveButtonLabel, isReadOnly, onSave, onCancel — and no sheet-only timezoneID, so a custom date editor cannot show one filter on two days as adaptive switches.
  • Dates: the sheet matches the popover exactly; no stable date change ships. The earlier token and editor timezoneID changes are reverted (PowerSearchEditPopover.tsx and formatFilterValue.ts are untouched; the editors store UTC midnight and read the UTC day as released) and the powersearch-date-only-timezone Changeset is deleted — changing what the editor stores changes onChange's unixSeconds and how saved filters reopen, a compatibility decision outside this experimental feature. Instead the token's value text is extracted, unchanged, into formatTokenValueText (formatTokenValue.ts; the token renders its result), and the sheet's Filters rows and Removed {label} text call the same function (untruncated) — same words, same zone handling, so within one zone setup the sheet agrees with the popover everywhere. The pre-existing popover inconsistency (editor UTC midnight vs. device-zone tokens vs. timezoneID in formatFilterValue) is recorded in #8000 as a known gap for a separate owner decision. The timezoneID prop the lab port had left on PowerSearchValueEditor and the sheet, which nothing read, is removed.
  • Joined labels through the catalog (architecture:internationalization INV1a): @astryx.powersearch.sheet.fieldOperator ("{field} {operator}") builds the value step's title, the operator radio names and the Filters row's label; @astryx.powersearch.sheet.labelValue ("{label} {value}") builds the Removed … text. No more space-joins.
  • The row's width budget, stated (FR12): the row calls the token's formatter with the budget lifted, so it never truncates a single value and never collapses a list to "{n} items" — the record now says exactly that instead of "before the token's width budget" / "a list value that fits".
  • Lists and numbers in the provider locale (INV1a, INV8): on the translator path (the sheet's rows and Removed …), formatTokenValueText formats a list through formatSharedList — the provider locale's Intl.ListFormat with {type: 'conjunction', style: 'narrow'}, the one list form that keeps every item separated in all 30 shipped locales ("Bug, Feature, Docs" in English, "Bug、Feature、Docs" in Simplified Chinese and Japanese, "Bug, Feature und Docs" in German; the unit type ran Chinese items together and gave Japanese and Korean a bare space, and unit is a type, not a style). FR12 states the rule; tests cover en, zh-CN, ja-JP and de-DE (zh-CN and ja-JP fail with the unit type) and an integer/float as the token reads it: where the locale writes numbers with the token's numerals — Latin digits and a . decimal mark, probed once per locale through formatSharedNumber — the row is String(value) itself, so 1e21, 1e-7 and -0 read "1e+21", "1e-7" and "0" exactly as on the token; elsewhere the locale's digits and decimal mark with no grouping and no rounding ("2,3456", "0,0000001", "-0" in German), differing from the token only in notation. FR12 states the rule in those terms. Tests: four English values row-vs-token, and the German notation-only case. Both helpers live in formatFilterValue.ts, the module architecture:internationalization names as the owner of PowerSearch's raw Intl access; formatFilterValue's own number formatting now goes through the same helper. The popover token's no-translator path keeps its literal ", " and String() (known gap). Test renders under locale="de" and asserts the row, the Removed … text and the untouched token.
  • Stale editor after a presentation swap (FR10): when adaptive switches to the sheet while the editor popover is open, the same Effect that moves focus to the trigger returns popoverState to idle (raw setter — the tokenizer input is gone) and hides the editor's layer (popover.hide()), so switching back neither reopens a stale editor nor leaves the layer open and empty for the next Escape to close. The test tracks the layer's shown state through stubbed showPopover/hidePopover: after the round trip no open layer remains (fails without the fix: the layer came back open), no Apply button, focus stays outside.
  • nested works like every other type: the popover's built-in nested editor in the value step (DEC-3, FR12, FR13, FR14, FR17; the sub-filters' own controls are the popover editor's, in their default presentation — the sheet passes no experimental down, FR14 says so): the popover's NestedEditor (the sub-filter tree with its Add action and per-row field/operator/value controls) is now exported from PowerSearchEditPopover.tsx and rendered as the value step's body for a nested operator, with the sheet's draft as its partialFilter and hasRootOperatorPicker={false} (a new NestedEditor prop, default true so the popover is unchanged) so the editor's root group-operator picker is not shown — the step's own Operator group takes its place — and the tree root reads the checked operator's label. Test: on a mixed field no "Group operator" combobox renders and the tree root reads "all of" (fails when the editor is handed every operator with the picker on). The editable-operator rule from the previous rounds is gone: every visible field lists (the nested-only drop and its dev warning are removed), the content search offers every suggestion (createSheetSuggestionSource only drops a field the config no longer knows), the Operator group lists every operator, and the remove-only "can't be edited here" state and its catalog key are gone. Chosen over naming nested as a DEC-3 exception because the editor already exists, is self-contained (config + partial filter in, partial filter out), and sits in the same body slot as every other editor — a group is created and edited the same way in both presentations. Tests: a nested filter's row opens the tree with Apply and Remove; a nested-only field and its "Group all of" suggestion are offered and the step creates the filter end to end through the built-in editor (add sub-filter → pick a value → Add filter → onChange([…], 'add', 0)); nested-only and mixed fields list with no warning; the suggestion source keeps the nested field.
  • Done after a rotation reported a second onFocus (bug): when adaptive stopped resolving to the sheet while the flow was open, the close focused the trigger (the focus FR11 reports) and the pending inline hand-off then called tokenizerRef.focus() directly — a second onFocus with no onBlur between. That hand-off now goes through focusAfterSwap, so the input taking over is the same visit. FR10 says so. Test: the open-flow rotation case now asserts one onBlur (flow open), one onFocus (close), none for the input taking over (fails without the fix: two).
  • No stable [fix] rides along: the StrictMode result-count fix, its popover-presentation test and its [fix] Changeset merged separately (fix(PowerSearch): the result count is announced once under StrictMode #8650) and are identical here to main; PowerSearch.test.tsx's only addition is the data-autofocus-across-re-renders test.
  • No rounding in any locale: the row's fraction cap is Intl's maximum (maximumFractionDigits: 100); German 1e-21 reads "0,000000000000000000001" where a cap of 20 read "0". Test added.
  • hasAutoFocus is suppressed only on the swap render (FR10): a presentation swap remounts the field's control (combobox input ↔ trigger), and React's autoFocus would focus it again — pulling focus from wherever on the page it was and firing onFocus. The first cut suppressed it on every render after the first, which also stripped data-autofocus from the stable popover presentation's combobox and would have broken autofocus inside an Astryx Dialog that keeps the field mounted. Now hasAutoFocus passes through exactly as on main until the first adaptive swap, after which every control a swap mounts stays without it. The derivation is render-pure (architecture:react-component-runtime INV3/INV4): state {presentation, isFirstControl} updated during render by the previous-value pattern — the first render whose presentation differs flips isFirstControl off for good — so the rendered autoFocus derives from state, not from a ref mutated in an Effect (the previous cut did that and was reverted). Tests: a popover field keeps data-autofocus on its combobox across re-renders (fails on the first cut); autofocused on mount, focus a button elsewhere, flip the resolution both ways → focus stays elsewhere, onFocus once; SSR with hasAutoFocus still ends on the trigger with no onFocus.
  • Filters rows keyed by filter identity, not position (FR12, FR16; bug found in review): rows were keyed ${index}-${field}-${operator}, so a parent's reorder, or a removal above the focused row, remounted the focused row and the re-home effect then focused the row at the old index (focus on "Author is Ada" in [Status, Author, Priority], drop Status → "Priority is P1" took focus). Rows now take a stable id from createFilterIdentities in powerSearchSheetUtils.ts: a filter object seen before keeps its id (a WeakMap); a new object takes the id of a previous-update filter with the same filterSignature that no present object claims (parents that clone their values); anything else mints a new id, and equal filters are told apart by order. A reorder or a removal above now moves elements instead of remounting them, so focus stays on the same filter; only a filter that is itself removed loses its element, and the re-home rule (buttonRowsOf, the following button row) is unchanged. Tests: reorder with the same objects and with clones keeps focus on the same element; removing a filter above keeps focus on the same element, not the old index (both fail without the change); the remove-focused-row tests pass unchanged. Chromium (Adaptive story, iPhone profile, the story's filters state set from the page): focus on "Priority is P1 - High" at index 1 → parent rotates to [Created, Status, Priority] → the same DOM node, now at index 2, keeps focus; parent then removes Status above it → the same node keeps focus at index 1.
  • One rule for a focused control a controlled update removes (FR16): the Filters and Add filter sheets record where focus is on every focus change (sheet + index among the button rows, so a plain read-only row above is not counted — the record and the recovery share one index space; the first cut indexed among all rows and sent focus one row too far past a read-only row); a layout effect on every commit of an open step checks whether focus is still inside the sheet's dialog and, when the parent's update unmounted the focused control (a filter's row, Clear all gone with the last filter, a dropped field's row), moves it to the nearest remaining row by that index (the following one, else the last), else the sheet's first control (search field / Add filter), else the panel — never the page body. A custom Editor's onSave(null) while creating already lands on Add filter; FR16 now says so. Tests: middle row removed → following row, then last row removed → new last row; a read-only row above the removed row → the row that followed, not one too far; Clear all removed with the last filter → Add filter; dropped focused field → a remaining row (all fail without the change).
  • Cancel from creation after the opening field is gone (FR14, FR16): when a controlled update removed the field whose row opened the step (or the content-search field itself), Cancel returns to the Add filter sheet with focus on its first control — the "Search filters" field when one shows, else the first field row, else Back — instead of a row that no longer exists. Test: few fields → first row; nine fields → the search field (fails without the change: focus on the body).
  • Stale filter rows and titles read the field's label when the field remains (FR12, FR14): the record now matches the code — "{field label} {operator key}" when only the operator is gone, raw keys when the field is too.
  • Element counts and the INV7 exception (spec:AST-017 FR60; Chromium, Experimental Adaptive story, 390×844, document.body.querySelectorAll('*').length): Chromium, iPhone touch profile for the coarse pointer, each state read once it has settled — every open dialog is that state's own and two readings one second apart agree — the method the record states: fine pointer 166 at rest / 334 with the editor popover open; coarse pointer 123 at rest / 289 Filters open / 289 Add filter open / 306 value step open on a choice-row value while creating (311 while editing a Filters row, which adds Remove) / 321 on a list value (the step holds the Tokenizer's trigger) / 392 with the Tokenizer's list-value sheet open above the step (FR21; two modal layers at once) — each open state 7 lower than with the switcher's host. (A reviewer's run read every open state one element higher with the same +71 for Tokenizer's sheet; the record carries these figures and their method.) Every single-sheet state stays under the open popover's 334; the stacked state exceeds it by more than FR60's 5-element open-layer allowance, so DEC-3 records it as an owner exception with before/after counts: 321 before the Tokenizer's sheet opens (value step on a list value), 392 after — the step's dialog stays mounted as a covered sheet of the stack (component:BottomSheet FR6, receded and blocked with its staged state kept) and the Tokenizer's sheet adds 71 above it; existing elements cannot do the job because the covered step must keep its state and take focus back (FR21) and the top sheet is the Tokenizer's own surface. DEC-3 records an owner exception to INV7's surface budget for adaptive: the closed field swaps elements by pointer (tokenizer field + combobox input for one trigger button) rather than restyling the same ones, because the two differ in role and a combobox cannot be restyled into a dialog trigger without lying about it; it renders fewer elements (123 vs 166) and only on a resolution change. The counts are also in AV5 (structure proviso cites INV7) and the verification row. The impl copy of the record also drops the AST-086 FR14 gap note, which describes main's useAnnounce; this branch's base carries fix(core): useAnnounce delivers inside the open Astryx modal that encloses its caller #8009 and the Dialog-hosted test passes.
  • Apply/Cancel after the edited filter is gone (FR15, FR16): when a controlled update removed the edited filter, or left two it could be, Apply writes nothing and both Apply and Cancel return to the Filters sheet with focus on the nearest remaining row that is a button around the filter's former position (the following one on a tie, per nearestEditableIndex), else Add filter — previously Apply went straight to Add filter and Cancel looked for a row that no longer existed. Test: middle filter removed while editing → Apply focuses the following row; last filter removed → Cancel focuses Add filter (fails without the change).
  • Stale filter's step title (FR12, FR14, AR5): for a filter whose field or operator the config no longer has, the step's title — the dialog's name — reads the raw keys ("legacy was"), as the row does; the unused @astryx.powersearch.sheet.editFilter catalog key is removed. Test asserts the heading and the dialog's aria-label.
  • A controlled update that removes the edited field (FR14, FR16, AR6): the unavailable state now renders through SheetEmptyMessage, so its text is announced inside the sheet's dialog by the shared own-content module (once per opened filter), and a layout effect moves focus from the replaced body to Remove (Cancel when there is no Remove) when the body's control that held focus unmounted — focus never falls to the page body inside the open sheet. FR14 and FR16 name the destination, AR6 the announcement. Test: focus a choice row, remove the field from the config → the unavailable text, focus on Remove, the message in the dialog's region and nothing on the page body (fails without the fix: focus on the body).
  • Phone widths: the nested editor's rows wrap inside the sheet — NestedSubFilterRow's chipRow collapses from one line to wrapped lines under a container query (@container (max-width: 399px)) whose container is the popover's own styles.container; the sheet renders no such container, so at 390 and 414 CSS px every sub-filter's operator and value controls ran past the sheet's edge (Chromium, before: controls with right edges at 416 and 474 px inside a 390 px sheet, same at 414 px). NestedEditor takes an xstyle for its tree root and the sheet passes containerType: inline-size; overflow: hidden, so the tree root is the query container (356 px wide inside the 390 px sheet) and the rows wrap. Chromium after, on the nested story in the sheet presentation at 390 and 414 px: no control's right edge past the sheet's, tree root container-type: inline-size, sheet scrollWidth === clientWidth.
  • Editing two groups on one field showed the first group's sub-filters (bug): the value step keyed its editor by field and operator, the step's sheet stays mounted between openings, and the built-in nested editor (like a caller's override) reads its filter on mount — so opening a second nested filter on the same field reused the first filter's editor state, and Apply could save it. The draft now carries a generation counter bumped on every opened step, and both the built-in nested editor and the Editor override are keyed generation-field-operator, so each opened filter gets its own editor. Test: two groups on one field — open the first (sub-filter "Status"), Cancel, open the second → "Author", Apply writes that group unchanged at index 1, reopen the first → "Status" (fails without the fix: the second showed "Status").
  • SSR and hydration of adaptive (FR10): new PowerSearch.ssr.test.tsx — renderToString of an adaptive field yields the combobox and no trigger; hydrateRoot under a coarse-pointer matchMedia reports no recoverable error and no console error, then the field is the trigger, and document.activeElement stays on the body. With hasAutoFocus the server markup's autofocus has focused the combobox input before hydration (no React focus event), and the swap removes that input: a layout effect on every commit of the closed field records that the root holds document.activeElement, so the swap knows the field was focused, and focus moves to the trigger within the same visit — the swap's own focus() call is flagged, and a focus React's autoFocus gives the incoming control in the swap's commit (before the Effect has recorded the new presentation, while the field still counts as focused) is recognised the same way — so neither onBlur nor onFocus reaches the caller for the hand-off. That continuation rule now covers every FR10 hand-off (trigger ↔ combobox on an adaptive flip), not only the autofocus case. Tests: SSR with hasAutoFocus (input focused as a browser would, hydrate under a coarse pointer → activeElement is the trigger, onBlur/onFocus not called; fails without the fix: onFocus fired for the trigger); the existing flip round-trip asserts one onFocus for the user's focus and none for the two hand-offs. Chromium at 390 and 414 px on the adaptive story, fine pointer (popover) vs coarse pointer (sheet): same x, y, width; height identical at 390 px (86/86) and one token row shorter for the trigger at 414 px (86 → 59) because the popover field's inline input wrapped onto its own row — recorded in FR10 as the swap's only layout effect.
  • Stale comments fixed: the row-button comment in PowerSearchBottomSheet.tsx and the powerSearchSheetUtils.ts header no longer describe a nested exclusion.
  • family:input-fields FR10 coverage — fieldInputFamily.test.tsx counts every component that renders <Field> as Field-composed, and the sheet trigger now composes Field for the box the tokenizer field has, so PowerSearch tripped "every Field-composed component type-checks its props, or is pending". PowerSearch is outside the family (a composition that consumes member behavior) and composes Field only in the experimental presentation; its stable props carry root xstyle/className/style — documented and in use — which both presentations apply to the field box (FR10's container* target), so the styling already lands where FR10 puts it. Omitting the root names from PowerSearchProps would be a stable API change for existing callers, so PowerSearch is listed in PENDING_FR10 with that reason (the comment names ref as well as the root styling props), and docs(spec): PowerSearch records the experimental touch sheet presentation (DEC-3) #8000 records the gap — FR10 item 1 (ref on the root, the INV8 promise; only data-testid routed to the primary control; no id, aria-*, other data-*, hidden, inert, dir or handler forwarded at all, since the stable component destructures its props with no rest) and items 2–4 (root styling props) — as a separate stable decision with its own migration, pending the family owner's answer on amending family:input-fields. The family type check is not added (it would fail by design until that conversion).
  • AR6 and the first known gap, corrected (docs(spec): PowerSearch records the experimental touch sheet presentation (DEC-3) #8000 and the impl's record copy): the popover presentation announces a removal with the filter's formatFilterValue label, not the token's text — 1500 with units: 'ms' shows "1500" on the token and is announced "1,500 ms" — so "as it displays it" is gone; and the first known gap gains a fifth token-vs-formatFilterValue difference: a single value is cut at a different length and with a different mark (the token's budget is maxTokenLength less the label lengths, ≥ 10, + 3 slack, "..."; formatFilterValue cuts at maxTokenLength with "…").
  • Field groups: ungrouped first, as the popover suggests them (FR13): the Add filter sheet grouped fields in insertion order, so a config whose first field is grouped listed that group above the ungrouped fields; it now groups through the shared groupItems(…, {ungroupedFirst: true}) the popover's field suggestions use (usePowerSearchSource), so both presentations list a config's fields in one order. Test: a config whose first field is grouped lists Title, then the People and Dates groups (fails without the change).
  • "No filters match your search" only for a non-empty query; aria-controls always names an element (FR13): a config with no field opens Add filter with no "no match" message; the content searchbox's aria-controls names the results list, the empty-result message while a query matches nothing (the message carries the results id), or an empty results region while an empty query lists nothing. Tests for both (fail without the change).
  • Operator group outside any editor (FR14): the Operator group is one element rendered above the body in both branches — the caller's components[type].Editor override and the sheet's own editors — so on a mixed field (a nested operator beside a plain one) the override opens on the default operator with the group above it, and switching operators swaps the body (override ↔ sheet editor). The step checks the operator it opened with — the filter's while editing; while creating the suggestion's operator (FR13), else the field's default, else its first — and the title is "{field} {operator}" only when no group shows. Tests: mixed field with an override (group above, swap both ways, title "Mixed"); a "Status is not" suggestion checks "is not", not the default "is".
  • Summaries and relative-date labels through the catalog (INV1a): formatTokenValueText takes an optional translator; with it (the sheet's rows and Removed …) the list, date-range and nested summaries resolve through the existing @astryx.powersearch.valueEditor.itemsCount / dateRange / filtersCount (ICU plurals); without it (the popover token, and so the sheet's trigger, FR11) the token's own literals stay byte-for-byte. buildRelativeDateOptions likewise takes an optional translator; the sheet's choice rows use six new ICU messages @astryx.powersearch.sheet.relative{Days,Weeks,Months}{Ago,FromNow}, the popover editor's labels are untouched. In English every word is identical to the popover. Both popover literals are recorded in #8000 as a known gap.
  • Cleared number unstages (FR14, FR17; bug): in the sheet, the number input staged every parseable keystroke but kept the last one when the field was cleared, so Add filter stayed enabled over an empty field and committed the deleted digit. PowerSearchValueEditor gains an internal onUnstage the sheet wires to clear the draft's value when the draft is empty or does not parse (only the bottom-sheet presentation stages as typed; the popover never calls it). Test: type 1, delete it → Add filter disabled and a tap commits nothing; - → still disabled; 12 → enabled.
  • Row and Removed through one message (INV1a): the Filters row now renders from @astryx.powersearch.sheet.labelValue too — formatted with a private-use placeholder for the value, split on it, the value segment bold — so a locale that reorders the message reorders the row and the announcement together. Test overrides the message to {value} ({label}) and asserts the row reads "Open (Status is)" with "Open" bold and Remove announces "Removed Open (Status is)".
  • Relative-date values on the sheet's surfaces (docs(spec): PowerSearch records the experimental touch sheet presentation (DEC-3) #8000's optional item): a stored 3d_ago reads "3 days ago" on the Filters row and in Removed … through FR20's catalog keys, consistent with the value step's choice rows; a value no choice row matches (any string, e.g. 7d) reads as stored, as the token shows it; the trigger shows the popover token as it is (3d_ago, known gap). Test covers trigger, row, selected choice row and announcement.
  • A saved filter the config no longer knows (FR12; real gap): it showed as a token on the trigger but had no Filters row, so only Clear all could remove it while the popover's × can. It now lists by its raw keys ("legacy was") as a row button that opens the value step's existing "This filter is no longer available" state, where Remove commits onChange(rest, 'remove', index) and announces Removed legacy was. Test added.
  • Choice rows carry their state as state (FR17, AR5): enum and date_relative rows are radio buttons in a radiogroup named by the field label, enum_list rows are checkbox buttons in a group, each with aria-checked and a RadioIndicator/CheckboxIndicator, as the Tokenizer's sheet rows; the ", selected" name suffix and its catalog key are gone. Disabled rows stay focusable with aria-disabled (spec:AST-085 FR1). The rows still run edge to edge on the body inset. Keyboard model: a radio group is one tab stop — the checked row, else the first (roving tabIndex) — and ArrowDown/Right, ArrowUp/Left (wrapping), Home and End move focus and check the row they land on, as a native radio group does; checkbox rows are one tab stop each, toggled by Space/Enter (the button's activation), arrows inert. Unit test covers tab stops, every arrow, wrap, Home/End and the checkbox rows. Chromium + axe (wcag2a/aa, 2.1, best-practice) on the Filters sheet, an enum step and an enum_list step: 0 violations; on "Priority is", Shift+Tab from the footer lands on the checked P1 (the only tabIndex=0 row), ArrowDown focuses and checks P2, ArrowUp ×2 focuses and checks P0, another Shift+Tab leaves the group; a Tags checkbox toggles aria-checked true → false.
  • Stale filter while read-only (FR12): a plain row (the popover gives such a token no ×); only an editable stale filter is a button that opens the unavailable state. Test added (filter read-only; field read-only).
  • Removal focus follows the row-button rule (FR16): nearestEditableIndex takes the sheet's own isRowButton (field interactive, filter not read-only; a nested or stale row is a button too), one rule the rows and the focus share; its doc comment says so. Test: Status, nested Mixed, Author → removing Status focuses the nested row.
  • Clear all + count as one message (INV1a): the sheet now reports onFiltersCleared() and PowerSearch composes the after-close announcement itself — @astryx.powersearch.sheet.filtersClearedWithCount ("All filters removed. {resultCount}") when a count changed during the exit, filtersCleared alone otherwise, the bare count when only it changed. Test overrides the message to "{resultCount} — all filters removed" and asserts the shared channel reads exactly that; Chromium (motion on): "All filters removed. 240 results" in the page-body region 375 ms after the tap, nothing while any dialog was open or modal.
  • Right-to-left (DR4): the Filters rows' trailing chevron, the field rows' chevron and Add filter's Back glyph already carry the shared mirror style; a unit test in dir="rtl" asserts it on all three, and Chromium with dir="rtl" on the document shows each rendering with matrix(-1, 0, 0, 1, 0, 0) (scaleX(-1)). No code change.
  • Announcements route by location (spec:AST-086 FR14, current on main; the public useAnnounce implements it since the reworked fix(core): useAnnounce delivers inside the open Astryx modal that encloses its caller #8009): every announcing call is plain useAnnounce. PowerSearch itself announces only with the flow closed (the closed-field count, and Clear all's after-close message), which FR14 routes into whatever Astryx modal holds the field — a Dialog, a modal Drawer — or the shared channel. While the flow is open, every message about a sheet's content (Added …, Removed …, the result count, the empty-result messages) is spoken from inside that sheet's dialog: each step's body renders a SheetAnnouncer, a component inside the BottomSheet whose useAnnounce resolves the sheet's dialog as the enclosing modal, and the flow's effects speak through the speaker it publishes. No hold or queue sits between the speaker and useAnnounce: an earlier Chromium run had shown "Removed Priority is P1 - High 80 results" reaching the page body, because the handoff back from the value step closes the step's dialog while the Filters dialog is presented again, and the closing dialog handed the unexposed message off to the body; fix(core): useAnnounce delivers inside the open Astryx modal that encloses its caller #8009 now lets a provider ignore a close whose dialog has reopened a frame later and hand a real close off to the top open modal, and with that fix the message is written once, straight into the Filters dialog's region (3/3 Chromium runs, no detour via the body). The result count while a sheet is open is passed into the sheet (resultCountText) and announced there; the owner's count Effect stays quiet while the flow is open. The sheet-body ref routing and the bridge helper are gone; the after-close queue is unchanged. Test: a PowerSearch inside an open Astryx Dialog announces "3 results" (popover), "5 results" (sheet, closed) and "All filters removed. 240 results" (after Clear all's exit) in that Dialog's region, with the page-body channel empty throughout.
  • adaptive flips while the editor popover is open (FR10): the popover is portaled, so its focused control was neither inside the field's root nor seen by the field's focus events; when the switch to the sheet unmounts it, focus now moves to the trigger (an Effect reads the popover's open state from the render before the swap). Test: open the editor on a token, focus Apply, flip the compact-touch query → the trigger has focus (fails without the fix).
  • Space / Enter on choice rows (spec:AST-020 FR15): each row is a native button, so Space and Enter already fire its activation — a radio row checks, a checkbox row toggles; the code comment now says so and a unit test drives it with real key events (user-event): Space checks an unchecked radio row, Enter keeps the arrowed row checked, Space toggles a checkbox row on and off, Enter toggles it on. The Operator group is RadioList's native radio inputs, whose Space and arrow keys are the browser's. Chromium: on a create step with nothing checked, Shift+Tab lands on the first radio row (unchecked), Space checks it (0 → 1 checked in the group); Space on a checkbox row flips aria-checked false → true.
  • Lint: the choice-row icon is guarded with isRenderable(item.icon) (@astryx/no-nullish-jsx-guard).
  • Radio rows: Home/End stated plainly (they move to and check the first/last row); the "as a native radio group does" comparison is gone from the record.
  • FR14 title: the code already titles the step "{field} {operator}" when the field offers one non-nested operator (the operator group appears only above one); the record now says "one non-nested operator" so a field with one editable operator beside a nested one has a title and no group.
  • Result count during the exit (AR6): a count change that lands while isSheetPresented && !isSheetOpen (Clear all's commit, where the sheet body is already detached and the dialog still modal) joins the after-close queue instead of the page-body channel; the queue is spoken as one announcement in order once focus is back on the trigger ("All filters removed 240 results"). Chromium (iPhone-size, motion on, forced story, FR14 useAnnounce): nothing in any region while a dialog is open or modal; the combined text reaches the page-body region 358 ms after the tap with focus on the trigger; in the same run a removal from the value step delivers "Removed Priority is P1 - High" and the count change inside the Filters dialog's own region only. The forced story gains a resultCount that follows the filters so the behavior is visible there.
  • Choice rows (FR17 wording): a tap selects an enum or date_relative row whatever its state and toggles an enum_list row; the code is unchanged and the record now says exactly that.
  • Removals are announced (AR6): Remove in the value step announces Removed {label} — the Filters row's exact text, produced by the same describeFilterParts helper the rows render from (value via formatTokenValueText, joined through the catalog) — through the in-dialog region of the Filters sheet the flow returns to. Clear all announces "All filters removed" (new key @astryx.powersearch.sheet.filtersCleared; the popover's clear-all announces nothing today, so there was no message to reuse) only after the flow has fully closed: the sheet hands the message to PowerSearch (onAnnounceAfterClose), which speaks it when focus returns to the trigger — the same point that releases the presentation latch — because the closing dialog stays modal through its exit and hides both its own region and the page-body channel. Chromium (iPhone-size, motion on): the page-body region receives it 377 ms after the tap with no dialog open and none :modal (re-run this round together with the result count, see below).
  • onFocus/onBlur, size, statusVariant, hasAutoFocus (FR11): opening the flow fires onBlur (focus moves into the sheet) and the close that returns focus to the trigger fires onFocus, as the popover fires them around its editor; a test asserts the counts. The other three already applied to the trigger; FR11 now says so.
  • Token override on the trigger (FR11): a components[type].Token renders on the trigger with exactly the popover's props and onClick/onRemove undefined; the test now asserts the exact prop set.
  • AST-086 citations: the in-sheet route comments and test names cite FR3/FR4/FR14 (the in-modal requirement the AST-086 record now states, in this base chain) instead of FR10.
  • Empty-result messages are announced through the shared own-content module (AR6; architecture:react-component-runtime INV10): "No results found" and "No filters match your search" are rendered by a SheetEmptyMessage element inside the Add filter sheet's dialog that calls useAnnounceRenderedText(ref, true, query) — the module reads the rendered text and speaks it once per query that matches nothing, as Typeahead, Selector, CommandPalette and the Tokenizer's sheet do, and because the call is inside the dialog, useAnnounce routes it into the dialog (FR14). The sheet's own Effect that spoke these messages is gone. PowerSearchBottomSheet.tsx has its entry in hooks/useAnnounceRenderedText.adopters.test.tsx (main's INV10 adopter coverage, fix(ChatComposer): the status bar announces through the shared own-content module #8363): its own rendered content is the two empty-result texts and the unavailable state, all handed to the shared module; the flow's Added, Removed, result-count and after-close messages are not rendered content and go through useAnnounce directly. The messages are plain Text, not an EmptyState, so main's self-announcing EmptyState (fix(EmptyState): announce once when it appears after load, not as a live root #8294) is not involved and nothing speaks them twice — the test asserts the region holds the one message, that the same query is not re-announced, and that a new non-matching query is.
  • Lab deltas the prototype named are closed: strings are @astryx.powersearch.sheet.* catalog keys (twenty-four new under @astryx.powersearch.sheet.*; Cancel, Apply, Operator, Value, Values, Entities, the placeholders and the result count reuse the existing editor keys; the value-suggestion announcement reuses @astryx.tokenizer.tokenAdded, the same phrase the popover presentation's add makes), the trigger's name contains its visible text, the live region is StrictMode-safe, and the surface has a spec contract.

Deliberate differences from the prototype, each recorded in DEC-3 or an open question:

  • Standalone replacing step sheets (component:BottomSheet FR5–FR7, FR10; component:BottomSheetSwitcher DEP-0015): the three steps are sibling BottomSheets, each isOpen={step === …} with finalFocusRef={triggerRef} (and purpose="form" on the value step); a step change closes one in the update that opens the next, so BottomSheet replaces it in place — one scrim, the entering step above the leaving one, the flow's opener carried through the handoff. One handleStepOpenChange(sheetStep, isOpen) guard, bound per step, ends the flow only on the current step's own close request (a dismissal its purpose allows), so a replaced sheet's report and a stale one are ignored; every step change still goes through the flow's handlers. The draft outlives a step change (the outgoing sheet stays presented through the handoff). The deprecated switcher, its sheetIds and onActiveSheetChange handler are gone; the TouchDateField/TouchTimeField deep-import edits went with them (they existed only because the switcher reached the eager chunk through the BottomSheet barrel; measured again below, the barrel imports now cost nothing). Chromium (Adaptive story, iPhone profile): during a handoff the leaving dialog is inert with the scrim it owns at 1 and the entering one at 0, then one dialog with one scrim; Back lands on Add filter, Cancel on the edited row, a commit on the new row; Escape closes only the Tokenizer's sheet above the value step, then the flow, with focus on the trigger and the scroll lock released; Added "open" is announced from the Filters dialog's region; a resize past compact while a sheet is open takes effect after close, the combobox taking focus (FR10). No page errors.
  • Clear all keeps the popover presentation's semantics: every filter goes, read-only ones included, reported as onChange([], 'remove', lastIndex) — the change the field's clear control commits through the Tokenizer. The prototype kept read-only filters and reported the first removed index. Whether both presentations should keep read-only filters is an open question in the record.
  • Date editors read and write dates as the popover presentation does (UTC day), so one filter shows the same day as adaptive switches. The prototype read them in timezoneID. Whether both editors should use timezoneID, as the tokens and Filters rows do, is an open question.
  • date_absolute with isDateOnly: false gets the popover presentation's date-only editor, not the prototype's DateTimeInput; a date-and-time editor for both presentations is an open question.
  • Becoming disabled closes the flow (the prototype kept an open editor with disabled actions), per spec:AST-085 FR2 and the Tokenizer review finding.

packages/lab/src/MobilePowerSearch/ is untouched; the Lab prototype can be retired after this lands.

Current specification

component:PowerSearch (packages/core/src/PowerSearch/PowerSearch.spec.md, current) DEC-3, FR10–FR21, AR4–AR6, PR5, AV5–AV6 — merged in #8000. spec:AST-017 FR21/FR22/FR27 for the experimental boundary, the [experimental] patch Changeset, and no new stable theming target. component:Selector FR4 for the shared adaptive resolution. component:Tokenizer DEC-3 for the list-value sheet. component:BottomSheet (its sheet stack: one scrim per stack, dismissal at the top sheet, same-update replace) and family:overlay-dismissal own the sheets' dialogs, gestures, handoff and dismissal; component:BottomSheetSwitcher is deprecated (DEP-0015) and is not used. spec:AST-085 FR1/FR2 for the disabled trigger. family:input-fields FR10 and architecture:public-component-api INV8 for where ref and the base props land.

Public delta and caller burden

API (PowerSearchProps):

/** @experimental */
experimental?: {
  /** @experimental @default 'popover' */
  presentation?: 'popover' | 'bottom-sheet' | 'adaptive';
};

No new exports; the option type is inlined on the prop. No change to any stable prop, default or type. PowerSearchHandle.focusTypeahead()/blurTypeahead() act on the trigger in the sheet presentation.

// before and after — no opt-in, no change
<PowerSearch config={config} filters={filters} onChange={setFilters} />

// opt in: the sheet flow on compact touch screens, the field and popover elsewhere
<PowerSearch config={config} filters={filters} onChange={setFilters}
  experimental={{presentation: 'adaptive'}} />

Behavior in the sheet presentation (FR11–FR21): one trigger named by the label plus its visible text; the Filters sheet with rows, Add filter, Done and Clear all; Add filter with grouped fields or the content search; the value step staging field → operator → value with Cancel, the confirm action and Remove; exact onChange metadata; focus after each step; list values through the Tokenizer's sheet titled with the field label; Escape dismisses only the topmost sheet; the result count announces only on change.

Inert in the sheet presentation (no surface to act on; documented on the prop and in the record; each warns once in development rather than being silently dropped): menuWidth, tokenOverflowBehavior.

Prop targets: ref reaches the root in both presentations; xstyle/className/style the field container in both; data-testid the combobox input in the popover presentation (unchanged) and the trigger button in the sheet presentation. endContent renders in the field's end lane outside the trigger in both presentations, with its own tab stop.

Theming: none added while the option is experimental (spec:AST-017 FR27); the root keeps the existing power-search target in both presentations. The probe theme is unchanged.

i18n: twenty-four @astryx.powersearch.sheet.* keys (filtersTitle, addFilter, noFilters, clearAll, done, back, search, searchResults, searchFields, searchFieldsPlaceholder, noMatchingFields, noResults, filterUnavailable, remove, filtersCleared, filtersClearedWithCount, fieldOperator, labelValue, relative{Days,Weeks,Months}{Ago,FromNow}); Removed {label} reuses @astryx.tokenizer.tokenRemoved; the value summaries reuse @astryx.powersearch.valueEditor.{itemsCount,dateRange,filtersCount}.

Accessibility: trigger button named by the label plus its visible content, aria-haspopup="dialog" / aria-expanded, aria-describedby the status message and the disabled reason; aria-disabled (never native disabled) on the trigger; each step a modal dialog named by its title; rows as buttons named by their text (", selected" suffix on a chosen choice row); the operator step a radio group named "Operator"; the content search a searchbox named "Search" controlling the "Search results" list; every sheet action at least 44px tall; announcements made while a sheet is open go through a role="status" region inside that open dialog, not the page-body channel; focus returns to the trigger after the flow closes.

Bundle (architecture:public-component-api INV6; each subpath bundled alone from a fresh dist with esbuild — minified, ESM, code splitting, gzip level 9; react, react-dom, react/jsx-runtime, @stylexjs/stylex external; base = this branch's base, the Tokenizer branch rebased on main after #7648; this head = the migrated commit):

Subpath Eager, base Eager, this PR Lazy chunks, this PR
PowerSearch 157.4 KB gz 162.2 KB gz (+4.8) PowerSearchBottomSheet 11.3 KB gz (new; the three sheets, the sheet utils, the choice rows), TokenizerBottomSheet 8.8 KB gz (unchanged, from #7977)
DateInput 95.7 KB gz 96.0 KB gz (+0.3) unchanged
TimeInput 82.5 KB gz 82.8 KB gz (+0.3) unchanged
Tokenizer 65.3 KB gz 65.6 KB gz (+0.3) unchanged

CSS (astryx.css, gzip level 9): 33,421 → 33,457 bytes (+36 B gz; 209,623 → 209,970 raw) — the trigger, choice-row and nested-editor-container styles.

What changes in DateInput, TimeInput and Tokenizer is only the catalog: en.generated.js grows by ≈0.3 KB gz with the 24 @astryx.powersearch.sheet.* strings, and every consumer that bundles the default locale carries them; nothing else in those subpaths changes. formatFilterValue's shared number/list helpers are new exports, reached only from PowerSearch.

What stays eager for PowerSearch (+4.8 KB gz) and why: PowerSearch.tsx grows by ≈1.6 KB gz (the trigger branch, the lazy host, presentation resolution, the swap/focus/after-close effects), PowerSearchValueEditor.tsx by ≈0.35 KB (the presentation branches), formatTokenValue.ts is ≈0.8 KB (the token's value formatting, moved out of PowerSearch.tsx and shared with the sheet), the catalog ≈0.3 KB, and the rest is what the trigger newly reaches eagerly (Field for the box, which Tokenizer already pulled, the announce and adaptive-presentation hooks). The trigger is not lazy-loaded, the way Tokenizer keeps its own trigger eager and lazy-loads only its sheet: the trigger is the closed field in the sheet presentation — it server-renders for bottom-sheet, and under adaptive it is what the first client render after hydration swaps in (FR10) — so a lazy trigger would paint nothing, or the wrong field, until its chunk arrived. Everything that renders only while the flow is open is in the lazy PowerSearchBottomSheet chunk (powerSearchSheetUtils included; verified not reachable from the eager graph).

Design and alternatives

Chosen: the Lab three-sheet flow behind an experimental opt-in; adaptive resolved by the shared policy; the steps as standalone BottomSheets that replace one another on a same-update switch (component:BottomSheet FR10; the deprecated switcher is not used); Core's own value editors with a sheet presentation; list values through the Tokenizer's sheet; the popover presentation's Clear all and date semantics so one field reads the same in both presentations. Rejected (recorded in DEC-3): automatic switching on pointer: coarse without an opt-in (FR21); a top-level stable presentation prop before consumer evidence; a separate touch component chosen per breakpoint; sheet-only Clear all semantics that keep read-only filters; sheet-only date editors reading timezoneID while the popover's read UTC.

Two implementation notes: the flow's step follows the controlled isOpen in the render that sees the change (React's adjust-state-on-prop-change pattern), not in an effect, so the entry step's sheet opens in the same commit as the flow; and the two focus effects in the sheet file name the system they synchronize (the document's focus) and acquire nothing, so they need no cleanup (architecture:react-component-runtime INV3).

Evidence

  • Unit — full packages/core suite: 519 files, 14,563 tests — 518 files / 14,562 pass in the full parallel run; src/postinstall.test.mjs "still nudges when no folder up to the repository root ran init" fails identically on this branch's base (feat/tokenizer-experimental-bottom-sheet) and depends on the machine's parent directories; (an earlier full run saw Breadcrumbs.test.tsx and Table.perf.test.tsx fail once under load; both pass on their own and in this run, and neither imports anything this PR touches). PowerSearchBottomSheet.test.tsx (144 tests), PowerSearch.ssr.test.tsx (2 tests), PowerSearch.test.tsx (42 tests; two new), Field/fieldInputFamily.test.tsx (8 tests): presentation resolution — experimental omitted, {} and 'popover' keep the tokenizer field; 'bottom-sheet' forces the trigger and opens a modal dialog named "Filters"; 'adaptive' with matchMedia stubbed to match (max-width: 768px) and (pointer: coarse) opens the sheet and without it keeps the field (the selectors' test shape); menuWidth/tokenOverflowBehavior warn once in the sheet presentation and not in the popover. Trigger: name is the label plus the placeholder, or plus the tokens; display-only tokens; a token tap opens the flow; aria-disabled with no native disabled and no open; the disabled reason describes it; becoming disabled with the edit step open closes the flow and returns focus to the trigger, and re-enabling does not reopen it; hasAutoFocus, onFocus/onBlur, handleRef, ref on the root / data-testid on the trigger / className on the field, and data-testid still on the combobox input in the popover; the result count under <StrictMode> announces nothing on mount and announces a change; with :modal stood in for (jsdom has no showModal()), a result-count change while a sheet is open lands in that dialog's role="status" region and the page-body channel stays empty, and after Done the next change uses the shared channel; adaptive flipping to popover while the flow is open changes nothing until Done, keeps the trigger mounted through the exit, then mounts the tokenizer field with focus on its input and no trigger left; an action button in endContent is a sibling of the trigger with its own tab stop, fires its own click without opening the flow, and is absent from the trigger's name while a tap on the field's empty space still opens it; flipping the compact-touch query while the closed trigger holds focus mounts the combobox input with focus, flipping back mounts the trigger with focus, a focused token remove control hands focus to the trigger, a focused endContent (remounted by the other presentation) hands it to the primary control both ways, and flipping while focus is outside the field leaves it there. Ported Lab cases: Filters rows with bold values and chevrons, header/footer order, edge-to-edge rows on the body inset, Done, Clear all (now onChange([], 'remove', lastIndex), read-only included), hasClear, read-only; the sticky header and footer at computed z-index: 1 inside an isolation: isolate content wrapper (read from the injected StyleX rules; fails with the old values); Add filter direct entry, Back both ways, grouped fields with ungrouped fields first even when the config's first field is grouped, field search from eight fields with maxSearchResults, nested-only and mixed fields listed like any other, read-only while open; the value step's staging, exact add/edit/remove metadata, operator radios only for several operators, single-operator title, value kept across same-type operators, confirm disabled until a value, focus after Cancel/commit/Remove, nearest editable row after a removal, identity following across reorders, no edit when the target disappears, the way out when the config drops the field, Editor overrides (a custom Editor receives exactly the popover's prop set, timezoneID excluded); content search with inline suggestions, ArrowDown/ArrowUp, Enter with the Added "…" announcement delivered inside the Filters sheet's dialog region (page-body channel empty), the IME guard, the no-results state only for a non-empty query, "No results found" and "No filters match your search" announced inside the open sheet once per settle (not again on the next keystroke that keeps the list empty; again after results came back); Remove from the value step announces Removed Status is Open in the Filters sheet's region with the page-body channel empty, and Clear all announces "All filters removed. 240 results" (one catalog message carrying the count that changed in the closing commit) on the shared channel only after the dialog's exit has completed and focus is back on the trigger (nothing in either region before); a date_range row reads "date range" and a nested row "1 filter" from the catalog; every value type, with string_list and entity_list through the Tokenizer's sheet (named with the field, Search ID, Add ABC-1, Escape closes only that sheet, focus returns to its trigger, the draft survives); for a boundary date under timezoneID UTC, Tokyo, Honolulu and unset, the sheet's trigger token, the Filters row's bold value and the Removed … announcement carry exactly the text the popover presentation's token shows for the same filter (read from a popover render in the same test); a string_list, an integer and a date_range row read "ID is any of a, b", "Lines is greater than 7", "Window is between date range", the popover token's words; the editor reads the UTC day as the popover does; a nested filter's row opens the built-in nested editor with Apply and Remove, and a mixed field's Operator group lists both its operators with the editor swapping underneath; onBlur fires once when the flow opens and onFocus again when it closes. Helpers: identity resolution, nearest editable index, and the suggestion filter over the popover presentation's own source.
  • Unchanged paths — pnpm exec vitest run packages/core/src/PowerSearch packages/core/src/Tokenizer packages/core/src/BottomSheet packages/core/src/hooks — 56 files, 1239 tests passed (the Tokenizer suite as revised on the base, including the list-value tests against the Loading-row and latch changes); packages/core/src/DateInput packages/core/src/TimeInput packages/core/src/DateRangeInput (the deep-import change) — 11 files, 513 tests passed. packages/lab/ is untouched.
  • Real browser (Chromium 156 headless shell via Playwright on Linux, static Storybook build of the previous head, iPhone 13 profile 390×844 with touch emulation; 50/50 checks): the touch context reports the compact coarse-pointer query; no combobox in the sheet presentation; the trigger's name carries the tokens; one tab stop; a tap on a token opens the Filters sheet (modal dialog "Filters", panel 824px of 844, page scroll locked); three preset rows at 49px, Done at 44px; Add filter opens the field sheet with chevrons; Priority (one operator) opens "Priority is" with no radios, a row tap stages P2, Add filter enables, the commit returns to Filters with focus on Add filter and four rows, and the controlled state holds p2; the edit step has Remove, and Cancel returns focus to the edited row; Assignee renders the Tokenizer trigger (no combobox), tapping it opens the Tokenizer's sheet titled "Assignee" above the step, Search Assignee filters, a row tap checks it, Escape closes only that sheet and focus returns to its trigger showing the staged token, Add filter commits the entity filter; Title (two operators) shows radios and a text input at 338px of the 390px step, Cancel returns focus to the Title row, Back returns to Filters with focus on Add filter; Escape on the Filters sheet closes the flow, focus returns to the trigger, the scroll lock releases; the Default story keeps the combobox on touch; adaptive opens the sheet on the touch context and is the tokenizer field at 1280×800 with a fine pointer, still inline when that window narrows to 390px; the disabled trigger is aria-disabled, focusable, without native disabled, and does not open; the read-only Filters sheet has Done but no Add filter or Clear all; no page errors.
  • Tokenizer's sheet hosted inside the value step (component:BottomSheet FR5–FR9, component:Tokenizer DEC-3, FR21; Chromium 156 headless shell, Adaptive story, iPhone profile 390×844 — the hosted case the Tokenizer PR's stack tests cover in jsdom but could not drive in a browser, since main's PowerSearch hosts no Tokenizer): Add filter → Assignee opens the value step (one dialog "Assignee", scrim 1, panel top 68 px, focus inside); tapping the Tokenizer trigger ("Search…") opens Tokenizer's sheet on top — two open dialogs in open order, the value step's first and Tokenizer's "Assignee" sheet (search field, six rows) last, one scrim (the stack's owner at 1, the top sheet at 0), the value step's panel receded (top edge 68 → 57 px, behind the top sheet's 68 px) and not inert, because it contains the presented sheet (FR6); checking "Alice Johnson" then Escape closes only Tokenizer's sheet — its dialog goes inert / aria-hidden and slides out (translateY 674 px at 250 ms) while the value step stays open with its scrim at 1 — and once the exit settles focus is on the Tokenizer trigger inside the value step, now reading "Alice Johnson" (the staged token), aria-expanded="false", with the step's Add filter enabled: the staged state survived (FR21). During the top sheet's exit motion the active element is the body for a moment (the closing dialog is inert and the return focus lands after its exit), which is BottomSheet's close sequence, not the flow's. No page errors.
  • One scrim per stack (component:BottomSheet FR6): with the Tokenizer's sheet open over the value step, the top sheet's dialog owns the scrim (--_sheet-scrim-opacity 1) and the covered step's is 0; during a step handoff the leaving dialog keeps the scrim until the entering one takes it over. The page behind is dimmed once.
  • Accessibility audit — .github/scripts/accessibility-audit.js --components PowerSearch --baseline .github/a11y-baseline.json --fail-on-new on the same build: 0 new violations (2 baselined, pre-existing on the Disabled stories), the four new stories at 0 issues; no baseline change.

Writing rubric results

Run over this PR's change to PowerSearch.spec.md (main's merged record with its implementation-gap notes and the AST-086 gap removed, and PowerSearchBottomSheet.test.tsx and PowerSearch.ssr.test.tsx added to verified_by):

  • R1 narration: none — every passage describes the shipped behavior in the present tense; DEC-3 and the INV7 exceptions state the counts measured at this head, not how they were obtained.
  • R2 research in the record: none; the prototype history, the Chromium runs and the bundle measurements stay in this description.
  • R3 evidence naming a change: none; the verification row names test layers and real-engine evidence; the Basis column names the Lab evidence and the current popover and typeahead source.
  • R4 rejected alternatives over one line or outside a decision: none; DEC-3 carries its one-line Rejected: entries.
  • R5 pull request or issue references remaining: none (searched # + digits, /pull/, /issues/).
  • R6 ruled open questions still listing options: none; the known gaps state today's behavior and the open questions state today's behavior and the alternative side by side.

Scope

  • One capability is added; the stable StrictMode result-count fix shipped on its own (fix(PowerSearch): the result count is announced once under StrictMode #8650) and is not part of this diff.
  • Unrelated fixes, cleanup, and policy changes were removed or split. The size limit is waived by the owner ("i don't mind large PR. i can bypass [the review size] limit"); see On the size above for why the remaining pieces stay together.
  • Consumer docs (PowerSearch.doc.mjs prop with stability: 'experimental', an example; zh and dense variants), four stories under Core/PowerSearch (forced, adaptive, content search, states), focused tests and the [experimental] Changeset ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

  • pnpm exec vitest run packages/core/src/PowerSearch packages/core/src/Tokenizer packages/core/src/BottomSheet packages/core/src/hooks packages/core/src/Dialog — 66 files, 1456 tests passed; packages/core/src/DateInput packages/core/src/TimeInput packages/core/src/DateRangeInput — 513 passed (previous head).
  • CI's typecheck steps, each run individually under bash -c with its exit code: @astryxdesign/core typecheck 0, @astryxdesign/core typecheck:docs 0, @astryxdesign/lab typecheck:docs 0, @astryxdesign/storybook typecheck 0, @astryxdesign/a11y-spec typecheck 0, @astryxdesign/cli typecheck:strict 0, @astryxdesign/cli typecheck:template-docs 0.
  • ASTRYX_STRICT_LINT=1 pnpm exec eslint packages/core/src/PowerSearch/ apps/storybook/stories/PowerSearch.stories.tsx — 0 errors, 0 new warnings (the remaining warnings are pre-existing on PowerSearchEditPopover.tsx and the popover's result-count node).
  • Prettier over the changed files — clean (PowerSearch.doc.mjs is not Prettier-formatted on main and is left as is).
  • pnpm check:repo (knowledge, changesets, sync, i18n catalog and runtime, package boundaries, use-client, …) — exit 0; pnpm sync:exports:check — up to date; pnpm -F @astryxdesign/core build:css — 193.2 KB; pnpm visual:probe-theme:check — current, unchanged.
  • Vercel's local build — pnpm build && VERCEL_ENV=preview VERCEL_GIT_COMMIT_SHA=$(git rev-parse HEAD) node apps/docsite/scripts/build-previews.mjs && pnpm -F @astryxdesign/docsite build — all three exit 0 (783 sandbox routes and 73 embeds prerendered and verified; 431 docsite pages). pnpm storybook:build — succeeded at the previous head.
  • git diff --check — clean.
  • Real-browser matrix and accessibility audit as under Evidence.

@vercel

vercel Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 27, 2026 5:54pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 27, 2026, 6:28 PM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 23, 2026
@imdreamrunner
imdreamrunner force-pushed the feat/tokenizer-experimental-bottom-sheet branch from b154a0f to c07a91a Compare October 23, 2026 19:53
@imdreamrunner
imdreamrunner force-pushed the feat/powersearch-experimental-bottom-sheet branch from 07d619c to 0efc86f Compare October 23, 2026 19:56
@imdreamrunner
imdreamrunner force-pushed the feat/tokenizer-experimental-bottom-sheet branch from c07a91a to 36f8999 Compare October 23, 2026 20:03
@imdreamrunner
imdreamrunner force-pushed the feat/powersearch-experimental-bottom-sheet branch from 0efc86f to 7e07d10 Compare October 23, 2026 20:03
@imdreamrunner
imdreamrunner force-pushed the feat/tokenizer-experimental-bottom-sheet branch from 36f8999 to a017ec1 Compare October 23, 2026 20:17
@imdreamrunner
imdreamrunner force-pushed the feat/powersearch-experimental-bottom-sheet branch from 7e07d10 to 8b9c8d8 Compare October 23, 2026 20:17
@imdreamrunner
imdreamrunner force-pushed the feat/tokenizer-experimental-bottom-sheet branch from a017ec1 to 13206a2 Compare October 23, 2026 20:25
@imdreamrunner
imdreamrunner force-pushed the feat/powersearch-experimental-bottom-sheet branch from 8b9c8d8 to 11fb87a Compare October 23, 2026 20:26

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, but it cannot be approved yet: the head writes DEC-3 and FR10–FR21 into the current component:PowerSearch record, and an engineering owner still has to decide those requirements, including FR17/FR21's reliance on the Tokenizer sheet requirements from #7977 that are not on main yet. The approval on #8000 covers that pull request's head, not this one; an engineering owner records the decision here by commenting /approve-spec 11fb87a50dafe3d00fb7f54d4151c577eaa3ace9 on this pull request, and the author may do so as an engineering owner. Whatever that decision is, these fixes are needed:

  • Put data-testid on the sheet trigger (today it moves from the combobox input to a div), and drop the family:input-fields membership and FR10 citation, since that family's record excludes PowerSearch.
  • Show date-only values without a time of day in the Filters rows (the forced story shows Mar 9, 2024, 7:00 PM).
  • Align FR16 (focus after a value suggestion commits) and FR20 (the relative-date row labels) with the code, and declare the stable result-count fix in its own [fix] Changeset.
  • Split the 4,800-line change, for example by dropping the carried spec commit once #8000 lands and moving the stable fix out, or explain why it cannot be split.

[Automated review]

onClick={handleTriggerWrapperClick}
onFocus={handleTriggerFocusWithin}
onBlur={handleTriggerBlurWithin}
data-testid={testId}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the popover presentation data-testid reaches the combobox input (through Tokenizer); here it lands on the control-box div, so a test id changes element when an adaptive field switches. Putting it on the trigger <button> keeps it on the field's primary control in both presentations.

config,
operator.value,
filter.value,
Number.MAX_SAFE_INTEGER,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formatFilterValue formats every date_absolute with hour and minute, so a date-only filter (isDateOnly: true) reads Mar 9, 2024, 7:00 PM in this row while its token says Mar 9, 2024. Could the row format date-only values without the time?

value: filterValue,
};
pendingSheetFocusRef.current = {
type: 'manager-filter',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With a controlled parent this moves focus to the new filter's row after a value suggestion commits, while FR16 says a creation commit focuses Add filter. Either the code or FR16 should change so they agree.

]
modules: []
families: [family:overlay-dismissal]
families: [family:input-fields, family:overlay-dismissal]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

family:input-fields lists PowerSearch as excluded ("higher-level compositions that consume member behavior"), and its FR10 sends data-* to the primary semantic control, which this record's prop-target paragraph does not follow. Drop the membership and the FR10 citation, or amend that family first.

'@astryxdesign/core': patch
---

[experimental] PowerSearch gains a touch surface behind `experimental={{presentation: 'bottom-sheet' | 'adaptive'}}`. `bottom-sheet` makes the whole field one trigger showing display-only filter tokens, and moves filter editing into a flow of tall modal sheets: a Filters sheet listing the active filters as rows with Add filter, Done, and Clear all; an Add filter sheet with the fields grouped (searchable from eight fields), or the content search with the field typeahead's suggestions listed inline; and a value step that stages field → operator → value and confirms from its footer, with Remove while editing. Closed vocabularies are full-width rows, numbers stage as typed, and `string_list` / `entity_list` values edit through the Tokenizer's own sheet presentation titled with the field label. `adaptive` uses the sheet on compact coarse-pointer screens and the popover otherwise, resolved the same way Selector resolves it. The sheet flow loads on demand, so a PowerSearch that does not opt in does not carry it. New `@astryx.powersearch.sheet.*` catalog keys for the sheets' strings; no new theming target. The result-count announcement is now StrictMode-safe in both presentations (no announcement on mount). Anything inside PowerSearch's `experimental` prop may change or be removed in a patch release; omitting it keeps the stable behavior.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The StrictMode-safe result count changes the stable presentation too; spec:AST-017 FR22 keeps that out of an [experimental] entry, so it needs its own [fix] Changeset.

@imdreamrunner
imdreamrunner force-pushed the feat/tokenizer-experimental-bottom-sheet branch from 13206a2 to 6f02169 Compare October 23, 2026 21:25
@imdreamrunner
imdreamrunner force-pushed the feat/powersearch-experimental-bottom-sheet branch from 11fb87a to bf9ee1b Compare October 23, 2026 21:39
@imdreamrunner
imdreamrunner force-pushed the feat/tokenizer-experimental-bottom-sheet branch from 6f02169 to 1dc081e Compare October 23, 2026 22:10
@imdreamrunner
imdreamrunner force-pushed the feat/powersearch-experimental-bottom-sheet branch from bf9ee1b to 3cbfa4c Compare October 23, 2026 22:12
@imdreamrunner
imdreamrunner force-pushed the feat/powersearch-experimental-bottom-sheet branch from 3cbfa4c to c3bbf19 Compare October 23, 2026 22:21
@imdreamrunner
imdreamrunner force-pushed the feat/tokenizer-experimental-bottom-sheet branch from 1dc081e to c1d1852 Compare October 23, 2026 22:33
@imdreamrunner
imdreamrunner force-pushed the feat/powersearch-experimental-bottom-sheet branch from c3bbf19 to fc16c1d Compare October 23, 2026 22:34

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, but it cannot be approved yet: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, and an engineering owner has to decide those requirements, including FR17/FR21's reliance on the Tokenizer sheet requirements from #7977 that are not on main yet. An engineering owner records that decision by commenting /approve-spec followed by this head's full commit SHA on this pull request, and the author may do so as an engineering owner. Whatever that decision is, these still need changes:

  • Make a date-only filter show the same day on the trigger and in the Filters row; today the token formats in the device's time zone and the row in timezoneID.
  • Announce the sheets' empty-result messages as AR6 says, or drop that clause from AR6.
  • Split the 5,181-line change, for example by dropping the carried record change once #8000 lands and moving the stable result-count fix out, or explain why it cannot be split.

[Automated review]

operator.value.type === 'date_absolute' &&
filter.value.type === 'date_absolute' &&
operator.value.isDateOnly !== false
? formatDateAbsoluteCompact(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The row formats a date-only value in timezoneID, but the trigger's token (PowerSearch.tsx:430) formats the same instant with no time zone, so it follows the device. With timezoneID America/Los_Angeles on a Tokyo device, the forced story's token says Mar 10, 2024 while this row says Mar 9, 2024. Formatting both the same way (here, or in the token as its own [fix]) keeps one filter on one day, and the boundary test could assert the token too.

"Search filters"; every sheet action is at least 44px tall.
- **AR6 — Announcements and focus.** While a sheet is open, every announcement
the flow makes — `Added {label}` for a committed suggestion, the result count
of FR19, and the sheets' empty-result messages — is delivered by a persistent

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing announces the empty-result messages: "No filters match your search" and "No results found" render as plain Text with no live region (PowerSearchBottomSheet.tsx:1320–1334). Either deliver them through the flow's polite region or drop this clause from AR6.

@imdreamrunner
imdreamrunner force-pushed the feat/powersearch-experimental-bottom-sheet branch from fc16c1d to 5e77abd Compare October 23, 2026 23:19
@imdreamrunner
imdreamrunner force-pushed the feat/tokenizer-experimental-bottom-sheet branch from c1d1852 to 86731bf Compare October 23, 2026 23:33

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, but it cannot be approved yet: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, so an engineering owner needs to approve or amend those requirements on this exact head by commenting /approve-spec <full head SHA> (the author may do so as an engineering owner). FR17 and FR21 also rely on the Tokenizer requirements in #7977, so that record has to land first. The nested value step now fits 390 and 414 px sheets and no other implementation defect was found, but at 7,561 authored lines the change still needs splitting: drop the carried record commit once #8000 lands, move the StrictMode guard, the formatTokenValue.ts extraction and the story decorator into their own pull requests, and stage the flow.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, but it cannot be approved yet: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, so an engineering owner needs to approve or amend those requirements on this exact head by commenting /approve-spec <full head SHA> (the author may do so as an engineering owner), and FR17 and FR21 also rely on the Tokenizer requirements in #7977, which are not on main yet. Two changes are needed either way:

  • Editing a group filter can show and save another group's sub-filters: the value step keys NestedEditor by field and operator only (PowerSearchBottomSheet.tsx:1778) while the sheet keeps it mounted, so give each opened filter its own key and add a test that edits two groups on one field in a row.
  • At 7,586 authored lines the change needs splitting: drop the carried record commit once #8000 lands, move the StrictMode guard, the formatTokenValue.ts extraction and the story decorator into their own pull requests, and stage the flow.

[Automated review]

{operatorGroup}
{isDraftNested ? (
<NestedEditor
key={`${draft.field}-${draft.operator}`}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This key is the same for every group on one field and operator, and the switcher keeps the value step mounted between steps, while NestedEditor reads partialFilter only on mount. Opening a second group of the same field therefore shows the first group's sub-filters, and any edit there is committed over the second group (in a probe: onChange(…, 'edit', 1) with the first group's rows). A new group from Add filter also opens pre-filled, and Cancel does not discard. Keying the body by an id set on each open (in handleFilterEdit and openCreateEditor), plus a test that edits two groups on one field in a row, would hold it.

Comment on lines +1145 to +1149
// Whether a Filters row is a button (FR12): the field interactive and the
// filter not read-only. An editable operator's row opens the value step;
// a `nested` operator's row, and the row of a filter the config no longer
// knows, open the step's no-editor state, whose Remove is the way out — as
// the popover's token has its × for the same filter.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: since nested rows open the built-in nested editor, this comment is stale — a nested operator's row opens an editable step, and only a stale filter's row opens the no-editor state. The header of powerSearchSheetUtils.ts ("nested-operator exclusion", "keeps nested-only fields out of the content search") has the same leftover.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this exact head (the author may do so as an engineering owner), and FR17 and FR21 also rely on the Tokenizer requirements in #7977, which are not on main yet. The group-editing issue from the last review is fixed and covered by a regression test. One change is still needed either way: at 7,805 authored lines the change needs splitting — drop the carried record commit once #8000 lands, move the StrictMode guard, the formatTokenValue.ts extraction and the story decorator into their own pull requests, and stage the flow (the trigger and Filters sheet, then Add filter and the content search, then the value editors).

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this exact head (the author may do so as an engineering owner), and to settle whether family:input-fields FR10 is amended for the new Field-boxed trigger or the trigger stops composing Field; FR17 and FR21 also rely on the Tokenizer requirements in #7977, which are not on main yet. Two changes are needed either way:

  • After a rotation while the flow is open, Done calls onFocus twice (trigger, then input); routing that hand-off through focusAfterSwap at PowerSearch.tsx:638 keeps it one visit.
  • At 7,921 authored lines the change needs splitting: drop the carried record commit once #8000 lands, move the StrictMode guard, the formatTokenValue.ts extraction and the story decorator into their own pull requests, and stage the flow.

[Automated review]

if (!isSheetPresentation) {
if (isInlineFocusPendingRef.current) {
isInlineFocusPendingRef.current = false;
tokenizerRef.current?.focus();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the one hand-off that skips focusAfterSwap: after the flow closes with adaptive now resolving to popover, the trigger's return focus already called onFocus, and this focus() calls it again for the combobox input with no onBlur between (Chromium: onBlur, onFocus on the trigger, onFocus on the input). focusAfterSwap(tokenizerRef.current) here keeps it one visit, and the latch test at PowerSearchBottomSheet.test.tsx:1143 could assert the count.

`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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

family:input-fields FR10 item 1 also puts ref (with id, data-*, aria-* and handlers) on the primary semantic control, while this record keeps ref on the root (line 145); if the gap stays, could it name ref as well as the root styling props?

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch and fixes the previous head's duplicate onFocus, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this exact head (the author may do so as an engineering owner), and to settle whether family:input-fields FR10 is amended for the Field-boxed trigger or the trigger stops composing Field. FR17 and FR21 also rely on the Tokenizer requirements in #7977, which are not on main yet. One change is needed either way: at 7,933 authored lines the change needs splitting — drop the carried record commit once #8000 lands, move the StrictMode guard, the formatTokenValue.ts extraction and the story decorator into their own pull requests, and stage the flow.

[Automated review]

// under FR10. Omitting them from the props type is a stable API change for
// existing callers, so it waits for its own decision rather than riding the
// experimental option (`component:PowerSearch` Open questions).
const PENDING_FR10: ReadonlySet<string> = new Set<string>(['PowerSearch']);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Listing PowerSearch here keeps the family coverage test green while the sheet trigger composes <Field> with ref on the root and root xstyle/className/style still in the props type, which current family:input-fields FR10 does not allow for any component that composes Field. An engineering owner needs to amend FR10 for this experimental trigger, or the trigger needs to stop composing Field, before this exemption holds.

`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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gap names the root xstyle/className/style, but FR10 item 1 also requires ref to reach the primary semantic control, and this record keeps ref on the root (line 145). If the gap stays, it should name ref too.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this exact head (the author may do so as an engineering owner), and to settle whether family:input-fields FR10 is amended for the Field-boxed trigger or the trigger stops composing Field. FR17 and FR21 also rely on the Tokenizer requirements in #7977, which are not on main yet. One change is needed either way: at 7,941 authored lines the change needs splitting — drop the carried record commit once #8000 lands, move the StrictMode guard, the formatTokenValue.ts extraction and the story decorator into their own pull requests, and stage the flow. Two small fixes would also help: name ref in the PENDING_FR10 comment, and either scope FR12's English-parity sentence or keep the token's text for exponent-form numbers such as 1e-7.

[Automated review]

Comment on lines +18 to +26
// Field-composed inputs not yet converted to FR10. Every member is converted.
// PowerSearch is outside the family (a composition that consumes member
// behavior) and composes Field only in its experimental sheet presentation,
// whose trigger renders the Field box the tokenizer field has; its stable
// props carry root `xstyle`, `className`, and `style` (documented, in use),
// which both presentations apply to that field box — the `container*` target
// under FR10. Omitting them from the props type is a stable API change for
// existing callers, so it waits for its own decision rather than riding the
// experimental option (`component:PowerSearch` Open questions).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This explains the pending entry by the root xstyle/className/style alone, while the record's known gap (PowerSearch.spec.md:539–557) now also names ref staying on the root (FR10 item 1). Naming ref here keeps the exemption and the record in step.

Comment on lines +91 to +99
// The provider locale's digits and decimal mark, but no grouping and no
// rounding, so in English the row reads exactly as the token does
// (`1234`, `2.3456`).
return t
? formatSharedNumber(filterValue.value, locale, {
useGrouping: false,
maximumFractionDigits: 20,
})
: String(filterValue.value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In English this still differs from the token's String(value) for exponent-form values and negative zero: 1e-7 reads 0.0000001 on the row and in Removed …, 1e21 reads 1000000000000000000000, and -0 reads -0 beside the token's 0. FR12 says a row reads as the token does in English, so either the sentence names this exception or the row keeps the token's text for those values.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record and keeps a known departure from family:input-fields FR10, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide whether FR10 is amended for this experimental trigger or the trigger stops composing Field. Independently of that decision, three things need the author:

  • Split the 8,027-line change: let the record land with #8000, move the StrictMode fix, the formatTokenValue.ts extraction and the story decorator out, and stage the flow.
  • Add fine-pointer before/after evidence for the StrictMode fix, such as a popover-presentation StrictMode test that fails on the base, as spec:AST-017 FR59 asks.
  • Re-measure the bundle and CSS size at this head, including DateInput and TimeInput, and justify or lazy-load the eager trigger code (architecture:component-style-authoring INV6).

[Automated review]

config={config}
filters={[]}
onChange={vi.fn()}
experimental={{presentation: 'bottom-sheet'}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only StrictMode result-count test, and it renders the sheet presentation; the fix also changes the stable popover presentation, and spec:AST-017 FR59 asks touch work to show fine-pointer evidence before and after such a change. The same test without experimental fails on the base (its live region reads 12 results right after mount) and passes here.

? String(value)
: formatSharedNumber(value, locale, {
useGrouping: false,
maximumFractionDigits: 20,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With maximumFractionDigits: 20, a value with more fraction digits is rounded in a locale that does not write numbers with Latin digits and a . decimal mark: in German 1e-21 reads 0 and 1.23e-19 reads 0,00000000000000000012, while FR12 promises no rounding. A cap of 100 (or narrower FR12 wording) closes it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record and keeps a known departure from family:input-fields FR10, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide whether FR10 is amended for this experimental trigger or the trigger stops composing Field. Independently of that decision, two things need the author:

  • Split the 8,080-line change: let the record land with #8000, move the StrictMode fix, the formatTokenValue.ts extraction and the story decorator out, and stage the flow.
  • Speak the Add filter sheet's empty-result messages through the shared useAnnounceRenderedText module, as Typeahead, Selector and CommandPalette do, instead of the sheet's own Effect (architecture:react-component-runtime INV10).

[Automated review]

Comment on lines +922 to +931
const announcedEmptyResultRef = useRef<string | null>(null);
useEffect(() => {
if (emptyResultMessage === announcedEmptyResultRef.current) {
return;
}
announcedEmptyResultRef.current = emptyResultMessage;
if (emptyResultMessage != null) {
announce(emptyResultMessage);
}
}, [announce, emptyResultMessage]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Effect speaks the empty-result text the sheet also renders (:1684, :1692), the shape current architecture:react-component-runtime INV10 rules out for core components: an own-content announcement goes through the shared useAnnounceRenderedText module, as BaseTypeahead, Selector and CommandPalette do on main. Rendering each empty-state message through a small component inside the Add filter sheet that calls useAnnounceRenderedText on its own element keeps the in-dialog route (spec:AST-086 FR14) and lets this Effect go.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record and keeps a known departure from family:input-fields FR10, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide whether FR10 is amended for this experimental trigger or the trigger stops composing Field. The Add filter sheet's empty-result messages now go through the shared own-content announcement module, which settles the earlier finding. Independently of the owner's decision, two things need the author:

  • Split the 8,129-line change: let the record land with #8000, move the StrictMode fix, the formatTokenValue.ts extraction and the story decorator out, and stage the flow.
  • Re-run the size report at this head against its base, with the CSS delta in gzip (architecture:component-style-authoring INV6).

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record and keeps a known departure from family:input-fields FR10, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide whether FR10 is amended for this experimental trigger or the trigger stops composing Field. Independently of that decision, three things need the author:

  • Mount the sheet flow only while it is open or closing, or record an owner exception with element counts: at rest an adaptive field on a phone renders 296 elements against 167 with a mouse because three closed step dialogs stay mounted (architecture:component-style-authoring INV7).
  • Split the 8,211-line change: let the record land with #8000, move the StrictMode fix, the formatTokenValue.ts extraction and the story decorator out, and stage the flow.
  • Re-run the size report at this head against its base, and add the record's ## Writing rubric results to the description, which the owner's approval reads the record against.

[Automated review]

{showsDisabledMessage &&
disabledMessageTooltip.renderTooltip(disabledMessage)}
</Field>
<PowerSearchSheetHost

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This host renders whenever the field is in the sheet presentation, so once the chunk loads the BottomSheetSwitcher mounts all three steps while the flow is closed, and each step renders its <dialog> and children unpresented. On the Experimental: adaptive story the field at rest is 296 elements under a coarse pointer against 167 under a fine one (three closed dialogs with 51, 99 and 18 descendants). architecture:component-style-authoring INV7 asks for measured counts for added elements and fails "more elements under a coarse pointer than a fine one". Could the host mount the flow only while it is open or exiting (keeping the chunk warm with a bare import()), or record an owner exception with the counts?

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, so an engineering owner needs to approve or amend them by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), and to decide whether family:input-fields FR10 is amended for this trigger or the trigger stops composing Field; FR17 and FR21 also rely on Tokenizer requirements from #7977 that are not on main yet. Whatever that decision, these still need the author:

  • FR12 and FR14 promise raw keys for a filter whose operator the config dropped, but the row and the step title show the field's label (PowerSearchBottomSheet.tsx:1154, :1412); align the record or the code, with a test.
  • At 8,330 authored lines the change needs splitting: let the record land with #8000, move the StrictMode fix, the formatTokenValue.ts extraction and the story decorator into their own pull requests, and stage the flow.
  • Refresh the bundle-size table to this head and add the ## Writing rubric results section the record approval reads.

[Automated review]

// A filter whose field or operator the config no longer has still
// lists, by its raw keys, as its token does on the trigger; its row
// opens the unavailable step, whose Remove is the way out (FR12).
const fieldLabel = field?.label ?? filter.field;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a saved filter whose operator the config no longer has but whose field it still has, this row reads the field's label beside the raw operator key ("Status gone"), and the step title at line 1412 does the same, while proposed FR12 says the row "lists by its raw keys". Either reword FR12 and FR14 ("the field's label, or its key when the field is gone, and the operator's key") or show the field key in both places.

| 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; verified by focused sheet tests and real-engine touch evidence. |
| 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, before the token's width budget — 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 that fits reads as 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 by its raw keys as a row button 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', <index of the last filter>)` — 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; verified by focused sheet tests and real-engine touch evidence. |
| 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, an IME-composing Enter does nothing, and a query with no suggestion shows "No results found". 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; verified by focused sheet tests and real-engine touch evidence. |
| 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 the raw keys, "{field key} {operator key}", as the Filters row does (FR12). 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; verified by focused sheet tests and real-engine touch evidence. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says a stale filter's title reads "{field key} {operator key}", but when only the operator is gone the code titles it with the field's label (PowerSearchBottomSheet.tsx:1412), as the row does; one of the two should change before an owner approves the text.

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, so an engineering owner needs to approve or amend them by commenting /approve-spec <full head SHA> on this exact head (the author may do so as an engineering owner), and to settle whether family:input-fields FR10 is amended for the Field-boxed trigger or the trigger stops composing Field. FR17 and FR21 also rely on Tokenizer requirements from #7977 that are not on main yet. Two changes are needed either way:

  • With hasAutoFocus, an adaptive resolution change moves focus into the field and fires onFocus even when focus was elsewhere on the page (PowerSearch.tsx lines 1457 and 1531); applying autoFocus only on the first mount, with a test for that case, would fix it.
  • At 8,428 authored lines the change needs splitting: drop the carried record commit once #8000 lands, move the StrictMode fix, the formatTokenValue.ts extraction and the story decorator into their own pull requests, and stage the flow.

[Automated review]

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`, recorded as that record's own rule

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gap describes main's useAnnounce. The base this branch sits on already delivers inside the enclosing Astryx modal, and the Dialog test in PowerSearchBottomSheet.test.tsx ("announces inside the modal that holds the field…") passes on this head, while FR19's status says verified. Could the gap say it applies to main until that hook change lands, so the record does not state both?

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 (with an INV7 surface-budget exception) into the current component:PowerSearch record and keeps a known departure from family:input-fields FR10, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide whether FR10 is amended for this experimental trigger or the trigger stops composing Field. Independently of that decision, these need the author:

  • Split the 8,592-line change: let the record land with #8000, move the StrictMode fix, the formatTokenValue.ts extraction and the story decorator out, and stage the flow.
  • Apply hasAutoFocus only on the field's first mount: today every adaptive swap focuses the incoming control and fires onFocus even when focus was elsewhere on the page.
  • Re-home focus after a controlled removal by an index among the rows that hold a button: with a read-only filter above, focus skips the row that followed the removed one.
  • Add the ## Writing rubric results section a record change reports, from the docs/contributing/spec-writing.md rubric run over the record at this head.

[Automated review]

// The field's primary control: `data-testid` lands here, as it
// lands on the combobox input in the popover presentation.
data-testid={testId}
autoFocus={hasAutoFocus}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

autoFocus={hasAutoFocus} runs again every time an adaptive swap remounts the trigger (and the Tokenizer branch at line 1531 does the same for the combobox), so with hasAutoFocus a resolution change pulls focus into the field and fires onFocus even when focus was elsewhere on the page. FR10 says focus stays where it is in that case. Applying autoFocus only on the field's first mount, with a flip test that starts with focus outside the field, would fix it.

.filter((button): button is HTMLElement => button != null);
let target: HTMLElement | null = null;
if (last.rowIndex != null && rowButtons.length > 0) {
const index = Math.min(last.rowIndex, rowButtons.length - 1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

last.rowIndex was recorded among every row in managerRowsRef (plain read-only rows included, line 750), but it indexes rowButtons, which holds only rows with a button. With one read-only row above, removing the focused row sends focus one row past the row that followed it (rows F0–F4, F0 read-only, F2 removed: focus lands on F4). Indexing the full li list and taking the first button row at or after it would match FR16; a test with a read-only row above would cover it.

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pull request is on hold for a spec decision: the proposed component:PowerSearch record (DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6, with FR13's keyboard exception and DEC-3's surface-budget exception) and the sheet trigger's departure from family:input-fields FR10 need an engineering owner to approve them or name the requirements to change. An engineering owner records approval by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner; FR17 and FR21 also depend on the Tokenizer record in #7977. Two changes are needed either way:

  • hasAutoFocus now applies on the first mount only (PowerSearch.tsx:611-616), which removes data-autofocus from the stable field after its first re-render, so a PowerSearch inside a Dialog that opens later no longer takes focus; keep it as passed until a presentation swap actually happens, and add a test with a Dialog that opens after mount.
  • The change is 8,668 authored lines; split out the separable pieces (the carried spec commit, the stable StrictMode fix, the formatter extraction) or land the size exception under current authority.

[Automated review]

Comment on lines +611 to +616
const hasMountedRef = useRef(false);
const isFirstMount = !hasMountedRef.current;
useEffect(() => {
hasMountedRef.current = true;
}, []);
const autoFocusOnMount = hasAutoFocus && isFirstMount;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

autoFocusOnMount comes from a ref an Effect flips after the first commit, so it is false on every later render — including in the stable popover presentation, where it reaches the Tokenizer's hasAutoFocus and removes data-autofocus from the combobox. An Astryx Dialog keeps its children mounted while closed and focuses only [data-autofocus] after showModal(), so a <PowerSearch hasAutoFocus> in a Dialog that opens later no longer takes focus over the controls before it (in Chromium, with a button before the field: base focuses the combobox, head leaves focus on the button). Track the swap in state adjusted during render, as wasDisabled is, and keep hasAutoFocus constant until a swap actually happens.

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch presentation to PowerSearch, and approval waits on a spec decision: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record and keeps a recorded departure from family:input-fields FR10, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide whether FR10 is amended for this trigger or the trigger stops composing Field. FR17 and FR21 also depend on the Tokenizer requirements in #7977, which are not on main yet. Independently of that decision:

  • Derive the swap-render hasAutoFocus rule from state adjusted during render instead of the Effect-written renderedPresentationRef (PowerSearch.tsx:617-622, react-component-runtime INV4 and INV3).
  • Show "No filters match your search" only for a non-empty query, as FR18 says (PowerSearchBottomSheet.tsx:1852).
  • Split the 8,697-line change: land the record through its own spec pull request and move the StrictMode fix and the formatTokenValue.ts extraction into their own pull requests.

[Automated review]

Comment on lines +617 to +622
const renderedPresentationRef = useRef(isSheetPresentation);
const isSwapRender = renderedPresentationRef.current !== isSheetPresentation;
useEffect(() => {
renderedPresentationRef.current = isSheetPresentation;
}, [isSheetPresentation]);
const autoFocusOnMount = hasAutoFocus && !isSwapRender;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads renderedPresentationRef, written by an Effect, during render to decide the trigger's autoFocus and the Tokenizer's hasAutoFocus. architecture:react-component-runtime INV4 says a ref mutation must not be the sole source for a value later read to produce rendered output, and INV3 asks a new Effect to name the external system it synchronizes. Deriving the swap from state adjusted during render (as wasDisabled is), for example a hasSwapped flag, keeps the intent without the ref.

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, and approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 (with two INV7 owner exceptions) into the current component:PowerSearch record, keeps a recorded departure from family:input-fields FR10, and hosts the steps in BottomSheetSwitcher, which main now deprecates, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide the FR10 and switcher questions. Independently of that decision, these need the author:

  • Split the 8,720-line change: let the record land with #8000, move the StrictMode fix, the formatTokenValue.ts extraction and the story decorator out, and stage the flow.
  • Describe the stacked list-value sheet in DEC-3 and AV5 as an owner exception beyond the 5-element open-layer allowance, and drop "as it displays it" from AR6.
  • Show "No filters match your search" only for a non-empty query, and keep a target for the content search's aria-controls when nothing matches.
  • Add the writing-rubric results to the description and update the passages that still describe removed behavior.

[Automated review]

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 is recorded under the
open-layer allowance (`spec:AST-017/FR60`, `DEC-29`) as this record's INV7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spec:AST-017 FR60 and DEC-29 allow an open layer at most 5 extra elements under touch ("Anything beyond this needs an owner exception with before and after counts"). By this record's own counts the stacked state is 67 over the open popover (401 against 334), so it is an owner exception beyond the allowance rather than something recorded under it; could DEC-3 and AV5 say so?

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; each presentation announces a removed filter as it displays it: the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The popover presentation announces the Tokenizer item's label, which formatFilterValue builds (PowerSearch.tsx:841: a date with its time of day in timezoneID, a number with its units), not the token's visible text, and the first known gap below says the two differ in four places. "As it displays it" holds for the sheet only; could this say the popover announces its item label?

// root styling props is a stable API change for existing callers, so it waits
// for its own decision rather than riding the experimental option
// (`component:PowerSearch` Open questions).
const PENDING_FR10: ReadonlySet<string> = new Set<string>(['PowerSearch']);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This keeps the family coverage test green while the trigger composes <Field> with ref on the root and root xstyle / className / style in the props type, which current family:input-fields FR10 does not allow for any component that composes Field. The exemption should follow an engineering owner's decision on FR10 rather than precede it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, and approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 (with two INV7 owner exceptions) into the current component:PowerSearch record and keeps a recorded departure from family:input-fields FR10, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide the FR10 question. Independently of that decision, these need the author:

  • Split the 8,800-line change: let the record land with #8000, move the StrictMode fix and the formatTokenValue.ts extraction out, and stage the flow.
  • Update the description and the test file header where they still say nested-only fields are left out and nested rows are plain, and where the i18n list names the removed editFilter and selectedChoice keys.

[Automated review]

* sheet, Add filter, the operator step, each value type (list values
* through the Tokenizer's sheet), content search, focus after each step,
* disabled while open, the StrictMode-safe result count, onChange metadata,
* nested exclusion

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This header still lists "nested exclusion", which this head removed: nested-only fields now list in Add filter and their steps use the built-in nested editor. The description has the same outdated passages.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, and approval waits on an engineering owner. The head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6, with two INV7 owner exceptions, into the current component:PowerSearch record, and keeps recorded departures from family:input-fields FR10 and from the deprecated BottomSheetSwitcher. An engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Independently of that decision, these need the author:

  • Order Add filter's field groups with the ungrouped fields first, as FR13 says and the popover's field list already does (PowerSearchBottomSheet.tsx:1012-1023), and add a test whose first field is grouped.
  • Split the 8,800-line change: let the record land with #8000, move the StrictMode fix and the formatTokenValue.ts extraction into their own pull requests, and stage the flow.
  • Update the description where its Evidence list still names a nested-only exclusion test and where its catalog-key counts disagree with the 24 keys en.json adds.

[Automated review]

Comment on lines +1012 to +1023
const groupedFields = useMemo(() => {
const groups = new Map<string, PowerSearchField[]>();
for (const field of matchingFields) {
const key = field.group ?? '';
const existing = groups.get(key);
if (existing) {
existing.push(field);
} else {
groups.set(key, [field]);
}
}
return [...groups.entries()];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add filter's groups follow insertion order here, so a config whose first field is grouped lists that group above the ungrouped fields. FR13 at this head says "ungrouped first", and the popover's field list already orders them that way through groupItems(items, {ungroupedFirst: true}) in usePowerSearchSource.ts. Reusing that ordering (or putting the '' group first) keeps the two presentations of one config consistent; a test with a grouped first field would cover it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an opt-in touch flow for PowerSearch, and approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, with two INV7 owner exceptions and a recorded departure from family:input-fields FR10, so an engineering owner needs to approve or amend them by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide whether FR10 is amended for this trigger or the trigger stops composing Field. The previous review's Add filter group-order finding is fixed, and FR10 now records the layer runtime's missing presentation hold. Independently of that decision, the 8,856-line change still needs splitting: let the record land through #8000, and move the StrictMode fix and the formatTokenValue.ts extraction into their own pull requests.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The touch sheet flow now matches the component:PowerSearch DEC-3 contract that merged on main, and its tests, typecheck and browser checks hold up; the remaining blocker is size. At 8,480 authored lines the change needs to be broken up, and parts of it are separable: please land the StrictMode result-count [fix] (with its test and Changeset) and the formatTokenValue.ts extraction ahead of the flow, then put the sheet presentation up as a stack that lands together, for example the trigger, latch and Filters sheet, then Add filter with the content search, then the value step with list and nested values.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The touch sheet flow now matches the component:PowerSearch DEC-3 contract on main, with standalone step sheets, and its tests, typecheck and browser checks hold up; the remaining blocker is size. At 8,521 authored lines the change needs to be broken up: please land the StrictMode result-count [fix] (with its test and Changeset) and the formatTokenValue.ts extraction ahead of the flow, then put the sheet presentation up as a stack that lands together. While the record is being edited, please also refresh the AV5 and DEC-3 element counts, which were measured with the switcher host.

[Automated review]

@@ -218,8 +216,7 @@ FR10–FR21 govern.
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
deprecated switcher's replacement; it may compose List, RadioList, TextInput, Button, Heading, and Text in any

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the switcher allowance gone, the counts a few lines below (296 Filters, 296 Add filter, 318, 328 and 401 stacked) and DEC-3's "adds 73 above it" still describe the switcher host. On this head Chromium measures 290, 290, 307, 322 and 393 (+71) on the Experimental Adaptive story at 390 × 844, so every relation holds; please re-measure and update the figures in this upkeep commit, as DEC-3 asks ("The counts are re-measured when either surface changes").

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The touch sheet flow follows the current component:PowerSearch record and holds up in the tests, the typecheck and a phone-sized Chromium run, but two things need changes before it can be approved. At 8,555 authored lines it needs splitting: please land the StrictMode result-count [fix] (with its test and Changeset) and the formatTokenValue.ts extraction first, then put the sheet presentation up as a stack that lands together. Separately, the Filters rows are keyed by position (PowerSearchBottomSheet.tsx:1519), so when the parent removes a filter above the focused row, focus jumps to the next filter or the focused row silently shows a different one; keying rows by filter identity, with a test, fixes it.

[Automated review]

'@astryxdesign/core': patch
---

[fix] PowerSearch no longer announces its initial `resultCount` on mount under React StrictMode. The result-count live-region effect guarded its first run with a mounted flag that StrictMode's effect replay reset, so the count present on mount was spoken unprompted; the guard now compares against the last announced text, so only a change after mount is announced in every render mode.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This [fix] and its popover StrictMode test change the stable presentation and stand on their own (the record says it "ships as a [fix] patch release of its own"); landing them ahead of the sheet flow is the first step of the split this review asks for.

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

PowerSearch (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 640B 640B 0B
Lines of Code 3948 6380 -
Complexity Very High Very High (731) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/Field 44.4KB 44.7KB +309B 8.0KB 8.0KB 0B
core/FieldLabel 39.9KB 40.2KB +317B 6.5KB 6.5KB 0B
core/PowerSearch 158.1KB 161.5KB +3.4KB ⚠️ 19.9KB 20.6KB +686B
lab/MobilePowerSearch N/A 166.1KB N/A N/A 1.4KB N/A

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

⚠️ Touch-only size budget

These components grew past the touch-only budget (2 KB gzip JavaScript or 1 KB gzip CSS per component). If the growth runs only on a coarse pointer, load its JavaScript lazily so the import does not carry it, or justify the cost in the pull request description; CSS stays in the package stylesheet, so justify it.

Component JavaScript CSS
core/PowerSearch +3.4KB ⚠️ +686B

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 34.6KB 34.6KB +37B

Accessibility Audit

Status: 1 accessibility violation(s) found — 1 serious.

PowerSearch - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 2/35 stories · Learn more
    • WCAG: 1.4.3 (Level AA)

Visual Regression

55 of 502 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 35 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The touch sheet flow follows the current component:PowerSearch DEC-3 contract, but two things need changes before it can be approved. At 8,512 authored lines it needs splitting: land the formatTokenValue.ts extraction on its own, then put the sheet flow up as a stack that lands together (for example the trigger, latch and Filters sheet; Add filter with the content search; the value step), each with its tests. Separately, the Filters rows are keyed by position (PowerSearchBottomSheet.tsx:1519), so when the parent reorders the filters or removes one above the focused row, focus moves to a different filter; keying rows by filter identity, with tests for both cases, fixes it.

[Automated review]

filter,
index,
isEditable,
key: `${index}-${filter.field}-${filter.operator}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keying rows by ${index}-${filter.field}-${filter.operator} remounts the focused row when the parent reorders the filters or removes one above it, and the recovery effect then focuses the row at the old index: with focus on "Author is Ada" in [Status, Author, Priority], dropping Status lands focus on "Priority is P1". Keying each row by filter identity (a per-object id from a WeakMap, falling back to filterSignature for clones), with tests for a reorder and a removal above the focused row, keeps focus on the filter the person was on.

…tion

`experimental={{presentation: 'bottom-sheet' | 'adaptive'}}` makes the whole
field one trigger with display-only filter tokens and moves filter editing
into a flow of tall modal sheets: Filters (rows, Add filter, Done, Clear all),
Add filter (grouped fields or the content search with inline suggestions),
and a value step that stages field → operator → value and confirms from its
footer. The flow loads on demand; `adaptive` resolves through the shared
compact-touch policy and stays latched until the flow's exit has returned
focus. Value editors are Core's own in a sheet presentation; list values edit
through the Tokenizer's sheet titled with the field label. Announcements made
while a sheet is open go through the open dialog's own live region.

Implements `component:PowerSearch` DEC-3 (FR10–FR21, AR4–AR6, PR5) and
deletes the record's implementation-gap notes.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommending approval: the touch sheet flow matches the current component:PowerSearch contract (DEC-3, FR10–FR21), the stable popover presentation is unchanged, and the focused tests, typecheck and a phone-sized Chromium run all hold up. The size is accepted here because the record defines the sheet presentation as one complete flow, so it cannot land in parts without shipping an option that breaks its own requirements. The two earlier blockers are resolved: the StrictMode result-count fix is already on main, and Filters rows are now keyed by filter identity, with tests. What remains is the repository's spec-owner approval for the small record update.

[Automated review]

This branch was successfully deployed

1 active deployment
Preview — 957b9133 Deployed Oct 27, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant