Skip to content

docs(spec): PowerSearch's sheet presentation follows the Lab touch flow (DEC-3) - #9631

Merged
imdreamrunner merged 1 commit into
mainfrom
spec/powersearch-sheet-lab-ux
Nov 3, 2026
Merged

imdreamrunner merged 1 commit into
mainfrom
spec/powersearch-sheet-lab-ux

Conversation

@imdreamrunner

@imdreamrunner imdreamrunner commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Record only. The owner named Lab MobilePowerSearch's current touch flow as the target for Core's experimental sheet presentation. Core has no sheet presentation on main, so every sheet row keeps its implementation-gap note. The note is lifted when Core's sheet ships.

Rebased onto current main (5f44b1c14). FR22 (#9648) and #9676's popover Clear-all rows (FR23, DEC-4) are kept as merged. The sheet's Clear all matches FR23 and cites it, and the Clear-all part of the known gap is gone. The date lines follow the owner's rulings, which are in flight for the popover presentation: #9655 (a date-only value is one day in one zone) and #9702 (isDateOnly: false is an instant with a time of day). This record cites those by behavior, not by claim id, and a known gap says where the popover on main still differs.

Exact decision needed

Which flow Core PowerSearch's experimental.presentation sheet surface specifies. The current record describes the first Core draft's flow: steps that replace one another in place with a Back action on Add filter, Remove in the value step's footer, Clear all that removes every filter and closes the flow, the Add filter search focused on arrival, and date editors on the UTC day. Lab's MobilePowerSearch has since moved on (its "refine touch filter flow" change and the keyboard and confirmation pieces shared with MobileTokenizer), and the owner has decided its current flow is what Core should ship.

Existing authority searched

  • component:PowerSearch (docs/specs/components/PowerSearch/PowerSearch.spec.md, current): DEC-3 and FR10–FR21, AR4–AR6, PR5, AV5–AV6 own the sheet presentation; this PR changes those claims and no popover-presentation claim (FR1–FR9, DEC-1, DEC-2).
  • component:BottomSheet (current): FR5–FR9 own the stack — open order, covered sheets receded and blocked, dismissal at the top sheet, a nested sheet presented only while its host is open, focus returning into the revealed sheet — which the new FR15a leans on rather than restating; FR10's same-update replace is used only when a direct commit from Add filter, entered directly, closes Add filter alone as Filters opens.
  • component:BottomSheetSwitcher DEP-0015: the deprecated predecessor; the record's last mention of it as a fallback host is removed.
  • component:Tokenizer DEC-3, FR16–FR24: the list-value sheet the value step opens, now one more sheet of the same stack (FR21).
  • spec:AST-017 FR60 / DEC-29 (open-layer allowance), spec:AST-020 FR15 (arrow keys, the existing exception kept), spec:AST-085 FR1/FR2/FR4 (disabled trigger), spec:AST-086 FR3/FR4/FR14 (in-modal announcement route).
  • Overlapping open work: the Core implementation PR (feat(core): PowerSearch touch sheet flow behind experimental.presentation #8001). Its current head implements the earlier flow (replaced steps, the Back action, the footer Remove), and it is being reworked to carry this flow on top of this record. The in-flight popover fixes fix(PowerSearch): Clear all keeps read-only filters #9676, fix(core): PowerSearch reads a date-only value as one day in one zone, and edits it there #9655 and fix(core): PowerSearch gives isDateOnly: false the time of day it names #9702 carry the same rulings for the popover presentation. The merged Lab changes are the evidence, not authority.

No current claim settles which of the two flows Core specifies; DEC-3 did, and this PR revises DEC-3.

Decision

The sheet presentation follows the Lab touch flow of MobilePowerSearch. The record states it as Core's contract, and DEC-3 lists eight places where Core's contract differs from the prototype, as "including", so the list does not claim to be complete:

  • a date-only value with no timezoneID is its UTC day, where the prototype reads the viewer's zone;
  • nested-only fields are listed;
  • strings come from the catalog;
  • Add filter's empty results use Core's titles;
  • Clear all says "All filters removed" when no read-only filter remains;
  • a one-tap empty filter added from its field row is announced;
  • an Editor override receives only what the popover passes it (the prototype also passes timezoneID);
  • nothing of the flow mounts before a press.

The decision itself:

  • Stacked, retained steps; no Back (new FR15a; FR13, FR14, FR21). Each step opens above the sheet that led to it, which stays open beneath as a covered sheet of the stack. A dismissal — Escape, scrim, swipe, platform close — closes only the top sheet and reveals the one beneath; dismissing the bottom sheet closes the flow. Cancel is that dismissal. An edit's commit reveals Filters. A creation's commit closes Add filter and the value step together and shows Filters, as Lab does. On a direct entry, Filters opens in the same update. When Add filter alone closes (a direct commit from its row or suggestion), that is BottomSheet's same-update replace (FR10). When the value step closes with it, that is the stack's ordinary closes and open (FR5–FR9). Filters opens as other sheets close only when a creation ends after a direct entry. No sheet carries a Back action; Add filter entered directly carries Done in its footer.
  • Fixed header and footer (FR12–FR14). Headers (heading, Add filter, the search field) and footers (Done and Clear all; Cancel and the confirm action) stay fixed while only the rows scroll; the scroll viewport reaches the sheet edge.
  • Trailing remove actions (FR12, FR15, FR16, AR5). Every removable Filters row has a trailing remove button named "Remove {field label} filter", at the medium size with a hit area of at least 44 px, that commits remove without opening the step; a one-tap boolean (empty-valued) filter's row removes on tap and is added from its field row with no value step; the value step has no Remove. After a removal, focus lands on the remove action of the nearest remaining row that has one, passing over read-only rows, else on Add filter. After a creation commits from the value step, focus lands on the Filters header's Add filter. A direct add, from a suggestion that carries a value or a one-tap empty field, focuses the new row. All of this is as Lab does.
  • Add filter lists and empty results (FR13, AR6, representative states). The ungrouped fields come first under a "General" heading, as in Lab, and then each group in order of first appearance. Ordering and grouping follow the same rule as before; the heading is new. No current record governs headings for the Add filter field list. AST-092 FR2 orders typeahead rows, ungrouped first, and this list keeps that order. A search that matches nothing shows a compact EmptyState: a search icon, Core's title ("No filters match your search" or "No results found") and the description "Try a different search." It is mounted only while Add filter is the open sheet and is announced from its rendered text. The value step's "This filter is no longer available" stays plain text.
  • Touch targets (FR12, AR5). The trailing remove action and the header's Add filter keep their medium visible size. They meet the 44 px minimum through their hit area, not their visible size.
  • Unfocused search (FR13, FR16). Add filter's search field takes no focus on open; a vertical touch drag on the rows blurs a focused search so the keyboard leaves.
  • Editor surfaces (FR14). A lone editor sits on the sheet surface; with an Operator group each section is a muted container. Date-only values and ranges edit on inline Calendars, as the day in timezoneID, else the UTC day. A value whose operator sets isDateOnly: false edits on a touch-sized date-and-time input, as wall-clock time in timezoneID, else the viewer's zone (FR17).
  • Empty state (FR12). With no filter the Filters body is one empty state whose centered primary Add filter is the sheet's only Add filter.
  • Clear all (FR12, FR16, AR6). Behind a confirmation ("Clear all filters?", "Clear filters" / "Keep filters"); confirming commits onChange(kept, 'remove', firstRemovedIndex) keeping read-only filters, leaves Filters open with focus on its first control, and announces inside the sheet once the confirmation closes. The words are "Editable filters removed" while a read-only filter remains, else "All filters removed", the popover presentation's words, with the result count when it changes.
  • Kept as they were: rows and tokens keyed by filter identity, stale filters listed by raw keys, the focus-recovery rules (now including removal and the revealed-step case), and announcements delivered inside the open sheet only while that sheet is the open one (FR18, AR6).
  • Dates and tokens (FR11, FR12, FR17): tokens and rows read a date_absolute value as the sheet's editors do. A date-only value is its day in timezoneID, else the UTC day. A value whose operator sets isDateOnly: false is its date and time in timezoneID, else the viewer's zone.
  • Clear all and dates in the sheet; the popover is a separate stable change (DEC-3, Compatibility and migration). DEC-3 states the sheet presentation's Clear all and date behavior: read-only filters kept, remove reported at the first removed index, one zone for a date-only value, and the viewer's zone as the fallback for a value with a time. The owner ruled the same for the popover presentation. That change alters what the stable presentation reports through onChange, so this experimental record does not make it (spec:AST-017/FR22). A Compatibility bullet says so, and the in-flight popover fixes classify it under FR5. The Open questions known gap lists where the popover presentation on main still differs: its clear control, its date editor and token, and its isDateOnly: false editor. The two decided open questions (Clear all and read-only filters; a date-and-time editor) are removed. So is the known gap about dates the sheet inherited from the popover, which the rulings replace.
  • The presentation stays an unexposed experimental option; nothing stable changes.

Rejected alternatives

  • Steps replacing one another in place with Back actions (the first Core draft and the earlier Lab flow): a dismissal closed the whole flow from any step, and the person re-entered Filters by its entry point instead of finding it where they left it. Recorded in DEC-3.
  • Remove in the value step's footer: a second removal path reached only by opening the step; the row's trailing action removes in one tap. Recorded in DEC-3.
  • Focusing Add filter's search on open: the software keyboard covered the rows the person came to read. Recorded in DEC-3.
  • Keeping the earlier rulings that Clear all removes read-only filters and that sheet date editors read the UTC day: replaced by the owner's rulings for both presentations. DEC-3 states those rulings, and the popover's remaining differences on main are a known gap in Open questions.

Claim scope and non-goals

Only the sheet presentation's rows (FR10–FR21, FR15a, AR4–AR6, PR5, AV5–AV6, the representative states, DEC-3) and the derived gap. The popover presentation's clear control, date editors and token are not changed here. The in-flight popover fixes change them, and this record lists their difference on main as a known gap. Once those fixes merge, the gap's sentences go; the sheet strings' catalog keys (FR20) are unchanged; no theming target, no new prop, no change to adaptive's resolution (FR10). For the INV7 layer exception, DEC-3 now records element counts, as spec:AST-017/FR60 asks:

  • the bound: the popover presentation's open editor, 334 under a fine pointer (166 at rest);
  • the prototype's Touch flow story under a coarse pointer, measured in Chromium at 390×844 with touch emulation: 338 at rest, 339 with Filters open, 340 with Add filter above Filters, 366 with a choice-row value step above both, 429 with a list-value step above both, and 413 with an edit step above Filters. The prototype keeps its sheets mounted from the first render, so its rest count already carries them.

Core's own counts are recorded with its implementation.

Evidence and implementation relationship

  • Lab MobilePowerSearch on main (packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx, its test suite, now listed in verified_by as the flow's current evidence) implements the flow the record states, except the differences DEC-3 lists. The record was written against that source, not from memory.
  • Core BottomSheet's stack behavior the flow relies on is current on main (FR5–FR9, the nested-sheet drag fix and the replace-alignment fix).
  • No Core implementation of this flow exists on main. The Core implementation PR (feat(core): PowerSearch touch sheet flow behind experimental.presentation #8001) is being reworked to implement it, and it lifts these gap notes when it ships.

Changes in this head (fifth review round)

  • FR16: focus goes to the Filters sheet's first remaining control only after a confirmed Clear all. Keep filters, Escape or a scrim tap returns focus to Clear all, which component:Dialog AR2 also requires.
  • AR5: the Clear all confirmation is an alertdialog that focuses Keep filters when it opens, as Lab's does, so a stray Enter keeps the filters.
  • The internationalization known gap had a doubled list marker and a stray ,;; both are fixed. Its date exception now says "timezoneID or its fallback zone".

Changes in the previous head (fourth review round)

  • A caller's Editor override that calls onSave(null) while editing is now named in AR6, which announces Removed {label}, and in FR16's removal focus rule.
  • DEC-3's list of differences from the prototype now reads "include these eight". It adds that an Editor override receives only what the popover presentation passes it, while the prototype also passes timezoneID.
  • The internationalization known gap no longer says the trigger shows the popover token "as it is". It shows the token's text, except a date, which FR11 reads in timezoneID.
  • Optional cleanups: the INV7 exception says the Clear all confirmation covers Filters the same way a covered sheet does, and the verification row's repeated "chevrons" phrase is fixed.

Changes in the previous head (third review round, rebased onto main after #9676)

  • The rebase keeps fix(PowerSearch): Clear all keeps read-only filters #9676's FR23 rows, DEC-4, its Compatibility bullet and the "FR1–FR9, FR22, and FR23" line. FR12 and DEC-3 now cite FR23/DEC-4 for the sheet's Clear all. The known gap and the Compatibility bullet keep only the date differences.
  • AV5 bounds only the states with one sheet open by the popover editor's count. Each sheet open beneath the top one is left to DEC-3's owner exception and its recorded counts.
  • The description's Decision bullet and authority list now match FR15a's use of FR10: only when Add filter alone closes.

Changes in the previous head (second review round)

  • FR15a no longer files every direct-entry creation under FR10. A direct commit from Add filter closes one sheet and opens Filters, which is BottomSheet's same-update replace (FR10). A commit from the value step closes Add filter and the value step together, which is the stack's ordinary closes and open (FR5–FR9). A creating Editor override's onSave(null) (FR14) shows Filters the same way. The closing sentence of FR15a and AV5 now say so.
  • DEC-3 lists a seventh difference from the prototype: AR6's Added {label} for a one-tap empty field row. The PR title no longer says "as it ships", and the description no longer says Lab implements every behavior.
  • The spec:AST-086/FR14 known gap is removed, because it is closed on main: useAnnounce delivers inside the enclosing Astryx modal, and Dialog and the modal Drawer provide that route. FR19's gap is reworded to what holds on main: the closed field has no mount announcement under StrictMode and routes into its modal, and no sheet exists to carry the in-sheet route. Its old text said the guard still announced under StrictMode, which is fixed on main.

Changes in the previous head (first review round)

  • FR15a defines what a commit closes. An edit's commit reveals Filters. A creation's commit closes Add filter and the value step together and shows Filters, opening it in the same update on a direct entry. "No step replaces another" is now limited to dismissals. FR13, FR15 and AV5 follow FR15a.
  • FR16, as Lab does:
    • a creation from the value step focuses the Filters header's Add filter;
    • a direct add focuses the new row;
    • a removal focuses the nearest remaining row's remove action, passing over read-only rows, else Add filter.
  • Verification row: the failure signal is now "Clear all removes a read-only filter". The scenario "a nested filter removed from its step" is gone, and the nested-row case focuses that row's remove action.
  • DEC-3 states the sheet's Clear all and date behavior only. A Compatibility bullet keeps the popover change outside this experimental option (spec:AST-017/FR22). The known gap no longer says that the popover "follows the rulings".
  • DEC-3 no longer says "as MobilePowerSearch ships it". It lists Core's six differences from the prototype.
  • The INV7 layer exception records the prototype's measured counts and the popover bound (spec:AST-017/FR60). Core's own counts are recorded with its implementation.
  • FR12's basis no longer cites the popover's clear semantics. FR17 now says four sheet-native differences, and it lists four.

Writing rubric results

  • R1 narration: one passage told history: "the Lab prototype … this record states it as Core's contract" in Intent. It is kept as a present-tense statement of what the record covers. DEC-3's rulings paragraph states today's rulings, with the decider date, and does not tell their history. The comparison with the earlier flow lives in this description, not the record.
  • R2 research in the record: none; the Lab change history and the delta against the earlier Core draft stay in this description.
  • R3 evidence naming a change: none added; the verification row names the Lab suite as the current evidence layer and the gap that Core's own is not in the tree.
  • R4 rejected alternatives over one line or outside a decision: three new ones, each one line inside DEC-3; the reasoning is here.
  • R5 pull request or issue references remaining: none (searched # + digits, /pull/, /issues/).
  • R6 ruled open questions still listing options: none. The two questions the rulings settled are removed. The known gap states the popover's behavior on main beside the rulings.

Scope

  • This PR contains specification records only.
  • It records observable behavior and preserves equivalent internal implementations (AV5 names the stack as component:BottomSheet's and leaves the composition free).
  • Any named internal mechanism is intentionally public, names its dependent caller/system, and explains why an equivalent implementation would not satisfy the contract: the native modal dialogs and the stack's covered-sheet behavior, owned by component:BottomSheet, because dismissal, focus return and the announcement route depend on them.
  • Verification states the evidence layer and failure signal without prescribing CI job/workflow topology.
  • It records one intentional durable decision — which flow the sheet presentation is — not a rescue for a separable tagalong.
  • The canonical knowledge owner (component:PowerSearch DEC-3), affected current claims and overlapping open work are named; the one collision is called out: the Core implementation PR, which now rebases onto this record.
  • Adjacent API, visual, accessibility, composition, and implementation facts remain outside scope unless this exact decision depends on them.
  • Public text and artifacts contain no internal Meta context.

Validation

  • pnpm check:knowledge — "Knowledge templates and records are aligned."
  • Prettier (the repository's pinned 3.9.4) — clean.
  • git diff --check — clean.

@vercel

vercel Bot commented Nov 2, 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 Nov 3, 2026 8:21am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 2, 2026
@astracat-bot

astracat-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 3, 2026, 8:29 AM UTC

@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 2, 2026

@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 spec change rewrites the current component:PowerSearch sheet flow (DEC-3, FR12–FR17, AV5). Approval waits on an engineering owner recording the decision by commenting /approve-spec <full head SHA>, and as an engineering owner you may record it yourself. Separately from that decision, a few contradictions inside the record need fixing:

  • FR15 and FR15a say a commit closes only the value step and reveals Filters, while FR16 closes the value step and Add filter together, and when Add filter was the entry point there is no Filters beneath to reveal.
  • The verification row's failure signal still treats "Clear all skips a read-only filter" as a failure, which FR12 now requires, and still lists a removal from the value step.
  • AR6 announces "All filters removed" while read-only filters stay.
  • DEC-3 widens the INV7 layer-budget exception without the measured element counts INV7 asks for, and the open questions and the date known gap still describe the old Clear all and UTC-day behaviour.

[Automated review]

| FR13 | Add filter — from the Filters sheet's Add filter action, or directly when an interactive field with no filter opens — is a tall modal sheet named "Add filter" opened above the Filters sheet, which stays open beneath it (FR15a), so the sheet has no Back action: dismissing it — Escape, a scrim tap, a swipe — reveals Filters again, or closes the flow when Add filter was the entry point, in which case its fixed footer holds Done. Its header — the heading and, when one shows, the search field — stays fixed while the rows scroll. 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, a trailing chevron — or, for a field whose only operator takes an `empty` value, a trailing plus, since choosing it adds the filter at once (FR15) — 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`, with the same suggestions the popover presentation's typeahead offers listed inline as row buttons ("Search results"): fields, field-and-operator pairs, value matches, and a leading free-text item for the content field; Enter commits the first suggestion, ArrowDown and ArrowUp move between the search and the rows — arrow movement across independently focusable controls, recorded here as this record's exception to `spec:AST-020/FR15`, which adopts arrow keys for a single composite only: the searchbox-to-list continuity is what the popover presentation's combobox gives the same suggestions, and the rows stay in the Tab order as well — an IME-composing Enter does nothing, and a query with no suggestion shows "No results found" — the searchbox's `aria-controls` names the results list, or that message while nothing lists, so it always names an element; the field list's "No filters match your search" likewise shows only for a non-empty query that matches nothing, never for a config with no field. Neither search field takes focus when the sheet opens: the sheet opens with focus on its panel and the software keyboard stays down until the person taps the search; a direct vertical touch drag on the rows while a search field has focus blurs it, so the keyboard leaves and the rows can be read. A suggestion that carries a value commits `onChange([...filters, filter], 'add', filters.length)`, announces `Added {label}` inside the Filters sheet (AR6), and returns to it; any other suggestion or field row opens the value step with that field and operator, above Add filter. Every visible field lists, a field whose every operator is `nested` included: the value step edits every operator's value (FR14). | DEC-3; Lab touch-flow evidence; current typeahead source | Current contract. Implementation gap: no Add filter sheet exists. |
| FR14 | The value step is a tall modal sheet that edits every operator's value, opened above the sheet that led to it — Filters for an edit, Add filter for a creation — which stays open beneath it (FR15a); its title is the dialog's name (AR5), and for a filter whose field or operator the config no longer has it reads as the Filters row does (FR12): the field's label when the config still has the field, the raw key for whatever is gone. Its header and footer stay fixed while the body scrolls. 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 body's sections sit on the sheet's surface when the editor is the body's only section; when the body has more than one section — the Operator group above the editor — each section is a muted container, so the grouping reads without a frame around a lone editor. 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; the step has no Remove action: a filter is removed from its Filters row (FR12). 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 while the step is the open one (AR6), and keeps Cancel as the way out, the filter's trailing remove action on its Filters row being the removal path (FR12); when focus was on a control of the replaced body it moves to Cancel, so it never falls to the page body inside the open sheet (FR16). | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no value step exists. |
| FR15 | Confirming a created filter commits `onChange([...filters, filter], 'add', filters.length)`; choosing a field whose only operator takes an `empty` value commits that filter the same way from the Add filter row, with no value step. Tapping an editable Filters row opens the value step on that filter in edit mode; Apply commits `onChange(updated, 'edit', index)`; the row's trailing remove action, or a tap on an `empty`-valued filter's row, commits `onChange(rest, 'remove', index)`, where `index` is the filter's position at commit time: a controlled parent that reorders or clones its filters while the step is open is followed by object identity, then by a structurally equal filter in the original slot, then by a unique structural match; when the target is gone or ambiguous nothing is committed and the step closes back to Filters, focus landing as FR16 states for a row that no longer exists. Every commit from a step closes that step, revealing Filters. While the field is read-only, or disabled, no commit happens. | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no edit path exists. |
| FR15a | The steps stack: each step opens above the sheet that led to it, which stays open — receded and blocked as a covered sheet of the stack (`component:BottomSheet` FR5, FR6) — so the stack is Filters alone; Filters under Add filter; Filters under the value step for an edit; Filters under Add filter under the value step for a creation; or, when Add filter was the entry point, Add filter alone and then Add filter under the value step. No sheet of the flow carries a Back action: a dismissal — Escape, a scrim tap, a swipe, a platform close request — reaches only the top sheet (`component:BottomSheet` FR7), closes it, and reveals the sheet beneath, which takes focus back as FR16 states; dismissing the bottom sheet of the stack closes the flow. Cancel on the value step is that dismissal; a commit closes the step the same way. The flow therefore has one direction forward (open a step above) and one back (close the top step), and a step never replaces another in place. | DEC-3; `component:BottomSheet` FR5–FR9 | Current contract. Implementation gap: no flow exists. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"a commit closes the step the same way" closes only the value step, which reveals Add filter during a creation. FR16 (next row) closes the value step and Add filter together, and when Add filter was the entry point this stack has no Filters to reveal. Could this say that a creation's commit closes both and shows Filters, opening it on direct entry?

| FR14 | The value step is a tall modal sheet that edits every operator's value, opened above the sheet that led to it — Filters for an edit, Add filter for a creation — which stays open beneath it (FR15a); its title is the dialog's name (AR5), and for a filter whose field or operator the config no longer has it reads as the Filters row does (FR12): the field's label when the config still has the field, the raw key for whatever is gone. Its header and footer stay fixed while the body scrolls. 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 body's sections sit on the sheet's surface when the editor is the body's only section; when the body has more than one section — the Operator group above the editor — each section is a muted container, so the grouping reads without a frame around a lone editor. 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; the step has no Remove action: a filter is removed from its Filters row (FR12). 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 while the step is the open one (AR6), and keeps Cancel as the way out, the filter's trailing remove action on its Filters row being the removal path (FR12); when focus was on a control of the replaced body it moves to Cancel, so it never falls to the page body inside the open sheet (FR16). | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no value step exists. |
| FR15 | Confirming a created filter commits `onChange([...filters, filter], 'add', filters.length)`; choosing a field whose only operator takes an `empty` value commits that filter the same way from the Add filter row, with no value step. Tapping an editable Filters row opens the value step on that filter in edit mode; Apply commits `onChange(updated, 'edit', index)`; the row's trailing remove action, or a tap on an `empty`-valued filter's row, commits `onChange(rest, 'remove', index)`, where `index` is the filter's position at commit time: a controlled parent that reorders or clones its filters while the step is open is followed by object identity, then by a structurally equal filter in the original slot, then by a unique structural match; when the target is gone or ambiguous nothing is committed and the step closes back to Filters, focus landing as FR16 states for a row that no longer exists. Every commit from a step closes that step, revealing Filters. While the field is read-only, or disabled, no commit happens. | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no edit path exists. |
| FR15a | The steps stack: each step opens above the sheet that led to it, which stays open — receded and blocked as a covered sheet of the stack (`component:BottomSheet` FR5, FR6) — so the stack is Filters alone; Filters under Add filter; Filters under the value step for an edit; Filters under Add filter under the value step for a creation; or, when Add filter was the entry point, Add filter alone and then Add filter under the value step. No sheet of the flow carries a Back action: a dismissal — Escape, a scrim tap, a swipe, a platform close request — reaches only the top sheet (`component:BottomSheet` FR7), closes it, and reveals the sheet beneath, which takes focus back as FR16 states; dismissing the bottom sheet of the stack closes the flow. Cancel on the value step is that dismissal; a commit closes the step the same way. The flow therefore has one direction forward (open a step above) and one back (close the top step), and a step never replaces another in place. | DEC-3; `component:BottomSheet` FR5–FR9 | Current contract. Implementation gap: no flow exists. |
| FR16 | Focus follows the step: after a commit from the value step while creating, the value step and Add filter close and focus lands on the new filter's row in Filters; after a content-search suggestion or an `empty`-valued field row commits a filter directly, that filter's row; after Apply or Cancel from editing, the edited filter's row — followed across a controlled reorder or clone, and, when a controlled update removed it or left two filters it could be, the nearest remaining row that is a button around its former position (the following one on a tie), else Add filter; after a row's trailing remove action, the next remaining row's trailing remove action (the following row, else the previous), so a run of removals needs no re-aiming, else Add filter when no row remains; after Cancel from creation, Add filter is revealed with focus on the field row that opened the step, or on the content search when a suggestion opened it — and when a controlled update has removed that field, or the content-search field itself, the Add filter sheet's first control: its search field when one shows, else its first field row; after Add filter is dismissed, the Filters header's Add filter, or the empty state's; after a caller's `Editor` override calls `onSave(null)` while creating, the same (the flow returns to Filters unchanged, FR14); after Clear all's confirmation closes, the Filters sheet's first remaining control — a kept read-only filter's row has none, so Add filter — with the sheet still open; and, as one rule for the Filters and Add filter sheets, when a controlled update unmounts the control that holds focus — a filter's row or its remove action, a dropped field's row, Clear all gone with the last editable filter — the nearest remaining row that is a button, counted among the button rows by the removed one's index (the following one, else the last) — a plain row (a read-only filter, or every filter while the field is read-only) is passed over — else the sheet's first control (the Add filter sheet's search field, Add filter in the Filters header or empty state), else the sheet's panel, so focus never lands on the page body inside the open sheet; when a controlled update replaces the value step's body with the unavailable state while focus was on one of its controls, Cancel (FR14); after the flow closes, the trigger. A sheet that opens takes focus on its panel, not on a control, except where this row names one; the Add filter sheet's search fields never take it on open (FR13). | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no flow exists. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two focus targets here differ from the Lab flow this record cites. After a creation, Lab focuses Add filter, not the new row (its test "returns to Filters with focus on Add filter once a filter is committed"). After a removal, a read-only row has no remove action, so "else Add filter when no row remains" leaves focus undefined when only read-only rows remain. Lab passes over plain rows and falls back when no editable row remains.

route `spec:AST-086/FR14` names for the field's position then: the modal
that holds the field, or the shared channel when none does. A result count that changes while the flow is
closing (Clear all's commit) waits the same way and the two are spoken as one
it does not. Clear all announces "All filters removed" inside 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.

With read-only filters kept (FR12), "All filters removed" tells a screen-reader user that every filter is gone while some still apply. Lab announces "Editable filters removed".

| FR4–FR6, FR9 | Opening-path tests plus real-Chromium geometry assertions | Add from inner input; edit actual left/right/equidistant token/control; pointer/keyboard; reopen opposite side | Using pointer coordinates, modality, wrapper-only identity, stale identity, or public props fails. | `audit:PowerSearch/interaction` |
| PR1–PR4 | Instrumented unit tests plus first-frame real-Chromium capture | Open, close, reopen opposite side; hidden placement commit; first painted frame | Render-time reads, repeated reads, observers, pointer tracking, or a default-position flash fails. | `audit:PowerSearch/performance` |
| FR8, AR1–AR3 | Existing PowerSearch/editor suites plus focused browser checks | Pointer and keyboard edit, apply, cancel, Escape, light dismiss, focus return, selection, semantics | Focus, semantics, selection, dismissal, or reachability changes fail. | `audit:PowerSearch/accessibility` |
| FR10–FR21, AR4–AR6, PR5 | Focused PowerSearch sheet tests with the compact-touch query stubbed both ways, plus real-engine touch evidence (implementation gap: not yet in the tree; the Lab prototype's suite, `packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx`, is the current evidence for the flow the rows describe) | default popover, forced sheet, adaptive both ways (server markup and hydration as the popover field on a coarse-pointer client, then the trigger, no recoverable error, focus on the body; with `hasAutoFocus`, the autofocused input's focus ending on the trigger with no `onBlur` or `onFocus` to the caller), trigger name, Filters/Add filter/value steps, each value type, list value through the Tokenizer sheet, Clear all, read-only, disabled while open, focus after each step, focus after an `adaptive` switch from the trigger, the input, a removed control, an open editor popover, and from outside the field, a field inside an open Dialog announcing the count, Clear all, and in-sheet messages inside that Dialog and never on the page body, StrictMode result count, in-sheet live region while a sheet is open (added, removed, count, empty results), Clear all announced only after the dialog's exit, row, token and Removed text sharing one formatter, dates as the `timezoneID` day on all three (UTC, Tokyo, Honolulu, unset), summaries and relative-date labels and values from the catalog, the row and Removed through one reorderable message, an unmatched relative-date value as stored, a filter the config no longer knows listed and removable, a cleared number unstaged, choice rows as radios and checkboxes with `aria-checked`, a stale read-only filter as a plain row, a removal next to a nested row focusing that row, a nested filter removed from its step, a list value joined by the locale's narrow conjunction list in English, Simplified Chinese, Japanese, and German, an empty-result message spoken once per query from its rendered element, a controlled update that removes the edited field moving focus from the replaced body to Cancel and announcing the unavailable state in the sheet while the step is open and never from a step beneath another or retained closed, Apply and Cancel after a controlled update removed the edited filter focusing the nearest row, else Add filter, a row's trailing remove action focusing the next row's remove action, a one-tap `empty`-valued filter added from its field row and removed from its row, Clear all behind its confirmation keeping read-only filters and leaving Filters open, a dismissed step revealing the step beneath with focus restored to it, Add filter opening with its search unfocused and a touch drag on the rows blurring a focused search, a stale filter's step titled as its row reads (raw keys for what is gone, the field's label when the field remains), Cancel from creation after the opening field was removed focusing the Add filter sheet's first control, a removed focused Filters row, a removed Clear all, and a dropped focused field row each handing focus to the nearest remaining row or the sheet's first control, element counts at rest and with each of the Filters, Add filter and value sheets open, and with the Tokenizer's list-value sheet stacked above the value step, under fine and coarse pointers, nothing of the flow in the DOM at rest before a press and after the exit, a nested filter edited and created through the built-in nested editor (its sub-filter rows wrapping inside a 390 and a 414 px sheet; its root operator picker absent on a mixed field, the Operator group in its place), a nested-only field and its suggestion offered, a suggestion's operator checked over the field's default, the Operator group outside a caller's Editor override on a mixed field, a count changed during the exit spoken with Clear all's message as one catalog message after the close, a radio group's arrow keys, Home, and End moving and checking, Space and Enter checking a focused radio row and toggling a checkbox row, right-to-left layout with the row chevrons and the rows' chevrons mirrored in RTL (`design:bidirectionality/DR4`) | A field without `experimental` changes beyond what FR19 states, a value commits before the step confirms, Clear all skips a read-only filter, the trigger carries native `disabled`, focus lands elsewhere, an announcement reaches a channel outside the modal that holds the field at that moment, or the popover bundle carries the sheets. | `audit:PowerSearch/sheet` |

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 row still describes the replaced flow. Its failure signal fails "Clear all skips a read-only filter", which FR12 now requires. It also lists "a nested filter removed from its step", although FR14 removes the step's Remove, and "a removal next to a nested row focusing that row", although FR16 now focuses the next row's remove action.

`document.body.querySelectorAll('*').length`, at rest and with each state of
the stack open, under a fine and a coarse pointer, and recorded here when the
Core implementation exists; implementation gap: Core has no sheet flow to
measure, and the Lab prototype's counts stand in until it does. Owner exception to INV7's surface budget, which asks that a

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

INV7 accepts an exception only with the measured element count before and after. This paragraph widens the exception to every stacked state and removes the counts. It also says "the Lab prototype's counts stand in", but no Lab count is recorded, and AV5 still points at DEC-3's "measured counts".

@imdreamrunner
imdreamrunner force-pushed the spec/powersearch-sheet-lab-ux branch from f82f842 to cebf7cb Compare November 3, 2026 05:40
@imdreamrunner
imdreamrunner marked this pull request as ready for review November 3, 2026 05:40
@imdreamrunner

Copy link
Copy Markdown
Contributor Author

/approve-spec cebf7cb

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 3, 2026

@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 engineering-owner approval at this head settles the new sheet flow's direction. A few contradictions inside the record, and with other current records, still need fixing before it can merge:

  • FR15a says a commit closes only the top step, but FR13, FR15 and FR16 return to Filters after a commit, which on direct entry means opening Filters as Add filter closes, a same-update replace under component:BottomSheet FR10 that FR15a and AV5 rule out.
  • FR16 sends focus after a removal to the next row's remove action, which a read-only row does not have, and it focuses the new row after a creation while the Lab test now listed in verified_by expects focus on Add filter.
  • The verification row still fails "Clear all skips a read-only filter", which FR12 now requires, and still lists a removal from the value step, which FR14 no longer has.
  • DEC-3 now rules that the stable popover's Clear all and date editors change their onChange output, but Compatibility and migration still files DEC-3 as experimental only, which spec:AST-017 FR22 does not allow.
  • The description and DEC-3 say the record is Lab MobilePowerSearch as it ships, but Lab falls back to the viewer's zone for date-only values, leaves out nested-only fields, writes its sheet strings in English, and focuses Add filter after a creation.
  • DEC-3 widens the INV7 layer exception to every stacked state without the element counts spec:AST-017 FR60 requires, and the Lab counts it says stand in are not recorded.
  • FR12's basis still cites the popover's current clear semantics, which the row now departs from, and FR17 announces three sheet-native differences but lists four.

[Automated review]

| FR13 | Add filter — from the Filters sheet's Add filter action, or directly when an interactive field with no filter opens — is a tall modal sheet named "Add filter" opened above the Filters sheet, which stays open beneath it (FR15a), so the sheet has no Back action: dismissing it — Escape, a scrim tap, a swipe — reveals Filters again, or closes the flow when Add filter was the entry point, in which case its fixed footer holds Done. Its header — the heading and, when one shows, the search field — stays fixed while the rows scroll. Without a content-search field it lists the visible fields grouped by `group`, each group a named list: the ungrouped fields first, under the heading "General", then each group under its name in order of first appearance, each a row button with the field's icon, a trailing chevron — or, for a field whose only operator takes an `empty` value, a trailing plus, since choosing it adds the filter at once (FR15) — 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 the empty state "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`, with the same suggestions the popover presentation's typeahead offers listed inline as row buttons ("Search results"): fields, field-and-operator pairs, value matches, and a leading free-text item for the content field; Enter commits the first suggestion, ArrowDown and ArrowUp move between the search and the rows — arrow movement across independently focusable controls, recorded here as this record's exception to `spec:AST-020/FR15`, which adopts arrow keys for a single composite only: the searchbox-to-list continuity is what the popover presentation's combobox gives the same suggestions, and the rows stay in the Tab order as well — an IME-composing Enter does nothing, and a query with no suggestion shows the empty state "No results found" — the searchbox's `aria-controls` names the results list, or that message while nothing lists, so it always names an element; the field list's "No filters match your search" likewise shows only for a non-empty query that matches nothing, never for a config with no field. Each of the two is a compact empty state with a search icon, that title, and the description "Try a different search.", mounted only while the Add filter sheet is the open one. Neither search field takes focus when the sheet opens: the sheet opens with focus on its panel and the software keyboard stays down until the person taps the search; a direct vertical touch drag on the rows while a search field has focus blurs it, so the keyboard leaves and the rows can be read. A suggestion that carries a value commits `onChange([...filters, filter], 'add', filters.length)`, announces `Added {label}` inside the Filters sheet (AR6), and returns to it; any other suggestion or field row opens the value step with that field and operator, above Add filter. Every visible field lists, a field whose every operator is `nested` included: the value step edits every operator's value (FR14). | DEC-3; Lab touch-flow evidence; current typeahead source | Current contract. Implementation gap: no Add filter sheet exists. |
| FR14 | The value step is a tall modal sheet that edits every operator's value, opened above the sheet that led to it — Filters for an edit, Add filter for a creation — which stays open beneath it (FR15a); its title is the dialog's name (AR5), and for a filter whose field or operator the config no longer has it reads as the Filters row does (FR12): the field's label when the config still has the field, the raw key for whatever is gone. Its header and footer stay fixed while the body scrolls. 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 body's sections sit on the sheet's surface when the editor is the body's only section; when the body has more than one section — the Operator group above the editor — each section is a muted container, so the grouping reads without a frame around a lone editor. 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; the step has no Remove action: a filter is removed from its Filters row (FR12). 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 while the step is the open one (AR6), and keeps Cancel as the way out, the filter's trailing remove action on its Filters row being the removal path (FR12); when focus was on a control of the replaced body it moves to Cancel, so it never falls to the page body inside the open sheet (FR16). | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no value step exists. |
| FR15 | Confirming a created filter commits `onChange([...filters, filter], 'add', filters.length)`; choosing a field whose only operator takes an `empty` value commits that filter the same way from the Add filter row, with no value step. Tapping an editable Filters row opens the value step on that filter in edit mode; Apply commits `onChange(updated, 'edit', index)`; the row's trailing remove action, or a tap on an `empty`-valued filter's row, commits `onChange(rest, 'remove', index)`, where `index` is the filter's position at commit time: a controlled parent that reorders or clones its filters while the step is open is followed by object identity, then by a structurally equal filter in the original slot, then by a unique structural match; when the target is gone or ambiguous nothing is committed and the step closes back to Filters, focus landing as FR16 states for a row that no longer exists. Every commit from a step closes that step, revealing Filters. While the field is read-only, or disabled, no commit happens. | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no edit path exists. |
| FR15a | The steps stack: each step opens above the sheet that led to it, which stays open — receded and blocked as a covered sheet of the stack (`component:BottomSheet` FR5, FR6) — so the stack is Filters alone; Filters under Add filter; Filters under the value step for an edit; Filters under Add filter under the value step for a creation; or, when Add filter was the entry point, Add filter alone and then Add filter under the value step. No sheet of the flow carries a Back action: a dismissal — Escape, a scrim tap, a swipe, a platform close request — reaches only the top sheet (`component:BottomSheet` FR7), closes it, and reveals the sheet beneath, which takes focus back as FR16 states; dismissing the bottom sheet of the stack closes the flow. Cancel on the value step is that dismissal; a commit closes the step the same way. The flow therefore has one direction forward (open a step above) and one back (close the top step), and a step never replaces another in place. | DEC-3; `component:BottomSheet` FR5–FR9 | Current contract. Implementation gap: no flow exists. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"a commit closes the step the same way" closes only the top sheet, but FR13, FR15 and FR16 return to Filters after a commit. When Add filter was the entry point there is no Filters beneath, so Filters has to open as Add filter closes, and for a suggestion commit that is a same-update replace under component:BottomSheet FR10, which "a step never replaces another in place" rules out. Could this say a creation's commit closes the value step and Add filter together and shows Filters, opening it on direct entry, and limit "never replaces" to dismissals?

| FR14 | The value step is a tall modal sheet that edits every operator's value, opened above the sheet that led to it — Filters for an edit, Add filter for a creation — which stays open beneath it (FR15a); its title is the dialog's name (AR5), and for a filter whose field or operator the config no longer has it reads as the Filters row does (FR12): the field's label when the config still has the field, the raw key for whatever is gone. Its header and footer stay fixed while the body scrolls. 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 body's sections sit on the sheet's surface when the editor is the body's only section; when the body has more than one section — the Operator group above the editor — each section is a muted container, so the grouping reads without a frame around a lone editor. 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; the step has no Remove action: a filter is removed from its Filters row (FR12). 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 while the step is the open one (AR6), and keeps Cancel as the way out, the filter's trailing remove action on its Filters row being the removal path (FR12); when focus was on a control of the replaced body it moves to Cancel, so it never falls to the page body inside the open sheet (FR16). | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no value step exists. |
| FR15 | Confirming a created filter commits `onChange([...filters, filter], 'add', filters.length)`; choosing a field whose only operator takes an `empty` value commits that filter the same way from the Add filter row, with no value step. Tapping an editable Filters row opens the value step on that filter in edit mode; Apply commits `onChange(updated, 'edit', index)`; the row's trailing remove action, or a tap on an `empty`-valued filter's row, commits `onChange(rest, 'remove', index)`, where `index` is the filter's position at commit time: a controlled parent that reorders or clones its filters while the step is open is followed by object identity, then by a structurally equal filter in the original slot, then by a unique structural match; when the target is gone or ambiguous nothing is committed and the step closes back to Filters, focus landing as FR16 states for a row that no longer exists. Every commit from a step closes that step, revealing Filters. While the field is read-only, or disabled, no commit happens. | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no edit path exists. |
| FR15a | The steps stack: each step opens above the sheet that led to it, which stays open — receded and blocked as a covered sheet of the stack (`component:BottomSheet` FR5, FR6) — so the stack is Filters alone; Filters under Add filter; Filters under the value step for an edit; Filters under Add filter under the value step for a creation; or, when Add filter was the entry point, Add filter alone and then Add filter under the value step. No sheet of the flow carries a Back action: a dismissal — Escape, a scrim tap, a swipe, a platform close request — reaches only the top sheet (`component:BottomSheet` FR7), closes it, and reveals the sheet beneath, which takes focus back as FR16 states; dismissing the bottom sheet of the stack closes the flow. Cancel on the value step is that dismissal; a commit closes the step the same way. The flow therefore has one direction forward (open a step above) and one back (close the top step), and a step never replaces another in place. | DEC-3; `component:BottomSheet` FR5–FR9 | Current contract. Implementation gap: no flow exists. |
| FR16 | Focus follows the step: after a commit from the value step while creating, the value step and Add filter close and focus lands on the new filter's row in Filters; after a content-search suggestion or an `empty`-valued field row commits a filter directly, that filter's row; after Apply or Cancel from editing, the edited filter's row — followed across a controlled reorder or clone, and, when a controlled update removed it or left two filters it could be, the nearest remaining row that is a button around its former position (the following one on a tie), else Add filter; after a row's trailing remove action, the next remaining row's trailing remove action (the following row, else the previous), so a run of removals needs no re-aiming, else Add filter when no row remains; after Cancel from creation, Add filter is revealed with focus on the field row that opened the step, or on the content search when a suggestion opened it — and when a controlled update has removed that field, or the content-search field itself, the Add filter sheet's first control: its search field when one shows, else its first field row; after Add filter is dismissed, the Filters header's Add filter, or the empty state's; after a caller's `Editor` override calls `onSave(null)` while creating, the same (the flow returns to Filters unchanged, FR14); after Clear all's confirmation closes, the Filters sheet's first remaining control — a kept read-only filter's row has none, so Add filter — with the sheet still open; and, as one rule for the Filters and Add filter sheets, when a controlled update unmounts the control that holds focus — a filter's row or its remove action, a dropped field's row, Clear all gone with the last editable filter — the nearest remaining row that is a button, counted among the button rows by the removed one's index (the following one, else the last) — a plain row (a read-only filter, or every filter while the field is read-only) is passed over — else the sheet's first control (the Add filter sheet's search field, Add filter in the Filters header or empty state), else the sheet's panel, so focus never lands on the page body inside the open sheet; when a controlled update replaces the value step's body with the unavailable state while focus was on one of its controls, Cancel (FR14); after the flow closes, the trigger. A sheet that opens takes focus on its panel, not on a control, except where this row names one; the Add filter sheet's search fields never take it on open (FR13). | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no flow exists. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A read-only row has no remove action (FR12), so "the next remaining row's trailing remove action … else Add filter when no row remains" leaves focus undefined beside a read-only row or when only read-only rows remain. Lab passes over plain rows and falls back to Add filter when no editable row remains. Separately, after a creation Lab's suite (now in verified_by) expects focus on Add filter, not the new row.

| PR1–PR4 | Instrumented unit tests plus first-frame real-Chromium capture | Open, close, reopen opposite side; hidden placement commit; first painted frame | Render-time reads, repeated reads, observers, pointer tracking, or a default-position flash fails. | `audit:PowerSearch/performance` |
| FR8, AR1–AR3 | Existing PowerSearch/editor suites plus focused browser checks | Pointer and keyboard edit, apply, cancel, Escape, light dismiss, focus return, selection, semantics | Focus, semantics, selection, dismissal, or reachability changes fail. | `audit:PowerSearch/accessibility` |
| FR22 | PowerSearch and Tokenizer unit tests | A read-only field with a filter: no Clear all, no token remove control, Backspace on the empty input | A read-only field removes a filter through Backspace, Clear all, or a token control. | `audit:PowerSearch/interaction` |
| FR10–FR21, AR4–AR6, PR5 | Focused PowerSearch sheet tests with the compact-touch query stubbed both ways, plus real-engine touch evidence (implementation gap: not yet in the tree; the Lab prototype's suite, `packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx`, is the current evidence for the flow the rows describe) | default popover, forced sheet, adaptive both ways (server markup and hydration as the popover field on a coarse-pointer client, then the trigger, no recoverable error, focus on the body; with `hasAutoFocus`, the autofocused input's focus ending on the trigger with no `onBlur` or `onFocus` to the caller), trigger name, Filters/Add filter/value steps, each value type, list value through the Tokenizer sheet, Clear all, read-only, disabled while open, focus after each step, focus after an `adaptive` switch from the trigger, the input, a removed control, an open editor popover, and from outside the field, a field inside an open Dialog announcing the count, Clear all, and in-sheet messages inside that Dialog and never on the page body, StrictMode result count, in-sheet live region while a sheet is open (added, removed, count, empty results), Clear all announced only after the dialog's exit, row, token and Removed text sharing one formatter, a date-only value as its day in `timezoneID`, else the UTC day, and a date-and-time value in `timezoneID`, else the viewer's zone, on all three (UTC, Tokyo, Honolulu, unset), summaries and relative-date labels and values from the catalog, the row and Removed through one reorderable message, an unmatched relative-date value as stored, a filter the config no longer knows listed and removable, a cleared number unstaged, choice rows as radios and checkboxes with `aria-checked`, a stale read-only filter as a plain row, a removal next to a nested row focusing that row, a nested filter removed from its step, a list value joined by the locale's narrow conjunction list in English, Simplified Chinese, Japanese, and German, an empty-result message spoken once per query from its rendered element, a controlled update that removes the edited field moving focus from the replaced body to Cancel and announcing the unavailable state in the sheet while the step is open and never from a step beneath another or retained closed, Apply and Cancel after a controlled update removed the edited filter focusing the nearest row, else Add filter, a row's trailing remove action focusing the next row's remove action, a one-tap `empty`-valued filter added from its field row and removed from its row, Clear all behind its confirmation keeping read-only filters and leaving Filters open, a dismissed step revealing the step beneath with focus restored to it, Add filter opening with its search unfocused and a touch drag on the rows blurring a focused search, a stale filter's step titled as its row reads (raw keys for what is gone, the field's label when the field remains), Cancel from creation after the opening field was removed focusing the Add filter sheet's first control, a removed focused Filters row, a removed Clear all, and a dropped focused field row each handing focus to the nearest remaining row or the sheet's first control, element counts at rest and with each of the Filters, Add filter and value sheets open, and with the Tokenizer's list-value sheet stacked above the value step, under fine and coarse pointers, nothing of the flow in the DOM at rest before a press and after the exit, a nested filter edited and created through the built-in nested editor (its sub-filter rows wrapping inside a 390 and a 414 px sheet; its root operator picker absent on a mixed field, the Operator group in its place), a nested-only field and its suggestion offered, a suggestion's operator checked over the field's default, the Operator group outside a caller's Editor override on a mixed field, a count changed during the exit spoken with Clear all's message as one catalog message after the close, a radio group's arrow keys, Home, and End moving and checking, Space and Enter checking a focused radio row and toggling a checkbox row, right-to-left layout with the row chevrons and the rows' chevrons mirrored in RTL (`design:bidirectionality/DR4`) | A field without `experimental` changes beyond what FR19 states, a value commits before the step confirms, Clear all skips a read-only filter, the trigger carries native `disabled`, focus lands elsewhere, an announcement reaches a channel outside the modal that holds the field at that moment, or the popover bundle carries the sheets. | `audit:PowerSearch/sheet` |

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 row's failure signal still fails "Clear all skips a read-only filter", which FR12 now requires. It also keeps "a nested filter removed from its step" (FR14: the step has no Remove action) and "a removal next to a nested row focusing that row" beside FR16's remove-action target.

sheet needs a Back action. Closed vocabularies are rows; list values edit
through the Tokenizer's sheet presentation titled with the field label; a
`nested` value uses the popover presentation's built-in nested editor; a
date-only value edits on an inline Calendar in `timezoneID`, else UTC, and a

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Lab falls back to the viewer's zone for a date-only value when timezoneID is unset (unixSecondsToISODate and isoDateToUnixSeconds in mobilePowerSearchUtils.ts), so "else UTC" is not the flow "as MobilePowerSearch ships it". Lab also leaves out nested-only fields and writes its sheet strings in English.

`document.body.querySelectorAll('*').length`, at rest and with each state of
the stack open, under a fine and a coarse pointer, and recorded here when the
Core implementation exists; implementation gap: Core has no sheet flow to
measure, and the Lab prototype's counts stand in until it does. Owner exception to INV7's surface budget, which asks that a

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 accepts a budget exception only "recorded with its element counts". This exception now covers every stacked state with no counts, and the "Lab prototype's counts" it says stand in are not recorded anywhere. AV5 still bounds the flow by DEC-3's "measured counts".

Comment on lines +602 to +610
Owner rulings of 2026-11-02 cover both presentations of one field. Clear all
keeps read-only filters, which are the consumer's, and reports `remove` at the
first removed index. A date-only `date_absolute` value, and each absolute part
of a `date_range`, is one day in one zone: its day in `timezoneID` when the prop
is set, else the UTC day, read and written alike. A `date_absolute` value whose
operator sets `isDateOnly: false` is an instant with a time of day, read and
written in `timezoneID`, else the viewer's zone. The sheet presentation follows
these rulings (FR11, FR12, FR17); where the popover presentation does not follow
them yet, the known gap below records it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These rulings change what the stable popover's Clear all and date editors report through onChange. spec:AST-017 FR22 keeps such changes outside the experimental exception, but Compatibility and migration still files DEC-3 as the experimental option only. Either leave the popover rulings to the pull requests that change the popover, or classify each popover change there under FR5.

@imdreamrunner
imdreamrunner force-pushed the spec/powersearch-sheet-lab-ux branch from cebf7cb to cf4a0ae Compare November 3, 2026 06:26
@imdreamrunner

Copy link
Copy Markdown
Contributor Author

/approve-spec cf4a0ae

@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 engineering-owner approval at this head settles the sheet flow's direction. Three fixes inside the record and its description remain before it can merge:

  • FR15a and AV5 cite component:BottomSheet FR10's same-update replace for a creation committed from the value step after a direct entry, but that commit closes two sheets, one of them covered, which FR10 excludes; and FR14's onSave(null) while creating opens Filters the same way, against FR15a's "the one place a sheet opens as others close".
  • The title and description still say the record is the Lab flow "as it ships" and that Lab "implements every behavior the record states", while DEC-3 lists six differences, and that list misses a seventh: AR6 announces Added {label} for an empty-valued field row, which the Lab prototype does not.
  • The spec:AST-086/FR14 known gap in Open questions still routes "AR6 Clear all's after-close message" to the modal that holds the field, but AR6 now announces Clear all inside the still-open Filters sheet.

[Automated review]

| FR13 | Add filter — from the Filters sheet's Add filter action, or directly when an interactive field with no filter opens — is a tall modal sheet named "Add filter" opened above the Filters sheet, which stays open beneath it (FR15a), so the sheet has no Back action: dismissing it — Escape, a scrim tap, a swipe — reveals Filters again, or closes the flow when Add filter was the entry point, in which case its fixed footer holds Done. Its header — the heading and, when one shows, the search field — stays fixed while the rows scroll. Without a content-search field it lists the visible fields grouped by `group`, each group a named list: the ungrouped fields first, under the heading "General", then each group under its name in order of first appearance, each a row button with the field's icon, a trailing chevron — or, for a field whose only operator takes an `empty` value, a trailing plus, since choosing it adds the filter at once (FR15) — 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 the empty state "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`, with the same suggestions the popover presentation's typeahead offers listed inline as row buttons ("Search results"): fields, field-and-operator pairs, value matches, and a leading free-text item for the content field; Enter commits the first suggestion, ArrowDown and ArrowUp move between the search and the rows — arrow movement across independently focusable controls, recorded here as this record's exception to `spec:AST-020/FR15`, which adopts arrow keys for a single composite only: the searchbox-to-list continuity is what the popover presentation's combobox gives the same suggestions, and the rows stay in the Tab order as well — an IME-composing Enter does nothing, and a query with no suggestion shows the empty state "No results found" — the searchbox's `aria-controls` names the results list, or that message while nothing lists, so it always names an element; the field list's "No filters match your search" likewise shows only for a non-empty query that matches nothing, never for a config with no field. Each of the two is a compact empty state with a search icon, that title, and the description "Try a different search.", mounted only while the Add filter sheet is the open one. Neither search field takes focus when the sheet opens: the sheet opens with focus on its panel and the software keyboard stays down until the person taps the search; a direct vertical touch drag on the rows while a search field has focus blurs it, so the keyboard leaves and the rows can be read. A suggestion that carries a value commits `onChange([...filters, filter], 'add', filters.length)`, announces `Added {label}` inside the Filters sheet (AR6), and shows Filters as FR15a states; any other suggestion or field row opens the value step with that field and operator, above Add filter. Every visible field lists, a field whose every operator is `nested` included: the value step edits every operator's value (FR14). | DEC-3; Lab touch-flow evidence; current typeahead source | Current contract. Implementation gap: no Add filter sheet exists. |
| FR14 | The value step is a tall modal sheet that edits every operator's value, opened above the sheet that led to it — Filters for an edit, Add filter for a creation — which stays open beneath it (FR15a); its title is the dialog's name (AR5), and for a filter whose field or operator the config no longer has it reads as the Filters row does (FR12): the field's label when the config still has the field, the raw key for whatever is gone. Its header and footer stay fixed while the body scrolls. 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 body's sections sit on the sheet's surface when the editor is the body's only section; when the body has more than one section — the Operator group above the editor — each section is a muted container, so the grouping reads without a frame around a lone editor. 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; the step has no Remove action: a filter is removed from its Filters row (FR12). 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 while the step is the open one (AR6), and keeps Cancel as the way out, the filter's trailing remove action on its Filters row being the removal path (FR12); when focus was on a control of the replaced body it moves to Cancel, so it never falls to the page body inside the open sheet (FR16). | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no value step exists. |
| FR15 | Confirming a created filter commits `onChange([...filters, filter], 'add', filters.length)`; choosing a field whose only operator takes an `empty` value commits that filter the same way from the Add filter row, with no value step. Tapping an editable Filters row opens the value step on that filter in edit mode; Apply commits `onChange(updated, 'edit', index)`; the row's trailing remove action, or a tap on an `empty`-valued filter's row, commits `onChange(rest, 'remove', index)`, where `index` is the filter's position at commit time: a controlled parent that reorders or clones its filters while the step is open is followed by object identity, then by a structurally equal filter in the original slot, then by a unique structural match; when the target is gone or ambiguous nothing is committed and the step closes back to Filters, focus landing as FR16 states for a row that no longer exists. Every commit shows the Filters sheet as FR15a states. While the field is read-only, or disabled, no commit happens. | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no edit path exists. |
| FR15a | The steps stack: each step opens above the sheet that led to it, which stays open — receded and blocked as a covered sheet of the stack (`component:BottomSheet` FR5, FR6) — so the stack is Filters alone; Filters under Add filter; Filters under the value step for an edit; Filters under Add filter under the value step for a creation; or, when Add filter was the entry point, Add filter alone and then Add filter under the value step. No sheet of the flow carries a Back action: a dismissal — Escape, a scrim tap, a swipe, a platform close request — reaches only the top sheet (`component:BottomSheet` FR7), closes it, and reveals the sheet beneath, which takes focus back as FR16 states; dismissing the bottom sheet of the stack closes the flow. Cancel on the value step is that dismissal. A commit closes the step that made it: an edit's commit reveals Filters beneath the value step; a creation's commit, from the value step or from an Add filter row or suggestion that commits directly, closes Add filter, and the value step above it, together and shows Filters — revealed when it is open beneath them, else opened in the same update that closes them, BottomSheet's same-update replace (`component:BottomSheet` FR10), so the person sees the filter just added. The flow therefore has one direction forward (open a step above) and one back (close the top step): no dismissal replaces a step, and a creation's commit from a direct entry is the one place a sheet opens as others close. | DEC-3; `component:BottomSheet` FR5–FR9 | Current contract. Implementation gap: no flow exists. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

component:BottomSheet FR10 applies only when exactly one sheet closes and one opens, and keeps "a covered sheet closing, several sheets changing" on the ordinary push and pop. After a direct entry, a commit from the value step closes Add filter (covered) and the value step together, so it is not an FR10 replace; only the direct commit from an Add filter row or suggestion is. FR14's onSave(null) while creating also opens Filters as two sheets close, which this row's "the one place a sheet opens as others close" rules out.

date-and-time control in `timezoneID`, else the viewer's zone; every other value
type uses the editor the popover presentation uses. Clear all asks for
confirmation, removes the filters the person can edit, keeps read-only ones, and
leaves Filters open. Core's contract differs from the prototype in six places: a

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A seventh difference: AR6 announces Added {label} for an empty-valued field row, but the prototype's one-tap field path commits and focuses the new row without an announcement; only its suggestion path announces.

@imdreamrunner
imdreamrunner force-pushed the spec/powersearch-sheet-lab-ux branch from cf4a0ae to e8bc534 Compare November 3, 2026 06:47
@imdreamrunner imdreamrunner changed the title docs(spec): PowerSearch's sheet presentation is the Lab touch flow as it ships (DEC-3) docs(spec): PowerSearch's sheet presentation follows the Lab touch flow (DEC-3) Nov 3, 2026
@imdreamrunner

Copy link
Copy Markdown
Contributor Author

/approve-spec e8bc534

@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 engineering-owner approval at this head settles the sheet flow's direction, and the fixes from the last round have landed. Two fixes remain before it can merge:

  • AV5 keeps the open flow within the popover editor's element count plus the 5-element open-layer allowance for each covered sheet, but DEC-3's INV7 exception says each covered sheet is a whole dialog over that allowance, and the prototype counts it records exceed AV5's ceiling in every stacked state, so AV5 should leave the covered sheets to DEC-3's exception.
  • The description's Decision section still says every direct-entry creation opens Filters through BottomSheet FR10's same-update replace, and its authority list says FR10 is no longer used, while FR15a uses FR10 only when a lone Add filter closes.

[Automated review]

Comment on lines +245 to +248
open layer, no content mounted twice, and the open flow under a coarse pointer
within the popover presentation's open editor under a fine pointer plus the
open-layer allowance (`spec:AST-017/FR60`, `DEC-29`) for each sheet open
beneath the top one, with the counts DEC-3 records. The Tokenizer's list-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.

This keeps the stacked flow within the popover editor's count plus 5 elements per covered sheet (339 with one covered sheet, 344 with two), but DEC-3's INV7 exception says each covered sheet is a whole dialog over that allowance, and its prototype counts (340, 413, 366, 429) already exceed it. Could AV5 bound only the single-sheet states this way and leave each sheet open beneath the top one to DEC-3's owner exception, as the earlier wording did?

@imdreamrunner
imdreamrunner force-pushed the spec/powersearch-sheet-lab-ux branch from e8bc534 to 7a5874f Compare November 3, 2026 07:28
@imdreamrunner

Copy link
Copy Markdown
Contributor Author

/approve-spec 7a5874f

@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 engineering-owner approval at this head settles the sheet flow's direction, and both fixes from the last round have landed. Three record fixes remain before it can merge:

  • FR14 still lets a caller's Editor override remove a filter from the value step with onSave(null), but AR6 now announces Removed {label} only for row removals and FR16 names a focus target for onSave(null) only while creating, so name that removal in both rows.
  • DEC-3 says Core differs from the prototype in seven places, but FR14 passes an Editor override nothing the popover does not while the prototype also passes it timezoneID, so list that difference or drop the count.
  • The internationalization known gap in Open questions still says the trigger shows the popover token “as it is”, which FR11's timezoneID date reading now contradicts for dates.

Two optional cleanups: say which element budget covers Filters beneath the Clear all confirmation, and fix the repeated “row chevrons and the rows' chevrons” in the verification row.

[Automated review]

same FR14 route (FR19). Focus moves as FR16 states and returns to the
trigger after the flow closes. Implementation gap: no flow exists.
the flow makes — `Added {label}` for a committed suggestion or an
`empty`-valued field row, `Removed {label}` for a filter removed by its row's

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FR14 still lets a caller's Editor override remove a filter from the value step with onSave(null) while editing, but this clause now names Removed {label} only for row removals, and FR16 names a focus target for onSave(null) only while creating. Name the override's removal here and in FR16's removal rule.

date-and-time control in `timezoneID`, else the viewer's zone; every other value
type uses the editor the popover presentation uses. Clear all asks for
confirmation, removes the filters the person can edit, keeps read-only ones, and
leaves Filters open. Core's contract differs from the prototype in seven places:

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 list is not complete: FR14 hands an Editor override nothing the popover does not pass, and the popover passes no timezoneID, while the prototype passes timezoneID to it. List that difference, or replace the count with “including”.

@@ -640,15 +701,6 @@ would go with the rest).
(FR11); changing 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.

“Shows the popover token as it is on the trigger (FR11)” no longer holds for dates: FR11 reads a date_absolute value on the trigger in timezoneID, else the UTC day, with a time for isDateOnly: false, and says the popover's token does not. Suggest: “shows the popover token's text on the trigger, except dates, which FR11 reads in timezoneID”.

@imdreamrunner

Copy link
Copy Markdown
Contributor Author

/approve-spec 16b80e7

@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 engineering-owner approval at this head settles the sheet flow's direction, and the three fixes from the last round have landed. One record fix remains before it can merge:

  • FR16 sends focus to Add filter whenever the Clear all confirmation closes, including Keep filters, Escape and a scrim tap, but component:Dialog AR2 returns focus to the Clear all button that is still there, so limit that clause to a confirmed Clear all and say a dismissed confirmation returns focus to Clear all.

Optional cleanups: the internationalization known gap in Open questions starts with a doubled list marker, its last sentence has a stray ,; and leaves out FR11's fallback zones, and AR5 could name the confirmation's role and first focus (Lab uses an alert dialog with Keep filters focused first).

[Automated review]

| FR14 | The value step is a tall modal sheet that edits every operator's value, opened above the sheet that led to it — Filters for an edit, Add filter for a creation — which stays open beneath it (FR15a); its title is the dialog's name (AR5), and for a filter whose field or operator the config no longer has it reads as the Filters row does (FR12): the field's label when the config still has the field, the raw key for whatever is gone. Its header and footer stay fixed while the body scrolls. 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 body's sections sit on the sheet's surface when the editor is the body's only section; when the body has more than one section — the Operator group above the editor — each section is a muted container, so the grouping reads without a frame around a lone editor. 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; the step has no Remove action: a filter is removed from its Filters row (FR12). 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 while the step is the open one (AR6), and keeps Cancel as the way out, the filter's trailing remove action on its Filters row being the removal path (FR12); when focus was on a control of the replaced body it moves to Cancel, so it never falls to the page body inside the open sheet (FR16). | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no value step exists. |
| FR15 | Confirming a created filter commits `onChange([...filters, filter], 'add', filters.length)`; choosing a field whose only operator takes an `empty` value commits that filter the same way from the Add filter row, with no value step. Tapping an editable Filters row opens the value step on that filter in edit mode; Apply commits `onChange(updated, 'edit', index)`; the row's trailing remove action, or a tap on an `empty`-valued filter's row, commits `onChange(rest, 'remove', index)`, where `index` is the filter's position at commit time: a controlled parent that reorders or clones its filters while the step is open is followed by object identity, then by a structurally equal filter in the original slot, then by a unique structural match; when the target is gone or ambiguous nothing is committed and the step closes back to Filters, focus landing as FR16 states for a row that no longer exists. Every commit shows the Filters sheet as FR15a states. While the field is read-only, or disabled, no commit happens. | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no edit path exists. |
| FR15a | The steps stack: each step opens above the sheet that led to it, which stays open — receded and blocked as a covered sheet of the stack (`component:BottomSheet` FR5, FR6) — so the stack is Filters alone; Filters under Add filter; Filters under the value step for an edit; Filters under Add filter under the value step for a creation; or, when Add filter was the entry point, Add filter alone and then Add filter under the value step. No sheet of the flow carries a Back action: a dismissal — Escape, a scrim tap, a swipe, a platform close request — reaches only the top sheet (`component:BottomSheet` FR7), closes it, and reveals the sheet beneath, which takes focus back as FR16 states; dismissing the bottom sheet of the stack closes the flow. Cancel on the value step is that dismissal. A commit closes the step that made it: an edit's commit reveals Filters beneath the value step; a creation's commit, from the value step or from an Add filter row or suggestion that commits directly, closes Add filter, and the value step above it, together and shows Filters, so the person sees the filter just added: Filters is revealed when it is open beneath them; after a direct entry it opens in the same update that closes them — BottomSheet's same-update replace (`component:BottomSheet` FR10) when Add filter alone closes, and the stack's ordinary closes and open (`component:BottomSheet` FR5–FR9) when the value step closes with it. A creating `Editor` override's `onSave(null)` (FR14) shows Filters the same way. The flow therefore has one direction forward (open a step above) and one back (close the top step): no dismissal replaces a step, and Filters opens as other sheets close only when a creation ends after a direct entry. | DEC-3; `component:BottomSheet` FR5–FR9 | Current contract. Implementation gap: no flow exists. |
| FR16 | Focus follows the step: after a commit from the value step while creating, the value step and Add filter close and focus lands on the Filters header's Add filter, ready for the next filter; after a content-search suggestion or an `empty`-valued field row commits a filter directly, that filter's row; after Apply or Cancel from editing, the edited filter's row — followed across a controlled reorder or clone, and, when a controlled update removed it or left two filters it could be, the nearest remaining row that is a button around its former position (the following one on a tie), else Add filter; after a row's trailing remove action, or a caller's `Editor` override's `onSave(null)` while editing, which closes the value step, the trailing remove action of the nearest remaining row that has one (the following one on a tie) — a plain row, a read-only filter's, has none and is passed over — so a run of removals needs no re-aiming, else Add filter when no such row remains; after Cancel from creation, Add filter is revealed with focus on the field row that opened the step, or on the content search when a suggestion opened it — and when a controlled update has removed that field, or the content-search field itself, the Add filter sheet's first control: its search field when one shows, else its first field row; after Add filter is dismissed, the Filters header's Add filter, or the empty state's; after a caller's `Editor` override calls `onSave(null)` while creating, the same (the flow returns to Filters unchanged, FR14); after Clear all's confirmation closes, the Filters sheet's first remaining control — a kept read-only filter's row has none, so Add filter — with the sheet still open; and, as one rule for the Filters and Add filter sheets, when a controlled update unmounts the control that holds focus — a filter's row or its remove action, a dropped field's row, Clear all gone with the last editable filter — the nearest remaining row that is a button, counted among the button rows by the removed one's index (the following one, else the last) — a plain row (a read-only filter, or every filter while the field is read-only) is passed over — else the sheet's first control (the Add filter sheet's search field, Add filter in the Filters header or empty state), else the sheet's panel, so focus never lands on the page body inside the open sheet; when a controlled update replaces the value step's body with the unavailable state while focus was on one of its controls, Cancel (FR14); after the flow closes, the trigger. A sheet that opens takes focus on its panel, not on a control, except where this row names one; the Add filter sheet's search fields never take it on open (FR13). | DEC-3; Lab touch-flow evidence | Current contract. Implementation gap: no flow exists. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"after Clear all's confirmation closes, the Filters sheet's first remaining control … so Add filter" also covers Keep filters, Escape and a scrim tap, when Clear all is still there. component:Dialog AR2 returns focus to a still-connected invoking control, and Lab's confirmation does. Suggest: "after a confirmed Clear all, the Filters sheet's first remaining control …; after Keep filters, Escape or a scrim tap, Clear all".

sheet's is a stable change outside this experimental option (Compatibility and
migration).

- - Known gap, pre-existing in the popover presentation, against

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: the doubled marker (- - Known gap) renders this gap as a nested bullet under an empty one; one - and no blank line above keeps it a sibling of the other known gaps.

relative-date value as stored). The sheet presentation resolves all of these
from the catalog, or the locale's formats, on its own surfaces (FR12, FR20)
and shows the popover token's text on the trigger, except a date, which FR11
reads in `timezoneID`,; changing the token's and the editor's own text is a

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: stray comma in "timezoneID,;". FR11 also falls back to the UTC day, or to the viewer's zone for a time of day; suggest "except a date, which FR11 reads in timezoneID or its fallback zone;".

rows are buttons named by their visible text, and a Filters row's trailing
remove action is a button named "Remove {field label} filter" with that name
as its tooltip; the Clear all confirmation is a modal dialog named "Clear all
filters?" whose actions read "Clear filters" and "Keep filters"; a choice row

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: Lab's confirmation is an alertdialog with Keep filters focused first, the safe-first shape Tokenizer's sheet confirmation uses. Naming the role and the first focus here keeps a stray Enter from clearing the filters.

…ow (DEC-3)

The owner named Lab MobilePowerSearch's current flow as the target for Core's
experimental sheet presentation. The record now describes that flow as Core's
contract.

- Steps stack and stay open beneath the next (FR15a). A dismissal reveals the
  previous step, and no sheet carries Back. A creation's commit shows Filters.
- Headers, search fields and footers stay fixed while only the rows scroll.
- Every removable Filters row has a trailing remove action, one-tap boolean
  filters included. The value step has no Remove.
- Add filter's search stays unfocused until tapped. Its empty results are a
  compact empty state, and its ungrouped fields sit under "General".
- A lone editor sits on the sheet surface. The empty Filters state is one
  centered Add filter.
- Clear all sits behind a confirmation, keeps read-only filters and leaves
  Filters open.
- A date-only value edits as its day in timezoneID, else UTC. A value with a
  time of day edits in timezoneID, else the viewer's zone.

DEC-3 lists where Core's contract differs from the prototype. The rules for
identity, stale filters, focus recovery and announcements only from the open
step are unchanged.

The presentation stays unexposed, so every sheet row keeps its
implementation-gap note. The sheet's Clear all matches the popover
presentation's (FR23). The popover's date handling is stable behavior outside
this experimental option, and the known gap lists where it differs from the
sheet.
@imdreamrunner

Copy link
Copy Markdown
Contributor Author

/approve-spec b93e733

@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 engineering-owner approval at this head settles the sheet flow, and the last round's fix has landed: FR16 now returns focus to Clear all when Keep filters, Escape or a scrim tap dismisses its confirmation, and moves it to Add filter only after a confirmed clear. The optional cleanups are in too (the confirmation is an alertdialog with Keep filters focused first, and the internationalization gap's list marker and date wording are fixed).

No blocking findings remain, so nothing further is needed from the review's side.

[Automated review]

@imdreamrunner
imdreamrunner merged commit 65b7ab6 into main Nov 3, 2026
56 checks passed

This branch was successfully deployed

1 active deployment
Preview — b93e7335 Deployed Nov 3, 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant