@castandcrew/spotlight-mcp 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/data/index.json +16 -7
  2. package/package.json +1 -1
package/data/index.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": "0.1.0",
3
- "generated": "2026-07-29T22:21:18.808Z",
3
+ "generated": "2026-07-30T19:54:09.103Z",
4
4
  "components": [],
5
5
  "patterns": [
6
6
  {
@@ -260,7 +260,7 @@
260
260
  "title": "Filter Behavior Rules — Spotlight Design System",
261
261
  "category": "selection",
262
262
  "description": "Defines how filtering behaves across all Spotlight products: combination logic, update timing, debounce, search scope, empty results, server/client execution, and interaction with sort and pagination. Applies to all table and list screens with one or more filter criteria across myC&C, Hours+, Start+",
263
- "content": "# Filter Behavior Rules — Spotlight Design System\n\nDefines how filtering behaves across all Spotlight products: combination logic, update timing,\ndebounce, search scope, empty results, server/client execution, and interaction with sort and\npagination. Applies to all table and list screens with one or more filter criteria across\nmyC&C, Hours+, Start+, and Studio+. Status: **Finalized v1.0.0** (SPT-235).\n\n---\n\n## The 9 rules at a glance\n\n| # | Rule | Confidence |\n|---|------|-----------|\n| 1 | Different filter types combine with **AND** — each added filter narrows results. | Very high |\n| 2 | Multiple values within one multi-select combine with **OR**. Render as checkbox lists. | Very high |\n| 3 | Toolbar controls apply immediately; anything not in a toolbar requires an **Apply** action. | High |\n| 4 | Text-search debounce = **300ms** standard; 500ms exception for slow endpoints only. | Very high |\n| 5 | Search covers **all visible text columns**; placeholder states scope per table. | High |\n| 6 | Two **distinct** empty states; chips stay visible; Reset filters recovery; accessible announcement. | High |\n| 7 | ≤100 rows may filter client-side; **over 100 rows must filter server-side**. | High |\n| 8 | Filter and sort are **independent** — each holds when the other changes. | High |\n| 9 | Filter apply resets to page 1; a filtered URL always loads on page 1. | High |\n\n---\n\n## Rule 1 — Combination logic: between filter types\n\nDifferent filter types combine with **AND** — all active filters must match simultaneously. Each\nfilter type the user adds narrows the result set further.\n\n**Example:** `Status = Pending` AND `Date = This Week` → only records that are both pending\nand from this week. Adding filters always shows *less*, never more — which is why a combination\ncan reach zero results.\n\n---\n\n## Rule 2 — Combination logic: within a multi-select\n\nMultiple values selected within a single multi-select filter combine with **OR** — a row matches\nif it satisfies any selected value. Combined with Rule 1: **OR within a facet, AND across facets.**\n\n**Example:** `Status = [Pending, Rejected]` AND `Date = This Week` → records that are either\npending or rejected, AND from this week.\n\n**Accessibility — control type.** Multi-select filters must render as **checkbox lists**, not\nmodifier-key (ctrl/shift) multi-selects. Standard multi-select dropdowns rely on non-obvious\nkeyboard modifiers that cause users to fail (NN/g research).\n\n---\n\n## Rule 3 — Update timing\n\n**Toolbar controls** (single-select, toggle, search) apply **immediately** on change — no Apply\nbutton on the toolbar.\n\n**Non-toolbar controls** (inside a Filter Drawer or panel) require an explicit **Apply** action.\nThe Apply action batches multiple simultaneous changes to prevent the table from thrashing while\nthe user is still adjusting.\n\nImmediate toolbar updates must show a loading state while results resolve.\n\n---\n\n## Rule 4 — Text-search debounce\n\nText search waits **300ms** after the last keystroke before executing.\n\n| Setting | Value |\n|---------|-------|\n| Standard debounce | 300ms |\n| Allowed exception | 500ms (known-slow server-side endpoints or degraded networks only — not a second default) |\n\nImplement via the standard `SearchFilter` template / `useDebounce` hook rather than hand-rolling.\nDo not conflate with autosave (`autosaveDebounceMs`) or autocomplete (`searchDebounceMs`) debounces.\n\n---\n\n## Rule 5 — Search scope\n\nText search covers **all visible text columns** by default. The placeholder states the scope per\ntable in the format: `\"Search by [column], [column], or [column]…\"`\n\n- Timecards: \"Search by name, ID, or notes…\"\n- Crew Lists: \"Search by name, email, or phone…\"\n\nNon-text or derived columns (status enums, dates, specially-formatted IDs) get dedicated filter\ncontrols rather than free-text scope.\n\n**Accessibility.** Placeholder text disappears when the user types, so each search field must also\ncarry a persistent label or `aria-label` naming its scope.\n\n---\n\n## Rule 6 — Empty / zero-results handling\n\nWhen filters return zero results, show a **filtered-empty state** that is:\n- Distinct from the \"no data\" empty state\n- Keeps the active filter chips visible above the empty area\n- Offers a prominent **Reset filters** recovery action\n\n| State | Trigger | Message intent |\n|-------|---------|---------------|\n| **No data** | No records exist at all | Onboarding / create-first-item |\n| **Filtered-zero** | Records exist, but filters exclude everything | Non-blaming; offer recovery |\n\nThe two states are **system-detected** — the system decides which to render.\n\nRecovery follows the Reset Filters pattern: clear all → return to page default → confirmation\nsnackbar with Undo.\n\nNon-blaming copy: \"No timecards match your filters. Try adjusting your search criteria or date\nrange.\"\n\n**Accessibility.** Announce the zero-results state to screen readers via the GridTable\n`role=\"status\"` hidden-region mechanism; the change must not be visual-only.\n\n---\n\n## Rule 7 — Server-side vs. client-side filtering\n\n| Dataset size | Filtering |\n|-------------|-----------|\n| ≤ 100 rows | May filter client-side |\n| Over 100 rows | **Must filter server-side** (API call with filter params) |\n\n**Same-side rule.** Filtering, sorting, and pagination must run on the **same side** — never mixed.\nIf a table filters server-side, it must also sort and paginate server-side, or it will only operate\non the current page.\n\nPersisted filters (URL / session / local storage) must be reconstructable server-side on refresh\nor deep-link.\n\n---\n\n## Rule 8 — Filter + sort interaction\n\nFiltering and sorting are **independent**: applying or clearing a filter keeps the current sort,\nand sorting keeps the current filters.\n\n| Action | Effect on the other dimension |\n|--------|------------------------------|\n| Apply a filter | Current sort is maintained |\n| Clear / reset filters | Current sort is maintained |\n| Change the sort | Current filters are maintained |\n\n**Order of operations:** filter first, then sort, applied across the *entire filtered set* — not\njust the visible page.\n\nTables load with a sensible default sort (e.g. Timecards → date descending). Selected rows remain\nselected when the table is re-sorted.\n\nThe Reset → Undo snapshot captures **filters only**, not sort.\n\n---\n\n## Rule 9 — Filter + pagination interaction\n\nApplying a filter resets to **page 1** of the filtered results. Clearing or resetting filters\nresets to page 1 of the post-reset default state.\n\n**Deep-link precedence — filter wins.** When a URL contains both a filter and a page number, the\n**filter wins**: a filtered URL always loads on page 1. There is no deep-link to a specific page\nwithin a filtered view.\n\nWhen a filter resets pagination to page 1, prior-page selection is treated as cleared unless the\nproduct supports preserve-by-ID.\n\n---\n\n## Related patterns\n\n| Pattern | Purpose |\n|---------|---------|\n| `filter-selection-guide` | Which filter surface to use (Filter Bar vs Condensed vs Drawer) |\n| `filter-toolbar-pattern` | Toolbar anatomy, component ordering, chip row implementation |\n| `filter-drawer-pattern` | Filter Drawer anatomy, sizes, batch Apply behavior |\n| `filter-persistence-guide` | Session, URL, and saved-view persistence levels |\n| `filter-communicating-applied-guide` | Applied chips, count badge, results count, zero results |\n\n---\n\n*Pattern ID: `filter-behavior-rules` · Source: SPT-235 v1.0.0 (Finalized)*\n",
263
+ "content": "# Filter Behavior Rules — Spotlight Design System\n\nDefines how filtering behaves across all Spotlight products: combination logic, update timing,\ndebounce, search scope, empty results, server/client execution, and interaction with sort and\npagination. Applies to all table and list screens with one or more filter criteria across\nmyC&C, Hours+, Start+, and Studio+. Status: **Finalized v1.0.0** (SPT-235).\n\n---\n\n## The 9 rules at a glance\n\n| # | Rule | Confidence |\n|---|------|-----------|\n| 1 | Different filter types combine with **AND** — each added filter narrows results. | Very high |\n| 2 | Multiple values within one multi-select combine with **OR**. Render as checkbox lists. | Very high |\n| 3 | Toolbar controls apply immediately; anything not in a toolbar requires an **Apply** action. | High |\n| 4 | Text-search debounce = **300ms** standard; 500ms exception for slow endpoints only. | Very high |\n| 5 | Search covers **all visible text columns**; placeholder states scope per table. | High |\n| 6 | Two **distinct** empty states; chips stay visible; Reset filters recovery; accessible announcement. | High |\n| 7 | ≤100 rows may filter client-side; **over 100 rows must filter server-side**. | High |\n| 8 | Filter and sort are **independent** — each holds when the other changes. | High |\n| 9 | Filter apply resets to page 1; a filtered URL always loads on page 1. | High |\n\n---\n\n## Rule 1 — Combination logic: between filter types\n\nDifferent filter types combine with **AND** — all active filters must match simultaneously. Each\nfilter type the user adds narrows the result set further.\n\n**Example:** `Status = Pending` AND `Date = This Week` → only records that are both pending\nand from this week. Adding filters always shows *less*, never more — which is why a combination\ncan reach zero results.\n\n---\n\n## Rule 2 — Combination logic: within a multi-select\n\nMultiple values selected within a single multi-select filter combine with **OR** — a row matches\nif it satisfies any selected value. Combined with Rule 1: **OR within a facet, AND across facets.**\n\n**Example:** `Status = [Pending, Rejected]` AND `Date = This Week` → records that are either\npending or rejected, AND from this week.\n\n**Accessibility — control type.** Multi-select filters must render as **checkbox lists**, not\nmodifier-key (ctrl/shift) multi-selects. Standard multi-select dropdowns rely on non-obvious\nkeyboard modifiers that cause users to fail (NN/g research).\n\n---\n\n## Rule 3 — Update timing\n\n**Toolbar controls** (single-select, toggle, search) apply **immediately** on change — no Apply\nbutton on the toolbar.\n\n**Non-toolbar controls** (inside a Filter Drawer or panel) require an explicit **Apply** action.\nThe Apply action batches multiple simultaneous changes to prevent the table from thrashing while\nthe user is still adjusting.\n\n**Rule of thumb:** if it's not in a toolbar, it needs Apply.\n\nImmediate toolbar updates must show a loading state while results resolve.\n\n---\n\n## Rule 4 — Text-search debounce\n\nText search waits **300ms** after the last keystroke before executing.\n\n| Setting | Value |\n|---------|-------|\n| Standard debounce | 300ms |\n| Allowed exception | 500ms (known-slow server-side endpoints or degraded networks only — not a second default) |\n\nImplement via the standard `SearchFilter` template / `useDebounce` hook rather than hand-rolling.\nDo not conflate with autosave (`autosaveDebounceMs`) or autocomplete (`searchDebounceMs`) debounces.\n\n---\n\n## Rule 5 — Search scope\n\nText search covers **all visible text columns** by default. The placeholder states the scope per\ntable in the format: `\"Search by [column], [column], or [column]…\"`\n\n- Timecards: \"Search by name, ID, or notes…\"\n- Crew Lists: \"Search by name, email, or phone…\"\n\nNon-text or derived columns (status enums, dates, specially-formatted IDs) get dedicated filter\ncontrols rather than free-text scope.\n\n**Accessibility.** Placeholder text disappears when the user types, so each search field must also\ncarry a persistent label or `aria-label` naming its scope. *Note: GridTable search input is\ncurrently placeholder-only — persistent label support is tracked separately.*\n\n---\n\n## Rule 6 — Empty / zero-results handling\n\nWhen filters return zero results, show a **filtered-empty state** that is:\n- Distinct from the \"no data\" empty state\n- Keeps the active filter chips visible above the empty area\n- Offers a prominent **Reset filters** recovery action\n\n| State | Trigger | Message intent |\n|-------|---------|---------------|\n| **No data** | No records exist at all | Onboarding / create-first-item |\n| **Filtered-zero** | Records exist, but filters exclude everything | Non-blaming; offer recovery |\n\nThe two states are **system-detected** — the system decides which to render.\n\nRecovery follows the Reset Filters pattern: clear all → return to page default → confirmation\nsnackbar with Undo.\n\nNon-blaming copy: \"No timecards match your filters. Try adjusting your search criteria or date\nrange.\"\n\n**Accessibility.** Announce the zero-results state to screen readers via the GridTable\n`role=\"status\"` hidden-region mechanism; the change must not be visual-only.\n\n---\n\n## Rule 7 — Server-side vs. client-side filtering\n\n| Dataset size | Filtering |\n|-------------|-----------|\n| ≤ 100 rows | May filter client-side |\n| Over 100 rows | **Must filter server-side** (API call with filter params) |\n\n**Same-side rule.** Filtering, sorting, and pagination must run on the **same side** — never mixed.\nIf a table filters server-side, it must also sort and paginate server-side, or it will only operate\non the current page.\n\nPersisted filters (URL / session / local storage) must be reconstructable server-side on refresh\nor deep-link. Product teams determine which of their tables are server-paginated; this rule\nprovides the threshold.\n\n---\n\n## Rule 8 — Filter + sort interaction\n\nFiltering and sorting are **independent**: applying or clearing a filter keeps the current sort,\nand sorting keeps the current filters.\n\n| Action | Effect on the other dimension |\n|--------|------------------------------|\n| Apply a filter | Current sort is maintained |\n| Clear / reset filters | Current sort is maintained |\n| Change the sort | Current filters are maintained |\n\n**Order of operations:** filter first, then sort, applied across the *entire filtered set* — not\njust the visible page.\n\nTables load with a sensible default sort (e.g. Timecards → date descending). Selected rows remain\nselected when the table is re-sorted.\n\nThe Reset → Undo snapshot captures **filters only**, not sort.\n\n---\n\n## Rule 9 — Filter + pagination interaction\n\nApplying a filter resets to **page 1** of the filtered results. Clearing or resetting filters\nresets to page 1 of the post-reset default state.\n\n**Deep-link precedence — filter wins.** When a URL contains both a filter and a page number, the\n**filter wins**: a filtered URL always loads on page 1. There is no deep-link to a specific page\nwithin a filtered view.\n\nWhen a filter resets pagination to page 1, prior-page selection is treated as cleared unless the\nproduct supports preserve-by-ID.\n\n---\n\n## Sources & references\n\n- **Hours+** — PTN-028 (filter controls), PTN-002 / CNT-013 / CNT-014 (empty states & labels), 7B (sort), 7C (pagination & performance), PTN-032 (pagination).\n- **Studio+** — data-012 (filter data structure, Redux), data-009 (empty-state trigger), form-004 (debounce).\n- **Start+ / myC&C** — PTN-009 (pagination), PTN-022 (empty state); myC&C PTN-032 (pagination).\n- **Discovery** — FILT Discovery 01–04; communicating-applied-filters handoff; filter-toolbar handoff (SPT-229); SPT-241 (Reset Filters); SPT-243 (Persistence).\n- **Spotlight KB** — SearchFilter template (`debounceMs = 300`), useDebounce hook, GridTable accessibility (WCAG 2.2 AA, `role=\"status\"`, getRowLabel).\n- NN/g — [User Intent Affects Filter Design](https://www.nngroup.com/articles/applying-filters/) · [Filters vs. Facets](https://www.nngroup.com/articles/filters-vs-facets/) · [Filtering: Design Recommendations](https://www.nngroup.com/articles/filtering-ui/)\n- IBM Carbon — [Filtering](https://carbondesignsystem.com/patterns/filtering/) & [Pagination](https://carbondesignsystem.com/patterns/pagination/) patterns.\n- AWS Cloudscape — [Empty vs. No-match states](https://cloudscape.design/patterns/general/empty-states/).\n- Shopify Polaris — [Text fields](https://polaris.shopify.com/patterns-legacy/text-fields) (search placeholder exception).\n\n---\n\n## Related patterns\n\n| Pattern | Purpose |\n|---------|---------|\n| `filter-selection-guide` | Which filter surface to use (Filter Bar vs Condensed vs Drawer) |\n| `filter-toolbar-pattern` | Toolbar anatomy, component ordering, chip row implementation |\n| `filter-drawer-pattern` | Filter Drawer anatomy, sizes, batch Apply behavior |\n| `filter-persistence-guide` | Session, URL, and saved-view persistence levels |\n| `filter-communicating-applied-guide` | Applied chips, count badge, results count, zero results |\n\n---\n\n*Pattern ID: `filter-behavior-rules` · Source: SPT-235 v1.0.0 (Finalized)*\n",
264
264
  "components": [],
265
265
  "sourcePath": "content/patterns/filter-behavior-rules.md"
266
266
  },
@@ -269,7 +269,7 @@
269
269
  "title": "Communicating Applied Filters — Spotlight Design System",
270
270
  "category": "selection",
271
271
  "description": "Defines the four layers through which the system communicates that a filtered view is active. Covers all table views with 2+ filter criteria across Hours+, Studio+, and Start+. The primary risk this pattern addresses: users forget filters are active and believe data is \"missing\" — in payroll context",
272
- "content": "# Communicating Applied Filters — Spotlight Design System\n\nDefines the four layers through which the system communicates that a filtered view is active.\nCovers all table views with 2+ filter criteria across Hours+, Studio+, and Start+. The primary\nrisk this pattern addresses: users forget filters are active and believe data is \"missing\" — in\npayroll contexts this can cause approvals to be made on an incomplete dataset.\n\nSource: SPT-241 / communicating-applied-filters v0.2.0.\n\n---\n\n## The four communication layers\n\n| Layer | Mechanism | Active when |\n|-------|-----------|------------|\n| 1 — Applied filter chips (primary) | Dismissible `Chip` components in the Chip Row | Always, when ≥1 filter active |\n| 2 — Count badge | Superscript badge on the Filters button | Chips are hidden (condensed bar, drawer closed, mobile) |\n| 3 — Results count | \"Showing X of Y timecards\" above the table | Always when data is present |\n| 4 — Zero results recovery | Filtered-empty state with Reset action | Filters return no results |\n\nAll four layers work together. Layer 1 is primary and always visible when filters are active.\nLayers 2–4 support specific conditions.\n\n---\n\n## Layer 1 — Applied filter chips (Chip Row)\n\nThe Chip Row is the **bottom row of the Filter Toolbar**, not a standalone pattern. It renders\nonly when ≥1 filter is active — it collapses to zero height (not hidden via opacity) when\nno filters are applied.\n\n### Chip specification\n\n| Property | Value |\n|----------|-------|\n| Component | `Chip` (dismissible) in `ChipGroup` (flex-wrap) |\n| Label format | `[FilterName]: [Value] ✕` — e.g. \"Status: Pending ✕\" |\n| Multi-value truncation | `[FilterName]: [Val1], [Val2] +N ✕` — e.g. \"Status: Pending, Approved +2 ✕\" |\n| Chip Row height | 28px |\n| Gap between chips | 8px (via ChipGroup flex gap) |\n| Row padding | 8px horizontal |\n\n### Chip Row elements\n\n| Element | Component | Condition |\n|---------|-----------|-----------|\n| RemovableChips | `Chip` (dismissible) in `ChipGroup` (flex-wrap) | One chip per active filter. Visible when ≥1 filter active. |\n| MaxChipCount | `ToggleButton` · label: \"+N more\" | Appears when chips exceed overflow threshold. Opens popover. Hidden when all chips fit. |\n| Reset filters | `Button` (variant: ghost) · right-aligned | Visible only when ≥1 filter deviates from page default. Hidden at default state. |\n\n### Overflow — two-stage system\n\n| Stage | Behavior | Trigger |\n|-------|----------|---------|\n| Stage 1 — Wrap | Chips flex-wrap within the Chip Row; all chips remain visible | Always (default layout behavior) |\n| Stage 2 — MaxChipCount | Overflow chips collapse; \"+N more\" ToggleButton appears | Chips would wrap beyond 2 rows, or `MaxChipCount` prop value exceeded |\n\nThe \"+N more\" ToggleButton opens a `Popover` → `MaxChipSpillOverList` → `SpillOverItem` list.\nEach overflow chip in the popover has its own ✕ dismiss action.\n\n### Condensed bar chip row differences\n\nIn the Condensed Filter Bar, the chip row elements compact:\n\n| Element | Standard | Condensed |\n|---------|----------|-----------|\n| Chip Row height | 28px | 34px |\n| MaxChipCount | 72×28px — \"+N more\" text | 32×17px — badge only, no text |\n| Reset Filters | 98×28px — \"Reset filters\" ghost button | 10×10px → **must be ≥24px target** (WCAG 2.5.8); icon-only ⊗ + tooltip + `aria-label=\"Reset filters\"` |\n\n### Motion\n\n| Animation | Duration | Motion | Reduced motion |\n|-----------|----------|--------|---------------|\n| Chip Row appear (first filter applied) | lg · 300ms | Fade + translateY(-8px) | Instant appear |\n| Chip Row dismiss (all cleared) | md · 200ms | Fade out | Instant disappear |\n| Individual chip dismiss (✕) | md · 200ms | Fade out | Instant disappear |\n| Reset filters button appear | lg · 300ms | Fade in | Instant appear |\n| Reset filters button disappear | md · 200ms | Fade out | Instant disappear |\n\nAsymmetric timing: enter animations (lg · 300ms) are intentionally slower than exit (md · 200ms).\nUsers need a moment to orient to new content; they do not need to watch it leave.\n\n---\n\n## Layer 2 — Filter count badge\n\nShows on the Filters button when the chip row is not visible (drawer closed, condensed bar,\nmobile breakpoints).\n\n| Property | Value |\n|----------|-------|\n| Position | Superscript on Filters button (top-right) |\n| Format | Number only (e.g. \"3\") — no suffix |\n| What counts | Active filter controls only — **excludes search** |\n| Badge update animation | md · 200ms — background + border + text color transition simultaneously |\n| Required size | `xs` — **not yet available in Spotlight** (gap; `sm` = 24px is minimum currently) |\n| Badge value | Applied state only — **never draft state** from an open drawer |\n\n**Gap.** The Badge component ships with `sm` (24px) and `md` (32px) only. An `xs` size is\nrequired for the condensed filter count badge. This blocks implementation of Layer 2 in condensed\nmode until the component is updated.\n\n| Show badge | Hide badge |\n|-----------|-----------|\n| Chips hidden (condensed bar, mobile, drawer closed) AND ≥1 filter applied | Chips visible (standard toolbar) |\n| — | Count = 0 |\n\n---\n\n## Layer 3 — Results count display\n\nShows the count of visible records versus total records in the current dataset.\n\n| Property | Value |\n|----------|-------|\n| Format | \"Showing [X] of [Y] [record type]\" |\n| Active count [X] | Bold · primary text color |\n| Denominator [Y] | Secondary / muted text color (always shown — communicates scope) |\n| Position | Above table, left-aligned |\n\n### Module record type labels\n\n| Module | Screen | Label |\n|--------|--------|-------|\n| Hours+ | Timecards | timecards |\n| Hours+ | Reviews | reviews |\n| Hours+ | Invoices | invoices |\n| Hours+ | Crew Lists | crew members |\n| Start+ | Timecards | timecards |\n| Start+ | Crew | crew members |\n| Start+ | Offers | offers |\n| Studio+ | (all screens) | TBD — needs audit |\n\n---\n\n## Layer 4 — Zero results state\n\nWhen filters return zero results, show a **filtered-zero state** that is distinct from the\n\"no data\" empty state. The system detects which to render — these are not interchangeable.\n\n| Variant | Trigger | Message intent |\n|---------|---------|---------------|\n| No data empty state | No records exist at all | Onboarding / create-first-item |\n| Filtered-zero state | Records exist, but filters exclude everything | Non-blaming; offer recovery |\n\n### Zero results copy pattern\n\n| Property | Value |\n|----------|-------|\n| Heading | \"No [record type] found\" |\n| Body — standard | \"No results found for [record type]. Try adjusting your filter selections.\" |\n| Body — edge case (defaults may also return empty) | \"No results match your current filters. You can reset filters or try adjusting your criteria.\" |\n| Primary action | **\"Reset filters\"** — always this label, not \"Clear all filters\" |\n\nActive filter chips remain visible above the filtered-zero empty state so users can see what\nto adjust.\n\n**Accessibility.** Announce via GridTable `role=\"status\"` hidden region.\n\n---\n\n## Reset Filters behavior\n\nReset Filters returns to the page's **default filter state** — not to an unfiltered view of all\ndata. On Hours+ Timecards, reset returns to the current pay period, not to all-time.\n\n| Page type | Default state on load | What Reset returns to |\n|-----------|----------------------|----------------------|\n| Has configured defaults | Pre-applied filters (e.g. current pay period) | Those same defaults |\n| No configured defaults | No filters — full dataset | No filters (effectively zero) |\n\n**Visibility rule.** The Reset filters button shows only when ≥1 filter deviates from the page\ndefault. It is hidden when the user is already at the default state.\n\n**Snackbar + Undo.** After Reset fires, a snackbar appears: label \"Filters reset\" · action \"Undo\"\n· 5-second auto-dismiss · `spotlight_Snackbar` (605px · `z-index: 9999` · slide-in from bottom).\nUndo restores the exact pre-reset snapshot. Requires engineering coordination on snapshot storage.\n\n### Default view vs. filtered view communication\n\n**Decision — a filter is a filter.** Default filters and user-applied filters use the same\ndismissible chip — no visual distinction between them. The distinction is communicated behaviorally:\n\n| Layer | Default state | Deviated state |\n|-------|--------------|---------------|\n| Behavioral (Reset button) | Hidden | Visible |\n| Visual (Chip) | Same chip as user-applied | Same chip as default |\n\nAn optional \"Default:\" label prefix (e.g. \"Default: Current period\") is available at designer\ndiscretion when the default value is not self-evident.\n\n---\n\n## Related patterns\n\n| Pattern | Purpose |\n|---------|---------|\n| `filter-selection-guide` | Which filter surface to use |\n| `filter-toolbar-pattern` | Toolbar anatomy and chip row implementation |\n| `filter-behavior-rules` | Rule 6 (zero results) and full behavioral rules |\n| `filter-persistence-guide` | How applied filter state survives navigation |\n| `data-display-empty-state-selection-guide` | Empty state component selection |\n\n---\n\n*Pattern ID: `filter-communicating-applied-guide` · Source: SPT-241, communicating-applied-filters v0.2.0*\n",
272
+ "content": "# Communicating Applied Filters — Spotlight Design System\n\nDefines the four layers through which the system communicates that a filtered view is active.\nCovers all table views with 2+ filter criteria across Hours+, Studio+, and Start+. The primary\nrisk this pattern addresses: users forget filters are active and believe data is \"missing\" — in\npayroll contexts this can cause approvals to be made on an incomplete dataset.\n\nSource: SPT-241 / communicating-applied-filters v0.2.0.\n\n---\n\n## The four communication layers\n\n| Layer | Mechanism | Active when |\n|-------|-----------|------------|\n| 1 — Applied filter chips (primary) | Dismissible `Chip` components in the Chip Row | Always, when ≥1 filter active |\n| 2 — Count badge | Superscript badge on the Filters button | Chips are hidden (condensed bar, drawer closed, mobile) |\n| 3 — Results count | \"Showing X of Y timecards\" above the table | Always when data is present |\n| 4 — Zero results recovery | Filtered-empty state with Reset action | Filters return no results |\n\nAll four layers work together. Layer 1 is primary and always visible when filters are active.\nLayers 2–4 support specific conditions.\n\n---\n\n## Layer 1 — Applied filter chips (Chip Row)\n\nThe Chip Row is the **bottom row of the Filter Toolbar**, not a standalone pattern. It renders\nonly when ≥1 filter is active — it collapses to zero height (not hidden via opacity) when\nno filters are applied.\n\n### Chip specification\n\n| Property | Value |\n|----------|-------|\n| Component | `Chip` (dismissible) in `ChipGroup` (flex-wrap) |\n| Label format | `[FilterName]: [Value] ✕` — e.g. \"Status: Pending ✕\" |\n| Multi-value truncation | `[FilterName]: [Val1], [Val2] +N ✕` — e.g. \"Status: Pending, Approved +2 ✕\" |\n| Chip Row height | 28px |\n| Gap between chips | 8px (via ChipGroup flex gap) |\n| Row padding | 8px horizontal |\n\n### Chip Row elements\n\n| Element | Component | Condition |\n|---------|-----------|-----------|\n| RemovableChips | `Chip` (dismissible) in `ChipGroup` (flex-wrap) | One chip per active filter. Visible when ≥1 filter active. |\n| MaxChipCount | `ToggleButton` · label: \"+N more\" | Appears when chips exceed overflow threshold. Opens popover. Hidden when all chips fit. |\n| Reset filters | `Button` (variant: ghost) · right-aligned | Visible only when ≥1 filter deviates from page default. Hidden at default state. |\n\n### Overflow — two-stage system\n\n| Stage | Behavior | Trigger |\n|-------|----------|---------|\n| Stage 1 — Wrap | Chips flex-wrap within the Chip Row; all chips remain visible | Always (default layout behavior) |\n| Stage 2 — MaxChipCount | Overflow chips collapse; \"+N more\" ToggleButton appears | Chips would wrap beyond 2 rows, or `MaxChipCount` prop value exceeded |\n\nThe \"+N more\" ToggleButton opens a `Popover` → `MaxChipSpillOverList` → `SpillOverItem` list.\nEach overflow chip in the popover has its own ✕ dismiss action.\n\n### Condensed bar chip row differences\n\nIn the Condensed Filter Bar, the chip row elements compact:\n\n| Element | Standard | Condensed |\n|---------|----------|-----------|\n| Chip Row height | 28px | 34px |\n| MaxChipCount | 72×28px — \"+N more\" text | 32×17px — badge only, no text |\n| Reset Filters | 98×28px — \"Reset filters\" ghost button | 10×10px → **must be ≥24px target** (WCAG 2.5.8); icon-only ⊗ + tooltip + `aria-label=\"Reset filters\"` |\n\n### Motion\n\n| Animation | Duration | Motion | Reduced motion |\n|-----------|----------|--------|---------------|\n| Chip Row appear (first filter applied) | lg · 300ms | Fade + translateY(-8px) | Instant appear |\n| Chip Row dismiss (all cleared) | md · 200ms | Fade out | Instant disappear |\n| Individual chip dismiss (✕) | md · 200ms | Fade out | Instant disappear |\n| Reset filters button appear | lg · 300ms | Fade in | Instant appear |\n| Reset filters button disappear | md · 200ms | Fade out | Instant disappear |\n\nAsymmetric timing: enter animations (lg · 300ms) are intentionally slower than exit (md · 200ms).\nUsers need a moment to orient to new content; they do not need to watch it leave.\n\n---\n\n## Toolbar visual state change (AC 3 — pending decision)\n\nHow the toolbar itself communicates a filtered state — beyond the presence of chips — has not\nbeen decided. No internal precedent confirmed across Hours+, Studio+, or Start+.\n\n| Option | Treatment | Complexity |\n|--------|-----------|-----------|\n| **A** | Chip row presence is sufficient — no additional toolbar-level treatment | Low |\n| **B** | Subtle brand-colored border on the toolbar container when filters are active | Low–Med |\n| **C** | Light background tint on toolbar container when filters are active | Med |\n\n**Status: Pending design decision.** This AC cannot be specced or implemented until a direction\nis chosen.\n\n---\n\n## Layer 2 — Filter count badge\n\nShows on the Filters button when the chip row is not visible (drawer closed, condensed bar,\nmobile breakpoints).\n\n| Property | Value |\n|----------|-------|\n| Position | Superscript on Filters button (top-right) |\n| Format | Number only (e.g. \"3\") — no suffix |\n| What counts | Active filter controls only — **excludes search** |\n| Badge update animation | md · 200ms — background + border + text color transition simultaneously |\n| Required size | `xs` — **not yet available in Spotlight** (gap; `sm` = 24px is minimum currently) |\n| Badge value | Applied state only — **never draft state** from an open drawer |\n\n**Gap.** The Badge component ships with `sm` (24px) and `md` (32px) only. An `xs` size is\nrequired for the condensed filter count badge. This blocks implementation of Layer 2 in condensed\nmode until the component is updated.\n\n| Show badge | Hide badge |\n|-----------|-----------|\n| Chips hidden (condensed bar, mobile, drawer closed) AND ≥1 filter applied | Chips visible (standard toolbar) |\n| — | Count = 0 |\n\n### Persistence of indicators (AC 6)\n\n| Condition | Chips | Count Badge |\n|-----------|-------|------------|\n| Standard toolbar — filters active | Visible in Chip Row | Hidden |\n| Filter drawer open | Visible in Chip Row | Hidden |\n| Filter drawer closed | Visible in Chip Row | Hidden (chips visible) |\n| Condensed bar state | Hidden | Visible on Filters button |\n| Mobile / tablet breakpoint | Visible; drawer collapses progressively | Shows when chips hidden |\n| No filters active | Chip Row collapses to zero height | Hidden |\n\n---\n\n## Layer 3 — Results count display\n\nShows the count of visible records versus total records in the current dataset.\n\n| Property | Value |\n|----------|-------|\n| Format | \"Showing [X] of [Y] [record type]\" |\n| Active count [X] | Bold · primary text color |\n| Denominator [Y] | Secondary / muted text color (always shown — communicates scope) |\n| Position | Above table, left-aligned |\n\n### Module record type labels\n\n| Module | Screen | Label |\n|--------|--------|-------|\n| Hours+ | Timecards | timecards |\n| Hours+ | Reviews | reviews |\n| Hours+ | Invoices | invoices |\n| Hours+ | Crew Lists | crew members |\n| Start+ | Timecards | timecards |\n| Start+ | Crew | crew members |\n| Start+ | Offers | offers |\n| Studio+ | (all screens) | TBD — needs audit |\n\n---\n\n## Layer 4 — Zero results state\n\nWhen filters return zero results, show a **filtered-zero state** that is distinct from the\n\"no data\" empty state. The system detects which to render — these are not interchangeable.\n\n| Variant | Trigger | Message intent |\n|---------|---------|---------------|\n| No data empty state | No records exist at all | Onboarding / create-first-item |\n| Filtered-zero state | Records exist, but filters exclude everything | Non-blaming; offer recovery |\n\n### Zero results copy pattern\n\n| Property | Value |\n|----------|-------|\n| Heading | \"No [record type] found\" |\n| Body — standard | \"No results found for [record type]. Try adjusting your filter selections.\" |\n| Body — edge case (defaults may also return empty) | \"No results match your current filters. You can reset filters or try adjusting your criteria.\" |\n| Primary action | **\"Reset filters\"** — always this label, not \"Clear all filters\" |\n\nActive filter chips remain visible above the filtered-zero empty state so users can see what\nto adjust.\n\n**Accessibility.** Announce via GridTable `role=\"status\"` hidden region.\n\n---\n\n## Reset Filters behavior\n\nReset Filters returns to the page's **default filter state** — not to an unfiltered view of all\ndata. On Hours+ Timecards, reset returns to the current pay period, not to all-time.\n\n| Page type | Default state on load | What Reset returns to |\n|-----------|----------------------|----------------------|\n| Has configured defaults | Pre-applied filters (e.g. current pay period) | Those same defaults |\n| No configured defaults | No filters — full dataset | No filters (effectively zero) |\n\n**Visibility rule.** The Reset filters button shows only when ≥1 filter deviates from the page\ndefault. It is hidden when the user is already at the default state.\n\n**Snackbar + Undo.** After Reset fires, a snackbar appears: label \"Filters reset\" · action \"Undo\"\n· 5-second auto-dismiss · `spotlight_Snackbar` (605px · `z-index: 9999` · slide-in from bottom).\nUndo restores the exact pre-reset snapshot. Requires engineering coordination on snapshot storage.\n\n### Default view vs. filtered view communication\n\n**Decision — a filter is a filter.** Default filters and user-applied filters use the same\ndismissible chip — no visual distinction between them. The distinction is communicated behaviorally:\n\n| Layer | Default state | Deviated state |\n|-------|--------------|---------------|\n| Behavioral (Reset button) | Hidden | Visible |\n| Visual (Chip) | Same chip as user-applied | Same chip as default |\n\nAn optional \"Default:\" label prefix (e.g. \"Default: Current period\") is available at designer\ndiscretion when the default value is not self-evident.\n\n### Per-module default filter inventory\n\nLayer 1 (Reset button visibility) cannot be implemented without this inventory complete. Each\npage must be classified as: **Has defaults** (filters applied on load) or **Zero state** (no\nfilters on load).\n\n| Module | Screen | Default filter on load | Confirmed? |\n|--------|--------|----------------------|-----------|\n| Hours+ | Timecards | Current pay period | ✅ |\n| Hours+ | All other tables | Unknown | ❌ Needs audit |\n| Start+ | All tables | Unknown | ❌ Needs audit |\n| Studio+ | All tables | Unknown | ❌ Needs audit |\n\n---\n\n## Open items & gaps\n\n| Item | Status | Blocks | Owner |\n|------|--------|--------|-------|\n| **Chip color treatment** — brand + light prominence direction given, token mapping unconfirmed | Open | Layer 1 visual spec · AC 7 Layer 2 | Adanma to confirm |\n| **Count badge color treatment** — same open question as chip color | Open | Layer 2 visual spec | Confirm alongside chip color |\n| **AC 3 toolbar visual state change** — Options A, B, or C not yet selected | Pending decision | AC 3 spec + implementation | Team design decision |\n| **Per-module default filter inventory** — Hours+ Timecards only confirmed | TBD | AC 7 Layer 1 implementation | Audit required |\n| **Badge xs size** — does not exist in Spotlight component library | Gap | Layer 2 implementation | New component size ticket needed |\n| **Studio+ record types** — filter-context record type labels unconfirmed | TBD | Layer 3 + Layer 4 Studio+ copy | Studio+ audit |\n| **\"Reset Filters\" vs \"Reset All\" label inconsistency** in filter-toolbar-handoff v0.2.0 | Open | Toolbar doc accuracy | Settled label is \"Reset filters\" per pattern--reset-filters.md Rule 1 |\n\n---\n\n## Related patterns\n\n| Pattern | Purpose |\n|---------|---------|\n| `filter-selection-guide` | Which filter surface to use |\n| `filter-toolbar-pattern` | Toolbar anatomy and chip row implementation |\n| `filter-behavior-rules` | Rule 6 (zero results) and full behavioral rules |\n| `filter-persistence-guide` | How applied filter state survives navigation |\n| `data-display-empty-state-selection-guide` | Empty state component selection |\n\n---\n\n*Pattern ID: `filter-communicating-applied-guide` · Source: SPT-241, communicating-applied-filters v0.2.0*\n",
273
273
  "components": [],
274
274
  "sourcePath": "content/patterns/filter-communicating-applied-guide.md"
275
275
  },
@@ -278,10 +278,19 @@
278
278
  "title": "Filter Persistence Rules — Spotlight Design System",
279
279
  "category": "selection",
280
280
  "description": "Defines how long filter state lives, which tables warrant which persistence level, when filters must be destroyed, and how to encode filter state safely in URLs. Covers Hours+, Studio+, Start+, and myC&C. Source: SPT-243 v0.9.0.",
281
- "content": "# Filter Persistence Rules — Spotlight Design System\n\nDefines how long filter state lives, which tables warrant which persistence level, when filters\nmust be destroyed, and how to encode filter state safely in URLs. Covers Hours+, Studio+, Start+,\nand myC&C. Source: SPT-243 v0.9.0.\n\n---\n\n## Persistence levels\n\nFour levels are available. They are hierarchical and additive — Level 2 implies Level 1; Level 3\nimplies Levels 1 and 2. All levels govern *applied* filter state only. Draft state (selections\nin an open Filter Drawer before Apply) is sub-L1 and never URL-persisted or saved.\n\n| Level | Mechanism | Survives | Resets when | Mandate |\n|-------|-----------|---------|-------------|---------|\n| **L0 — None** | Component state only | Nothing — resets on any navigation | Any navigation | Restricted — use only for one-time decision flows (modal, export dialog). Never the default for a list screen. |\n| **L1 — Session** | Redux / React Context per-table slice | In-app SPA navigation (route changes, detail→list return) | Page refresh AND tab close | **Mandatory baseline — every filterable table** |\n| **L2 — URL params** | Query string `?status=pending&from=...` | Page refresh · bookmarking · link sharing | URL cleared · context switch · Reset filters | **Required for all primary list pages** |\n| **L3 — Saved views** | Server-side per user (FILT-10) — not localStorage | Across sessions and devices | User deactivates · context switch · deletion | Opt-in only — eligible tables only (see §Saved views) |\n\n**L1 note.** Redux clears on both page refresh AND tab close — not just tab close.\n\n**L3 note.** Server-side saved views (not localStorage). localStorage is device-bound, silently\nclearable, and unsafe on shared workstations. All enterprise comparators (Oracle, SAP Fiori,\nMicrosoft D365) implement cross-session persistence server-side.\n\n---\n\n## Decision matrix — which persistence level for a table\n\nEvaluate dimensions in this order:\n\n1. **Shareability (C)** — if team coordination is needed, L2 is required regardless of all else.\n2. **Workflow type (B override)** — if operational or batch, cap at L1 regardless of all else.\n3. **Visit frequency (A)** — daily-use tables get higher investment.\n4. **Data sensitivity (D)** — adds encoding constraints only; never changes the level assigned.\n\n| Visit frequency | Workflow | Shareability | Recommended level | C&C examples |\n|----------------|----------|-------------|-------------------|-------------|\n| Frequent (daily) | Review / approval | Team | L1 + L2 + L3 | Hours+ Timecards Admin · Studio+ Invoice list |\n| Frequent (daily) | Review / approval | Personal | L1 + L2 · L3 candidate | Hours+ Crew Timecards |\n| Any | Any | Team | L1 + L2 required | Hours+ Reviews · Reports · Start+ Offers |\n| Any | **Operational / batch** | Any | **L1 only** (B override) | Hours+ Bulk Edit |\n| Occasional | One-time lookup | Personal | L1 only | Hours+ Crew Lists |\n\n---\n\n## L1 — Session persistence: what persists and when it clears\n\n**What \"filters intact\" includes.**\n\n| Element | Persists? |\n|---------|----------|\n| All applied filter values | ✅ Yes |\n| Search text | ✅ Yes |\n| Sort order | ✅ Yes |\n| Filter chip display | ✅ Yes |\n| Badge count (Condensed) | ✅ Yes |\n| Draft state (Condensed Filter Drawer before Apply) | ❌ No — component-memory only |\n\n**When session state clears.**\n\n| Trigger | Clears L1? |\n|---------|-----------|\n| Page refresh (F5 / hard reload) | ✅ Yes — Redux clears |\n| Tab close | ✅ Yes — Redux clears |\n| Data context change (project / org switch) | ✅ Yes — explicit dispatch required |\n| User clicks Reset filters | ✅ Yes — returns to page default |\n| Route change within same context (SPA navigation) | ❌ No |\n| View type switch (table→cards) | ❌ No — filter state is dataset-level |\n\n**Implementation.** Per-table Redux slice (e.g. `timecardFiltersSlice`). Each table has its own\nindependent slice. The list component reads from its slice on every mount.\n\n---\n\n## L2 — URL param persistence: encoding guide\n\n**Bidirectional sync is required.** Filter changes → URL updates. URL loaded → Redux initializes.\nBoth directions must be wired. On page load, URL search params initialize the Redux slice;\ninvalid or stale params are silently discarded and fall back to page defaults.\n\n**Encoding rules.**\n\n| Filter type | Safe for URL? | Encoding | Chip label |\n|-------------|--------------|---------|-----------|\n| Status values | ✅ Safe | `?status=pending` · multi: `?status=pending,approved` | Status: Pending |\n| Date ranges | ✅ Safe | `?from=2025-11-01&to=2025-11-30` (ISO 8601) | Date: Nov 1–30 |\n| Employee names | ⚠ PII — use ID | `?employee=usr_1234` — never `?employee=John+Smith` | Employee: John Smith (resolved from ID) |\n| Department | ⚠ Context-dependent | System enum: `?dept=camera`; user-named: use dept ID | Department: Camera |\n| Project identifiers | ⚠ Use ID | `?project=proj_001` | Project: [Name] |\n| Search text | ⚠ Depends | `?q=camera` for non-PII; treat as PII if query contains names | Search: \"camera\" |\n| Draft state | ❌ Never encode | Draft state must never appear in URL | N/A |\n\n**L2 behavior rules.**\n\n- **Rule 1 — Update on every committed change.** Filter Bar: URL updates on each individual\n filter change. Condensed Filter Bar: URL updates only when Apply is clicked — not during drawer\n selection. Prevents half-applied states from being bookmarked.\n- **Rule 2 — Reset clears URL params.** When Reset filters fires, both Redux and URL params clear\n simultaneously. URL returns to base path with no query string.\n- **Rule 3 — Context switch clears URL params.** On project/org switch, URL params clear on the\n same navigation event. The destination URL contains no filter query string.\n- **Rule 4 — Access control not bypassed.** A shared URL delivers the filtered view only if the\n recipient has permission. Validate all URL params before processing.\n- **Rule 5 — One collection per URL.** Only apply URL persistence when a single filterable\n collection is clearly the primary page content. On pages with multiple tables, use session-only.\n\n---\n\n## When filters must reset — context change rule\n\n**Data integrity requirement.** Filters must reset when the data context changes, to prevent\nstale filters from silently narrowing a dataset the user believes is complete. In payroll and\nproduction accounting, a silently narrowed dataset is a compliance and financial risk.\n\n**Triggers that always reset.**\n\n| Navigation event | Always resets? |\n|-----------------|---------------|\n| Project switch | ✅ Always — no project shares valid filter state with another |\n| Organization switch | ✅ Always — all filter values are org-scoped |\n\n**What does NOT trigger a reset.**\n\n| Navigation event | Resets? | Reason |\n|-----------------|---------|--------|\n| Detail view → list return | ❌ No | In-context navigation (L1 baseline case) |\n| Tab switch within a table (Active / Archived) | ❌ No | Same dataset, different view |\n| View type switch (table → cards → list) | ❌ No | On-page filter state is dataset-level |\n| Page refresh at L2 | ❌ No | URL carries filter state — by design |\n\n**All levels must clear in the same navigation event.**\n\n| Level | Clear action |\n|-------|-------------|\n| L1 | `store.dispatch(clearFilters({ tableId }))` |\n| L2 | Navigate to destination URL with no query params |\n| L3 | `setActiveSavedView(null)` — deactivated, not deleted |\n| Draft state | Drawer closes; `clearDraftState()` fires |\n\nNo undo snackbar for context-switch resets. An undo mechanism would reintroduce the stale filter\nproblem this rule exists to prevent.\n\n---\n\n## Filter state and view-type switches\n\n**The rule.** Applied on-page filter state persists across view-type changes (table → cards →\nlist). On-page filters operate on the dataset, not on how data is displayed. Switching view type\ndoes not reset, modify, or re-evaluate applied filters.\n\nThis rule applies at every persistence level — the Redux slice belongs to the data domain, not\nthe view type. Filter URL params and saved views are independent of view type preference.\n\n---\n\n## Default filter state per table\n\nEvery filterable table exists in one of three states. \"Reset filters\" returns to State 1 (page\ndefault), not State 3 (zero).\n\n| State 1 — Default filter state | State 2 — User-deviated | State 3 — Zero filter state |\n|-------------------------------|------------------------|----------------------------|\n| Configured starting view. Active filters on load (e.g. current pay period). Chips visible. Reset button **hidden**. | User has changed ≥1 filter. Reset button **visible**. Returns to State 1. | No filters active. All records visible. Only default for tables with no configured defaults. |\n\n**Known defaults (Hours+):**\n- Timecards Admin (SCR-002): current pay period ✅\n- Bulk Edit (SCR-005): no default — zero state ✅\n- Reports (SCR-008): no default — zero state ✅\n- All others: unconfirmed — audit required before implementing Reset button visibility logic.\n\n---\n\n## Saved views — power user persistence (L3)\n\n**Eligibility.** Saved views are only offered on tables meeting both criteria:\n1. 4 or more filter controls, AND\n2. Visited daily by the relevant role.\n\n| Table | Controls | Daily? | Eligible? |\n|-------|---------|--------|----------|\n| Hours+ Timecards Admin (SCR-002) | 5 | Yes | ✅ Yes |\n| Studio+ Invoice list | 6–7 | Yes (production accountants) | ✅ Yes |\n| Hours+ Crew Timecards (SCR-004) | 4 | Yes | ✅ Yes |\n| Hours+ Reviews (SCR-006) | 3–5 | Per pay period (not daily) | ⚠ Borderline |\n| All other tables | 1–4 | Varies | ❌ L1 + L2 only |\n\n**Pattern.** Named saved views (multiple named filter combinations per table, one optionally as\nthe personal default) — not a binary \"Remember my filters\" toggle. Implemented server-side via\nFILT-10.\n\n**Reset behavior when saved view is active.** \"Reset filters\" returns to the system page default\n(recommended Option A), not to the saved view state.\n\n---\n\n## Related patterns\n\n| Pattern | Purpose |\n|---------|---------|\n| `filter-selection-guide` | Which filter surface to use |\n| `filter-behavior-rules` | Rule 7 (server/client threshold), Rule 9 (pagination reset) |\n| `filter-toolbar-pattern` | Toolbar implementation and chip row |\n| `filter-communicating-applied-guide` | Applied chips and reset behavior |\n\n---\n\n*Pattern ID: `filter-persistence-guide` · Source: SPT-243 v0.9.0*\n",
281
+ "content": "# Filter Persistence Rules — Spotlight Design System\n\nDefines how long filter state lives, which tables warrant which persistence level, when filters\nmust be destroyed, and how to encode filter state safely in URLs. Covers Hours+, Studio+, Start+,\nand myC&C. Source: SPT-243 v0.9.0.\n\n---\n\n## Persistence levels\n\nFour levels are available. They are hierarchical and additive — Level 2 implies Level 1; Level 3\nimplies Levels 1 and 2. All levels govern *applied* filter state only. Draft state (selections\nin an open Filter Drawer before Apply) is sub-L1 and never URL-persisted or saved.\n\n| Level | Mechanism | Survives | Resets when | Mandate |\n|-------|-----------|---------|-------------|---------|\n| **L0 — None** | Component state only | Nothing — resets on any navigation | Any navigation | Restricted — use only for one-time decision flows (modal, export dialog). Never the default for a list screen. |\n| **L1 — Session** | Redux / React Context per-table slice | In-app SPA navigation (route changes, detail→list return) | Page refresh AND tab close | **Mandatory baseline — every filterable table** |\n| **L2 — URL params** | Query string `?status=pending&from=...` | Page refresh · bookmarking · link sharing | URL cleared · context switch · Reset filters | **Required for all primary list pages** |\n| **L3 — Saved views** | Server-side per user (FILT-10) — not localStorage | Across sessions and devices | User deactivates · context switch · deletion | Opt-in only — eligible tables only (see §Saved views) |\n\n**L1 note.** Redux clears on both page refresh AND tab close — not just tab close.\n\n**L3 note.** Server-side saved views (not localStorage). localStorage is device-bound, silently\nclearable, and unsafe on shared workstations. All enterprise comparators (Oracle, SAP Fiori,\nMicrosoft D365) implement cross-session persistence server-side.\n\n---\n\n## Decision matrix — which persistence level for a table\n\nEvaluate dimensions in this order:\n\n1. **Shareability (C)** — if team coordination is needed, L2 is required regardless of all else.\n2. **Workflow type (B override)** — if operational or batch, cap at L1 regardless of all else.\n3. **Visit frequency (A)** — daily-use tables get higher investment.\n4. **Data sensitivity (D)** — adds encoding constraints only; never changes the level assigned.\n\n| Visit frequency | Workflow | Shareability | Recommended level | C&C examples |\n|----------------|----------|-------------|-------------------|-------------|\n| Frequent (daily) | Review / approval | Team | L1 + L2 + L3 | Hours+ Timecards Admin · Studio+ Invoice list |\n| Frequent (daily) | Review / approval | Personal | L1 + L2 · L3 candidate | Hours+ Crew Timecards |\n| Any | Any | Team | L1 + L2 required | Hours+ Reviews · Reports · Start+ Offers |\n| Any | **Operational / batch** | Any | **L1 only** (B override) | Hours+ Bulk Edit |\n| Occasional | One-time lookup | Personal | L1 only | Hours+ Crew Lists |\n\n---\n\n## L1 — Session persistence: what persists and when it clears\n\n**What \"filters intact\" includes.**\n\n| Element | Persists? |\n|---------|----------|\n| All applied filter values | ✅ Yes |\n| Search text | ✅ Yes |\n| Sort order | ✅ Yes |\n| Filter chip display | ✅ Yes |\n| Badge count (Condensed) | ✅ Yes |\n| Draft state (Condensed Filter Drawer before Apply) | ❌ No — component-memory only |\n\n**When session state clears.**\n\n| Trigger | Clears L1? |\n|---------|-----------|\n| Page refresh (F5 / hard reload) | ✅ Yes — Redux clears |\n| Tab close | ✅ Yes — Redux clears |\n| Data context change (project / org switch) | ✅ Yes — explicit dispatch required |\n| User clicks Reset filters | ✅ Yes — returns to page default |\n| Route change within same context (SPA navigation) | ❌ No |\n| View type switch (table→cards) | ❌ No — filter state is dataset-level |\n\n**Implementation.** Per-table Redux slice (e.g. `timecardFiltersSlice`). Each table has its own\nindependent slice. The list component reads from its slice on every mount.\n\n---\n\n## L2 — URL param persistence: encoding guide\n\n**Bidirectional sync is required.** Filter changes → URL updates. URL loaded → Redux initializes.\nBoth directions must be wired. On page load, URL search params initialize the Redux slice;\ninvalid or stale params are silently discarded and fall back to page defaults.\n\n**Encoding rules.**\n\n| Filter type | Safe for URL? | Encoding | Chip label |\n|-------------|--------------|---------|-----------|\n| Status values | ✅ Safe | `?status=pending` · multi: `?status=pending,approved` | Status: Pending |\n| Date ranges | ✅ Safe | `?from=2025-11-01&to=2025-11-30` (ISO 8601) | Date: Nov 1–30 |\n| Employee names | ⚠ PII — use ID | `?employee=usr_1234` — never `?employee=John+Smith` | Employee: John Smith (resolved from ID) |\n| Department | ⚠ Context-dependent | System enum: `?dept=camera`; user-named: use dept ID | Department: Camera |\n| Project identifiers | ⚠ Use ID | `?project=proj_001` | Project: [Name] |\n| Search text | ⚠ Depends | `?q=camera` for non-PII; treat as PII if query contains names | Search: \"camera\" |\n| Draft state | ❌ Never encode | Draft state must never appear in URL | N/A |\n\n**L2 behavior rules.**\n\n- **Rule 1 — Update on every committed change.** Filter Bar: URL updates on each individual\n filter change. Condensed Filter Bar: URL updates only when Apply is clicked — not during drawer\n selection. Prevents half-applied states from being bookmarked.\n- **Rule 2 — Reset clears URL params.** When Reset filters fires, both Redux and URL params clear\n simultaneously. URL returns to base path with no query string.\n- **Rule 3 — Context switch clears URL params.** On project/org switch, URL params clear on the\n same navigation event. The destination URL contains no filter query string.\n- **Rule 4 — Access control not bypassed.** A shared URL delivers the filtered view only if the\n recipient has permission. Validate all URL params before processing.\n- **Rule 5 — One collection per URL.** Only apply URL persistence when a single filterable\n collection is clearly the primary page content. On pages with multiple tables, use session-only.\n\n---\n\n## When filters must reset — context change rule\n\n**Data integrity requirement.** Filters must reset when the data context changes, to prevent\nstale filters from silently narrowing a dataset the user believes is complete. In payroll and\nproduction accounting, a silently narrowed dataset is a compliance and financial risk.\n\n**Triggers that always reset.**\n\n| Navigation event | Always resets? |\n|-----------------|---------------|\n| Project switch | ✅ Always — no project shares valid filter state with another |\n| Organization switch | ✅ Always — all filter values are org-scoped |\n\n**Triggers requiring team decision.**\n\n| Navigation event | Status |\n|-----------------|--------|\n| **Role switch within project** | ⚠ Undecided — resets when role change narrows the accessible dataset. Requires per-role data access inventory before this can be codified. |\n| **Cross-table nav (same project)** | ⚠ Undecided — resets when filter dimensions are incompatible between tables. Requires per-table-pair filter dimension inventory. |\n\n**What does NOT trigger a reset.**\n\n| Navigation event | Resets? | Reason |\n|-----------------|---------|--------|\n| Detail view → list return | ❌ No | In-context navigation (L1 baseline case) |\n| Tab switch within a table (Active / Archived) | ❌ No | Same dataset, different view |\n| View type switch (table → cards → list) | ❌ No | On-page filter state is dataset-level |\n| Page refresh at L2 | ❌ No | URL carries filter state — by design |\n\n**All levels must clear in the same navigation event.**\n\n| Level | Clear action |\n|-------|-------------|\n| L1 | `store.dispatch(clearFilters({ tableId }))` |\n| L2 | Navigate to destination URL with no query params |\n| L3 | `setActiveSavedView(null)` — deactivated, not deleted |\n| Draft state | Drawer closes; `clearDraftState()` fires |\n\nNo undo snackbar for context-switch resets. An undo mechanism would reintroduce the stale filter\nproblem this rule exists to prevent.\n\n---\n\n## Filter state and view-type switches\n\n**The rule.** Applied on-page filter state persists across view-type changes (table → cards →\nlist). On-page filters operate on the dataset, not on how data is displayed. Switching view type\ndoes not reset, modify, or re-evaluate applied filters.\n\nThis rule applies at every persistence level — the Redux slice belongs to the data domain, not\nthe view type. Filter URL params and saved views are independent of view type preference.\n\n---\n\n## Default filter state per table\n\nEvery filterable table exists in one of three states. \"Reset filters\" returns to State 1 (page\ndefault), not State 3 (zero).\n\n| State 1 — Default filter state | State 2 — User-deviated | State 3 — Zero filter state |\n|-------------------------------|------------------------|----------------------------|\n| Configured starting view. Active filters on load (e.g. current pay period). Chips visible. Reset button **hidden**. | User has changed ≥1 filter. Reset button **visible**. Returns to State 1. | No filters active. All records visible. Only default for tables with no configured defaults. |\n\n**🚨 Blocking prerequisite.** The Reset button visibility rule cannot be implemented until this\ninventory is complete. Product team must confirm or define the default filter state for every\nfilterable table.\n\n| Product | Table | Default filter on load | Confirmed? |\n|---------|-------|----------------------|-----------|\n| Hours+ | Timecards Admin (SCR-002) | Current pay period | ✅ PTN-028 |\n| Hours+ | My Timecards (SCR-001) | Current pay period (likely) | ⚠ Needs confirm |\n| Hours+ | Daily Timesheets (SCR-003) | Today (likely) | ⚠ Needs confirm |\n| Hours+ | Crew Timecards (SCR-004) | Current pay period (likely) | ⚠ Needs confirm |\n| Hours+ | Bulk Edit (SCR-005) | No default — zero state | ✅ Operational screen |\n| Hours+ | Reviews (SCR-006) | Current pay period + Pending status (likely) | ⚠ Needs confirm |\n| Hours+ | Invoices (SCR-007) | Unknown | ❌ Needs product team input |\n| Hours+ | Reports (SCR-008) | No default — zero state | ✅ Ad hoc screen |\n| Hours+ | Crew Lists (SCR-009) | Active status only (likely) | ⚠ Needs confirm |\n| Studio+ | Invoice list | Unknown | ❌ Needs product team input |\n| Studio+ | Search page | No default — zero state | ✅ Exploratory |\n| Start+ | Offers | Unknown | ❌ Needs product team input |\n| Start+ | Timecards | Unknown | ❌ Needs product team input |\n| Start+ | Crew | Unknown | ❌ Needs product team input |\n\n---\n\n## Saved views — power user persistence (L3)\n\n**Eligibility.** Saved views are only offered on tables meeting both criteria:\n1. 4 or more filter controls, AND\n2. Visited daily by the relevant role.\n\n| Table | Controls | Daily? | Eligible? |\n|-------|---------|--------|----------|\n| Hours+ Timecards Admin (SCR-002) | 5 | Yes | ✅ Yes |\n| Studio+ Invoice list | 6–7 | Yes (production accountants) | ✅ Yes |\n| Hours+ Crew Timecards (SCR-004) | 4 | Yes | ✅ Yes |\n| Hours+ Reviews (SCR-006) | 3–5 | Per pay period (not daily) | ⚠ Borderline |\n| All other tables | 1–4 | Varies | ❌ L1 + L2 only |\n\n**Pattern.** Named saved views (multiple named filter combinations per table, one optionally as\nthe personal default) — not a binary \"Remember my filters\" toggle. Implemented server-side via\nFILT-10.\n\n**Reset behavior when saved view is active.** \"Reset filters\" returns to the system page default\n(recommended Option A), not to the saved view state.\n\n**Open decisions for saved views (§08 in progress — pending ticket author confirmation).**\n\n| # | Decision | Status |\n|---|----------|--------|\n| 1 | Server-side saved views (FILT-10) as the mechanism — localStorage ruled out? | ⏳ Pending |\n| 2 | Named saved views pattern (replacing \"Remember my filters\" toggle)? | ⏳ Pending |\n| 3 | When a saved view is active, does Reset return to (A) system page default or (B) saved view state? Recommendation: Option A. | ⏳ Pending |\n| 4 | Confirm FILT_Discovery_04 threshold (4+ controls, daily use) as the eligibility gate. | ⏳ Pending |\n| 5 | Confirm v1 FILT-10 scope: filter state only (not sort, column visibility); per-user, per-table, private by default. | ⏳ Pending |\n\n---\n\n## Open questions\n\n| Question | Status |\n|----------|--------|\n| **Pagination on return from detail** — preserve the user's page number or reset to page 1? Both are defensible; decision needed before filter toolbar implementation begins. (SPT-229 §9 resets on filter change but detail→list is not a filter change.) | ⚠ Undecided |\n| **Role switch reset criteria** — requires per-role data access inventory to define which role transitions constitute a data domain change. | ⚠ Undecided |\n| **Cross-table navigation reset pairs** — requires per-pair filter dimension inventory for Hours+, Start+, Studio+. | ⚠ Undecided |\n\n---\n\n## Related patterns\n\n| Pattern | Purpose |\n|---------|---------|\n| `filter-selection-guide` | Which filter surface to use |\n| `filter-behavior-rules` | Rule 7 (server/client threshold), Rule 9 (pagination reset) |\n| `filter-toolbar-pattern` | Toolbar implementation and chip row |\n| `filter-communicating-applied-guide` | Applied chips and reset behavior |\n\n---\n\n*Pattern ID: `filter-persistence-guide` · Source: SPT-243 v0.9.0*\n",
282
282
  "components": [],
283
283
  "sourcePath": "content/patterns/filter-persistence-guide.md"
284
284
  },
285
+ {
286
+ "id": "filter-scaling-rules",
287
+ "title": "Filter Scaling Rules — Spotlight Design System",
288
+ "category": "selection",
289
+ "description": "Decision framework for how filter toolbars scale as complexity increases — from a 2-filter employee table to a 10-filter advanced admin view. Authoritative reference for which filter surface to use, when to use it, and how its components behave. Source: SPT-262 v1.0.0.",
290
+ "content": "---\nid: filter-scaling-rules\ntitle: Filter Scaling Rules\ncategory: selection\ntags: [filter, scaling, condensed, toolbar, drawer, role-based]\n---\n\n# Filter Scaling Rules — Spotlight Design System\n\nDecision framework for how filter toolbars scale as complexity increases — from a 2-filter\nemployee table to a 10-filter advanced admin view. Authoritative reference for which filter\nsurface to use, when to use it, and how its components behave. Source: SPT-262 v1.0.0.\n\n---\n\n## Quick reference\n\n| Question | Section |\n|----------|---------|\n| Which surface do I use for my screen? | [The Two Surfaces + Drawer](#the-two-surfaces--standalone-drawer) |\n| How do I count my filters? | [Filter Counting Rules](#filter-counting-rules) |\n| What are the condensed component specs? | [Condensed Filter Bar Anatomy](#condensed-filter-bar-anatomy--component-specs) |\n| How does the Filter Drawer size? | [Filter Drawer](#filter-drawer) |\n| How do the bar and drawer work together? | [Composition Model](#condensed-bar--drawer-composition-model) |\n| Which filters stay visible at smaller breakpoints? | [Responsive Priority Order](#responsive-priority-order) |\n| How do different roles affect the toolbar? | [Role-Based Filter Visibility](#role-based-filter-visibility) |\n| How do I apply this to my product? | [Product Mapping & Decision Framework](#product-mapping--decision-framework) |\n\n---\n\n## The Two Surfaces + Standalone Drawer\n\nFilter complexity is handled by **two toolbar surfaces** and one off-canvas surface. The toolbar\nsurface is not manually selected — it adapts automatically based on the number of filter controls\npassed into the Filters slot.\n\n**Auto-adapt behavior — core component requirement.** The FilterBar component counts the controls\nit receives and switches its own layout automatically. Role logic (which controls to pass) lives\nin the consuming screen. Surface selection logic (how to render) lives in the component.\n\n| Controls received | Surface rendered |\n|------------------|-----------------|\n| 1–4 | **Filter Bar** — inline controls, 16px gap, immediate apply |\n| 5+ | **Condensed Filter Bar** — Search + Filters button in bar; all filter controls in the Overflow Filter Drawer; batch Apply/Cancel |\n\n| Surface | Bar row contents | Key specs |\n|---------|-----------------|-----------|\n| **Filter Bar** (1–4 controls) | Search (flex-1, left) + filter controls in Filters slot (right) | Gap: 16px · Full labels · No Filters button at desktop · Chip row: 28px height |\n| **Condensed Filter Bar** (5+ controls) | Search (flex-1, left) + Filters button with badge (right). No inline filter controls. | All controls → Overflow Filter Drawer · Batch Apply/Cancel · Chip row: 34px height · see anatomy below |\n| **Filter Drawer** (standalone capable) | No bar — drawer is the sole filtering surface | Sizes: xsm 350px · sm 450px · md 650px · Footer: Reset Filters · Cancel · Apply |\n\n---\n\n## Filter Counting Rules\n\nCount only the controls passed into the Filters slot for the **current user's role** — not the\nmaximum possible controls for the screen. Search is always excluded.\n\n| Control type | Counts as | Rule |\n|-------------|-----------|------|\n| Search (`TextField` mode: search) | **0 — never counted** | Always in the Bar row's flex-1 left zone, outside the Filters slot. Excluded from badge count too. |\n| Date Range (From + To as two `DatePicker` instances) | **1** | Paired control sharing a single \"Clear dates\" action. Count as 1 until `DateRangePicker` ships (FILT-12). |\n| Single `SelectInputField` (Status, Employee, Project, Dept…) | **1 per dropdown** | Each dropdown addresses one filter dimension. |\n| `MultiSelectInput` or `MultiAutocompleteInput` | **1 per control** | Multi-select is still one control regardless of how many values are selected. |\n| `ToggleButtonGroup` (boolean or preset) | **1 per group** | The group is one control; individual toggles within it do not count separately. |\n| `Switch` (e.g. Read / Unread) | **1 per switch** | One binary filter dimension = one control. |\n| Chips / `ChipGroup` as quick filters | **1 per group** | The group addresses one filter dimension. |\n\n### Worked examples\n\n| Screen | Controls | Count | Surface |\n|--------|----------|-------|---------|\n| Hours+ My Timecards (employee) | Search (0) + Date Range (1) + Status (1) | **2** | Filter Bar |\n| Hours+ Timecards Admin | Search (0) + Date Range (1) + Status (1) + Employee (1) + Project (1) + Dept (1) | **5** | Condensed Filter Bar |\n| Studio+ Search page | Search (0) + Invoice # + Category + Week ending + Created range + Read/Unread + Latest version | **6–7** | Condensed Filter Bar |\n| Start+ / myC&C basic tables | Search (0) + 1–2 basic filters | **1–2** | Filter Bar |\n\n**6-control edge case.** Six controls technically qualify for the Condensed Filter Bar (5+ rule),\nbut evaluate whether inline rendering is still comfortable at 6 before defaulting to condensed.\nWhen in doubt, use the Condensed Filter Bar.\n\n**Designer complexity override.** Count is the primary trigger. If the combination of control\ntypes makes filtering feel cognitively heavy — e.g. a Date Range + multi-select autocomplete +\ntoggle group at 4 controls — the designer may choose the Condensed Filter Bar even below the\n5-control threshold. Recommendation, not a hard rule. A formal complexity threshold will be\ndefined in a future iteration (Q4 in progress).\n\n---\n\n## Condensed Filter Bar Anatomy & Component Specs\n\nThe Condensed Filter Bar is not just a structural change — every supporting element also shifts\nto its condensed form.\n\n**Bar row:** ① Search (always present, flex-1, left) + ② Filters button (badge shows applied count, right).\n**Chip row (condensed, 34px):** ③ RemovableChips + ④ MaxChipCount overflow + ⑤ Reset Filters icon.\n\n### Component state comparison — standard vs. condensed\n\n| Element | Standard | Condensed | Note |\n|---------|----------|-----------|------|\n| Chip Row height | 28px | 34px | Condensed row is taller to accommodate chip wrapping |\n| Max Chip Count | \"+N more\" text label | **\"+N\" plain text** (activatable, not a filled badge) — opens spillover Popover | SPT-225 v1.2.0 supersedes the \"badge-only 32×17px\" spec in SPT-262 |\n| Reset Filters | \"Reset Filters\" ghost button | Icon-only ⊗ · **≥24px interactive target** (WCAG 2.5.8) + `aria-label` + tooltip | SPT-225 v1.2.0 supersedes the \"10×10px\" spec in SPT-262; 10×10px fails WCAG 2.5.8 |\n| Filters slot | Composition slot — teams place controls here | Empty — all controls live in the drawer; Filters button replaces the slot | |\n\n### Condensed Filter Bar responsive sizes\n\n| Size | Width | Bar layout | Spotlight breakpoint | Viewport |\n|------|-------|-----------|---------------------|---------|\n| `xs` | 375px | Search + Filters button — horizontal, inline | `breakpoint-phone` | ≤600px |\n| `sm` | 375px | Search stacked above Filters button — vertical | `breakpoint-tablet-portrait` | 601–768px |\n| `md` | 728px | Search + Filters button — horizontal, more generous | `breakpoint-tablet-landscape` and above | ≥769px |\n\n---\n\n## Filter Drawer\n\nThe Filter Drawer is the off-canvas surface where all filter controls live when a Condensed\nFilter Bar is active. It can also be used standalone where a toolbar surface is not appropriate.\n\n| Size | Width | Spotlight breakpoint | Viewport | Footer layout |\n|------|-------|---------------------|---------|---------------|\n| **xsm** | 350px | `breakpoint-phone` | ≤600px | Stacked — Reset Filters above Cancel + Apply (full width) |\n| **sm** | 450px | `breakpoint-tablet-portrait` | 601–768px | Horizontal — Reset Filters left · Cancel + Apply right |\n| **md** | 650px | `breakpoint-tablet-landscape` and above | ≥769px | Horizontal — Reset Filters left · Cancel + Apply right |\n\n### Drawer anatomy\n\n| Element | Spec |\n|---------|------|\n| Header | \"Filters\" label + ✕ close |\n| Body | Composition slot — teams place filter controls here; accordion template for long lists |\n| Footer — Reset Filters | Ghost button · returns to page default state (not unfiltered) |\n| Footer — Cancel | Outlined · discard draft + close |\n| Footer — Apply | Solid · commit all selections · batch — data refreshes once |\n| Scrim | Tap = close + **preserve draft** (same as ✕) |\n\n**Standalone drawer.** When used as the sole filtering surface (no toolbar), the same sizes,\nanatomy, and footer rules apply. Use when a toolbar is not appropriate for the context — at\ndesigner discretion.\n\n---\n\n## Condensed Bar + Drawer Composition Model\n\nThe Condensed Filter Bar and the Overflow Filter Drawer are two halves of one system.\nThe bar owns **visible applied state**; the drawer owns **filter controls**.\n\n### Interaction loop\n\n| Trigger | Behavior | State after |\n|---------|----------|------------|\n| Click **Filters button** | Drawer opens · controls pre-populated with applied state | Drawer open · no changes yet |\n| Make selections | Draft state — controls update visually; not applied yet | Drawer open · selections pending |\n| Click **Apply** | Drawer closes · chip row updates · results refresh · badge = applied count | Applied |\n| Click **Cancel** | Drawer closes · draft discarded · chip row unchanged | Unchanged · draft lost |\n| Tap **scrim** or click **✕** | Drawer closes · draft **preserved** · results unchanged | Unchanged · draft preserved |\n| Click **Reset Filters** (drawer footer) | All controls cleared · drawer closes · chip row empties · Undo snackbar | No filters applied |\n| Remove **chip** from chip row | That filter cleared immediately · Undo snackbar · drawer need not be open | One filter removed |\n\n### State ownership\n\n| Surface | Owns | Does not own |\n|---------|------|-------------|\n| **Condensed Filter Bar** | Applied filter state · chip row · Filters button badge · Search | Filter controls · draft state |\n| **Overflow Filter Drawer** | Filter controls · draft selections | Applied state display · badge count |\n\n**Badge reflects applied count only — never draft.** Pending drawer selections never increment\nthe badge. Showing a draft count would signal active filters when nothing has been committed —\nespecially misleading after a scrim dismiss where draft state is preserved but unapplied.\n\n---\n\n## Responsive Priority Order\n\nThis applies to the **Filter Bar only**. The Condensed Filter Bar already uses the drawer for\nall filter controls — its responsive behavior is handled by its three size variants (`xs` / `sm`\n/ `md`) mapped to Spotlight breakpoints.\n\n### Spotlight breakpoints\n\n| Token | Viewport range | Max content width | Columns |\n|-------|---------------|-------------------|---------|\n| `breakpoint-desktop` | ≥1281px | 1376px | 12 |\n| `breakpoint-laptop` | 1025–1280px | 1216px | 12 |\n| `breakpoint-tablet-landscape` | 769–1024px | 960px | 8 |\n| `breakpoint-tablet-portrait` | 601–768px | 704px | 8 |\n| `breakpoint-phone` | ≤600px | 552px | 4 |\n\n### Filter Bar control visibility by breakpoint\n\n| Priority | Control | Desktop + Laptop (≥1025px) | Tablet landscape (769–1024px) | Tablet portrait (601–768px) | Phone (≤600px) |\n|----------|---------|--------------------------|------------------------------|---------------------------|----------------|\n| **0** | Search | ✓ Bar | ✓ Bar | ✓ Bar | ✓ Bar |\n| **1** | Date Range | ✓ Inline | ✓ Inline | ✓ Inline | → Drawer (xsm) |\n| **2** | Status | ✓ Inline | ✓ Inline | ✓ Inline | → Drawer (xsm) |\n| **3+** | Entity dropdowns (Employee, Project, Dept…) | ✓ Inline | ✓ Inline (960px accommodates) | → Drawer (sm) | → Drawer (xsm) |\n\nEntity dropdowns move to the drawer at `breakpoint-tablet-portrait` (≤768px, 704px max content)\nwhere available width becomes too narrow for 3+ controls alongside Search. Date Range and Status\nstay inline through tablet portrait.\n\n---\n\n## Role-Based Filter Visibility\n\nRole determines which controls are passed into the Filters slot. The component counts what it\nreceives and renders the correct surface automatically.\n\n| Role | Controls passed | Count | Surface |\n|------|----------------|-------|---------|\n| Employee (My Timecards) | Search + Date Range + Status | 2 | Filter Bar |\n| Supervisor / Reviewer | Search + Date Range + Status + Employee (direct reports only) | 3 | Filter Bar |\n| Payroll Admin / Manager | Search + Date Range + Status + Employee + Project + Dept | 5 | Condensed Filter Bar |\n| Super Admin | All of the above + additional dimensions | 6+ | Condensed Filter Bar |\n\n**The consuming screen is responsible for one thing only:** passing the correct controls for the\ncurrent user's role into the Filters slot. The component handles everything else — no surface\nselection, no layout switching, no role logic inside the toolbar component.\n\n---\n\n## Product Mapping & Decision Framework\n\n### Current product mapping\n\n| Screen | Surface | Controls |\n|--------|---------|---------|\n| Hours+ My Timecards (employee) | Filter Bar | Search + Date Range + Status = **2** |\n| Hours+ Timecards Admin | Condensed Filter Bar | Search + Date Range + Status + Employee + Project + Dept = **5** |\n| Studio+ Search / Directory | Condensed Filter Bar | 6–7 filter types · **migration required** from current form-based architecture (view-switching screen — hard rule applies) |\n| Start+ / myC&C | Filter Bar | Search + 1–2 basic filters = **1–2** |\n\n### Decision framework — which surface for my screen?\n\n1. **List all filter dimensions needed, per role.** Write out every filter control for each role.\n Note which controls are role-suppressed.\n2. **Count controls using the counting rules above.** Search = 0. Date Range = 1. Everything else\n = 1 each. At 6 controls, evaluate whether inline rendering is comfortable before defaulting.\n3. **Pass the role's controls into the Filters slot — the component adapts automatically.**\n - 1–4 → Filter Bar (inline, immediate apply)\n - 5+ → Condensed Filter Bar (Search + Filters button in bar, all controls in drawer, batch Apply)\n - No toolbar needed → Filter Drawer standalone (designer discretion)\n4. **Select Filter Drawer size (Condensed only).**\n - ≤600px → `xsm` (350px · stacked footer)\n - 601–768px → `sm` (450px · horizontal footer)\n - ≥769px → `md` (650px · horizontal footer)\n5. **Apply responsive priority order (Filter Bar only).** See table above. Condensed Filter Bar\n uses its xs/sm/md size variants instead.\n\n---\n\n## Column Filters vs. On-Page Filters — Locked Decisions\n\n| Decision | Resolution |\n|----------|-----------|\n| **Q1 — View-switching screens** | **Hard rule, no exceptions.** Any screen supporting view-switching (table / cards / list) must use on-page filters (Filter Bar or Condensed Filter Bar). Table column filters are only permitted on fixed table-only screens. Studio+ FilelistItem must migrate. |\n| **Q2 — When are column filters the right choice?** | Out of scope for SPT-262. Deferred to table usage patterns work. |\n| **Q3 — Can on-page and column filters coexist?** | **Coexistence allowed.** On-page filters handle dataset-level filtering; column filters handle column-specific refinement. Compound state rules, visual communication, and combined Reset path deferred to table usage patterns work. |\n| **Q4 — Is complexity a separate input from count?** | In progress. Working rule: count is primary (1–4 = Filter Bar, 5+ = Condensed); designer may choose Condensed below threshold if control types make filtering feel cognitively heavy. Formal complexity threshold not yet defined. |\n\n---\n\n## Component Gaps\n\n| Gap | Impact | Tracking |\n|-----|--------|---------|\n| **FilterToolbar / FilterBar composition component** — no composed wrapper exists; only raw CSS available | Teams build their own layout until the component ships | filter-toolbar-handoff v0.2.0 §7 |\n| **Badge `xs` size missing** — ships with `sm` (24px) and `md` (32px) only; `xs` needed for Filters button active count badge | Blocks condensed badge implementation | communicating-applied-filters v0.2.0 §4 |\n| **`DateRangePicker` component** — no single composed From/To component exists; interim: two `DatePicker` instances side by side, counted as 1 | Manual pairing required | FILT-12 |\n\n---\n\n## Related patterns\n\n| Pattern | Purpose |\n|---------|---------|\n| `filter-selection-guide` | Surface selection by role and control count |\n| `filter-toolbar-pattern` | Full toolbar anatomy, chip row, component ordering |\n| `filter-drawer-pattern` | Filter Drawer anatomy, sizes, batch-Apply |\n| `filter-behavior-rules` | AND/OR logic, debounce, server/client threshold |\n| `filter-communicating-applied-guide` | Applied chips, badge, results count, zero results |\n| `filter-persistence-guide` | Session, URL, and saved-view persistence |\n\n---\n\n*Pattern ID: `filter-scaling-rules` · Source: SPT-262 v1.0.0 · June 2026*\n",
291
+ "components": [],
292
+ "sourcePath": "content/patterns/filter-scaling-rules.md"
293
+ },
285
294
  {
286
295
  "id": "filter-selection-guide",
287
296
  "title": "Filter Pattern Selection Guide — Spotlight Design System",
@@ -548,7 +557,7 @@
548
557
  "title": "Filter Drawer Pattern",
549
558
  "category": "recipe",
550
559
  "description": "The Filter Drawer is a right-positioned overlay surface for advanced and multi-control filtering. Its native usage is **standalone** — opened from its own trigger, as the sole filtering surface on a screen. The drawer used inside the Condensed Filter Toolbar is a derivative component piece that inhe",
551
- "content": "# Filter Drawer Pattern\n\nThe Filter Drawer is a right-positioned overlay surface for advanced and multi-control filtering.\nIts native usage is **standalone** — opened from its own trigger, as the sole filtering surface\non a screen. The drawer used inside the Condensed Filter Toolbar is a derivative component piece\nthat inherits everything defined here. Source: SPT-223 v1.2.1.\n\n---\n\n## When to use the Filter Drawer\n\n### Use the standalone Filter Drawer when\n\n- Filtering is advanced or multi-control (multi-select, date ranges, grouped criteria) and\n benefits from batch-Apply.\n- The page is form- or search-first and a persistent control bar would compete with primary content.\n- Filtering is a periodic, deliberate action rather than continuous refinement.\n- The data view needs full width and there is no room for an always-visible bar.\n- The Filter Toolbar already has filter controls, but those controls move off-canvas at smaller\n breakpoints (responsive fallback).\n\n### Use a different surface when\n\n- Filters are few and simple (1–4 controls) — use the inline **Filter Bar** instead.\n- The screen has room for a persistent bar — use the **Filter Toolbar** (or its Condensed variant\n at 5+ controls).\n\n**Standalone is the standard, not an exception.** The drawer-on-its-own is the canonical form.\nThe Condensed Toolbar Drawer is the special case built on top of it.\n\n---\n\n## Sizes\n\nThe drawer size is driven by screen size (Spotlight breakpoint), not by content density.\n\n| Size | Width | Spotlight breakpoint | Viewport range | Footer layout |\n|------|-------|---------------------|----------------|---------------|\n| **xsm** | 350px | `breakpoint-phone` | ≤600px | Stacked — Reset Filters above Cancel + Apply (full width) |\n| **sm** | 450px | `breakpoint-tablet-portrait` | 601–768px | Horizontal — Reset Filters left · Cancel + Apply right |\n| **md** | 650px | `breakpoint-tablet-landscape` and above | ≥769px | Horizontal — Reset Filters left · Cancel + Apply right |\n\n**Mobile full-width.** At ≤768px the `spotlight_Drawer` is already built to render full-width\n(`width: 100%`). Use the `xsm` or `sm` drawer piece and the CSS handles the rest.\n\n```css\n/* bundled_components.css — Drawer sizes (read-only reference) */\n.spotlight_Drawer-dialog {\n &.sm { width: var(--drawer-size-width-sm); } /* 350px */\n &.md { width: var(--drawer-size-width-md); } /* 450px */\n &.lg { width: var(--drawer-size-width-lg); } /* 650px */\n @media (max-width: 768px) {\n width: 100%;\n }\n}\n```\n\n---\n\n## Anatomy\n\nThe drawer is three stacked rows over a scrim: a fixed header, a scrolling body, and a pinned footer.\n\n| Zone | Contents |\n|------|----------|\n| **Header** | \"Filters\" title + ✕ close button |\n| **Body** | Vertical accordion stack · per-group count badge + chevron · groups open by default · optional per-group search for long lists (>10–15 options) · optional tab bar layer above accordion |\n| **Footer** | **Reset Filters** (ghost, left) · **Cancel** (outlined) · **Apply** (solid) · stacked full-width on xsm |\n\n**Label settled.** Footer uses \"Reset Filters\" (returns to default, not zero — per SPT-241).\nNot \"Clear All.\"\n\n---\n\n## Core behavior\n\n| Property | Value |\n|----------|-------|\n| Surface | Right overlay + scrim; content not reflowed behind |\n| Apply model | **Batch** — draft updates visually; data commits once, on Apply |\n| Commit point | Single global Apply — no per-group apply |\n| Scrim tap / ✕ | Close + **preserve draft state** (reopening restores draft) |\n| Cancel | Discard draft + close |\n| Apply | Commit draft + close |\n| Motion — open | `slideInRight` 300ms ease-out + scrim fade |\n| Motion — close | Reverse 300ms ease-in + scrim fade (symmetric duration) |\n| `prefers-reduced-motion` | `animation: none` on panel and overlay — verified in `Drawer.module.css` |\n\n**Anti-thrash rationale.** Batch apply prevents the data view from thrashing while several\nfilters are adjusted. Recommended enhancement: a pending result count on the Apply button\n(\"Apply (142)\") to restore live feedback without thrash.\n\n---\n\n## Trigger (standalone drawer)\n\nBecause the standalone drawer has no chip row on the page, the **count badge is mandatory** when\nfilters are applied — users must see that filters are active without opening the drawer.\n\n| Trigger property | Value |\n|-----------------|-------|\n| Component | Filter Drawer Trigger button |\n| Label + icon | \"Filters\" text + leading funnel icon |\n| Count badge | Shown when appliedFilter = on; number-only pill; hidden at count 0 |\n| Badge value | Applied dimensions (not values); applied state only — never draft |\n| `appliedFilter` prop | on / off — drives badge render |\n| `active` prop | on / off — whether drawer is currently open |\n| Clear control | **None beside the trigger** — reset happens inside the drawer only (team-decided) |\n\n---\n\n## Reset Filters in the drawer\n\n| Property | Value |\n|----------|-------|\n| Location | Footer — left-aligned (horizontal footer) or stacked above Cancel+Apply (xsm) |\n| Behavior | Returns to page default state — not to zero (SPT-241) |\n| Minimum target | ≥24px (WCAG 2.5.8) |\n| Accessibility | `aria-label=\"Reset Filters\"` + tooltip |\n| Confirmation | None — immediate, non-destructive |\n| After reset | Undo snackbar (5s · \"Filters reset\" · \"Undo\") |\n\nNo \"Clear filters\" button beside the trigger. The trigger shows only the applied count, not which\nfilters are applied, so clearing from outside the drawer would be a blind action. Users open the\ndrawer to see and reset their filters.\n\n---\n\n## Standalone vs. Condensed Toolbar Drawer\n\nThe Condensed Filter Toolbar (SPT-225) uses a **derivative component piece** based on this\ndefinition — not a separate component. It reuses the container, sizes, anatomy, body, footer,\nbatch-Apply, and motion from this base definition and adds only the composition context.\n\n| Aspect | Base drawer (standalone) | Condensed Toolbar Drawer (derivative) |\n|--------|--------------------------|--------------------------------------|\n| Opens from | Its own trigger on the page | Filter Toolbar escalation (controls move in) |\n| Surface above | None — page content | Filter Toolbar: Search + Filters trigger + chip row |\n| Applied-state | Trigger badge (mandatory when no chip row) | Bar chip row + trigger badge |\n| State on open | Own state | Adopts live state from bar on escalation; persists |\n| Container / anatomy / footer | Defined here (base) | Inherited — unchanged |\n| Sizes / batch-Apply / motion | Defined here (base) | Inherited — unchanged |\n\n**Inheritance direction.** Base → derivative. The Condensed Toolbar Drawer never forks the specs\nowned here.\n\n---\n\n## Accessibility\n\n| Requirement | Implementation |\n|-------------|----------------|\n| Focus-in on open | Focus moves to first interactive element inside drawer on open |\n| Focus-return on close | Focus returns to the trigger that opened the drawer |\n| `prefers-reduced-motion` | `animation: none` on panel and overlay |\n| Reset button target | ≥24px |\n| Reset button label | `aria-label=\"Reset Filters\"` + tooltip when icon-only |\n| Applied filter count | Screen reader must read badge count (\"3 filters applied\") |\n\n---\n\n## Common patterns inside the drawer body\n\n### Accordion filter groups\n\n```\nDrawer body\n├─ Group: Status (open by default) [3] ˅\n│ ├─ ☑ Pending\n│ ├─ ☐ Approved\n│ └─ ☐ Rejected\n├─ Group: Date Range (open by default) [1] ˅\n│ ├─ From: [DatePicker]\n│ └─ To: [DatePicker]\n└─ Group: Employee (open by default) [0] ˅\n ├─ [Search within group...]\n ├─ ☐ Jane Smith\n └─ ☐ ...\n```\n\n### Tab bar (optional, above accordion)\n\nUse when filter groups span fundamentally different contexts (e.g. active vs. archived data)\nand the user should navigate between them. Not required for standard multi-control filters.\n\n---\n\n## Filter Drawer + data table or grid — surface priority filters\n\n> **Recommendation** — when a Filter Drawer is used alongside a data table or card grid, do not\n> move all filter controls exclusively into the drawer. Identify the 1–3 filters users reach for\n> most often (typically Search, Date Range, and Status) and keep them visible in the page's filter\n> toolbar bar row. Reserve the drawer for secondary and advanced filters.\n>\n> **Why it matters.** Hiding every filter behind a drawer forces an extra tap for the most common\n> workflows. Users who narrow by date or status on every session should not have to open a panel\n> each time.\n>\n> **Severity: Recommendation.** This is not a compliance rule. Screens where all filters are\n> equally infrequent, or where a full-width data view justifies a drawer-only approach, may deviate\n> with explicit design rationale.\n\n---\n\n## Related patterns\n\n| Pattern | Purpose |\n|---------|---------|\n| `filter-selection-guide` | When to use a drawer vs. inline toolbar |\n| `filter-toolbar-pattern` | Condensed Filter Bar and toolbar anatomy |\n| `filter-behavior-rules` | AND/OR logic, debounce, server/client threshold |\n| `filter-communicating-applied-guide` | Applied state: chips, badge, results count, zero results |\n| `filter-persistence-guide` | Draft state vs. applied state; URL param encoding |\n| `overlay-modal-vs-drawer-selection-guide` | General modal vs. drawer surface decision |\n\n---\n\n*Pattern ID: `filter-drawer-pattern` · Source: SPT-223 v1.2.1*\n",
560
+ "content": "# Filter Drawer Pattern\n\nThe Filter Drawer is a right-positioned overlay surface for advanced and multi-control filtering.\nIts native usage is **standalone** — opened from its own trigger, as the sole filtering surface\non a screen. The drawer used inside the Condensed Filter Toolbar is a derivative component piece\nthat inherits everything defined here. Source: SPT-223 v1.2.1.\n\n---\n\n## When to use the Filter Drawer\n\n### Use the standalone Filter Drawer when\n\n- Filtering is advanced or multi-control (multi-select, date ranges, grouped criteria) and\n benefits from batch-Apply.\n- The page is form- or search-first and a persistent control bar would compete with primary content.\n- Filtering is a periodic, deliberate action rather than continuous refinement.\n- The data view needs full width and there is no room for an always-visible bar.\n- The Filter Toolbar already has filter controls, but those controls move off-canvas at smaller\n breakpoints (responsive fallback).\n\n### Use a different surface when\n\n- Filters are few and simple (1–4 controls) — use the inline **Filter Bar** instead.\n- The screen has room for a persistent bar — use the **Filter Toolbar** (or its Condensed variant\n at 5+ controls).\n\n**Standalone is the standard, not an exception.** The drawer-on-its-own is the canonical form.\nThe Condensed Toolbar Drawer is the special case built on top of it.\n\n---\n\n## Sizes\n\nThe drawer size is driven by screen size (Spotlight breakpoint), not by content density.\n\n| Size | Width | Spotlight breakpoint | Viewport range | Footer layout |\n|------|-------|---------------------|----------------|---------------|\n| **xsm** | 350px | `breakpoint-phone` | ≤600px | Stacked — Reset Filters above Cancel + Apply (full width) |\n| **sm** | 450px | `breakpoint-tablet-portrait` | 601–768px | Horizontal — Reset Filters left · Cancel + Apply right |\n| **md** | 650px | `breakpoint-tablet-landscape` and above | ≥769px | Horizontal — Reset Filters left · Cancel + Apply right |\n\n**Mobile full-width.** At ≤768px the `spotlight_Drawer` is already built to render full-width\n(`width: 100%`). Use the `xsm` or `sm` drawer piece and the CSS handles the rest.\n\n```css\n/* bundled_components.css — Drawer sizes (read-only reference) */\n.spotlight_Drawer-dialog {\n &.sm { width: var(--drawer-size-width-sm); } /* 350px */\n &.md { width: var(--drawer-size-width-md); } /* 450px */\n &.lg { width: var(--drawer-size-width-lg); } /* 650px */\n @media (max-width: 768px) {\n width: 100%;\n }\n}\n```\n\n**Reconcile — size tokens.** Figma variants are `xsm` / `sm` / `md` (350 / 450 / 650px); the\ncoded Drawer uses `sm` / `md` / `lg`. Confirm canonical naming before implementation — pending\ndesign-implementation ticket.\n\n---\n\n## Anatomy\n\nThe drawer is three stacked rows over a scrim: a fixed header, a scrolling body, and a pinned footer.\n\n| Zone | Contents |\n|------|----------|\n| **Header** | \"Filters\" title + ✕ close button |\n| **Body** | Vertical accordion stack · per-group count badge + chevron · groups open by default · optional per-group search for long lists (>10–15 options) · optional tab bar layer above accordion |\n| **Footer** | **Reset Filters** (ghost, left) · **Cancel** (outlined) · **Apply** (solid) · stacked full-width on xsm |\n\n**Label settled.** Footer uses \"Reset Filters\" (returns to default, not zero — per SPT-241).\nNot \"Clear All.\"\n\n---\n\n## Core behavior\n\n| Property | Value |\n|----------|-------|\n| Surface | Right overlay + scrim; content not reflowed behind |\n| Apply model | **Batch** — draft updates visually; data commits once, on Apply |\n| Commit point | Single global Apply — no per-group apply |\n| Scrim tap / ✕ | Close + **preserve draft state** (reopening restores draft) |\n| Cancel | Discard draft + close |\n| Apply | Commit draft + close |\n| Motion — open | `slideInRight` 300ms ease-out + scrim fade |\n| Motion — close | Reverse 300ms ease-in + scrim fade (symmetric duration) |\n| `prefers-reduced-motion` | `animation: none` on panel and overlay — verified in `Drawer.module.css` |\n\n**Anti-thrash rationale.** Batch apply prevents the data view from thrashing while several\nfilters are adjusted. Recommended enhancement: a pending result count on the Apply button\n(\"Apply (142)\") to restore live feedback without thrash.\n\n---\n\n## Trigger (standalone drawer)\n\nBecause the standalone drawer has no chip row on the page, the **count badge is mandatory** when\nfilters are applied — users must see that filters are active without opening the drawer.\n\n| Trigger property | Value |\n|-----------------|-------|\n| Component | Filter Drawer Trigger button |\n| Label + icon | \"Filters\" text + leading funnel icon |\n| Count badge | Shown when appliedFilter = on; number-only pill; hidden at count 0 |\n| Badge value | Applied dimensions (not values); applied state only — never draft |\n| `appliedFilter` prop | on / off — drives badge render |\n| `active` prop | on / off — whether drawer is currently open |\n| States | enabled · hovered · pressed · disabled (each × active on/off × appliedFilter on/off) |\n| Clear control | **None beside the trigger** — reset happens inside the drawer only (team-decided) |\n\n---\n\n## Reset Filters in the drawer\n\n| Property | Value |\n|----------|-------|\n| Location | Footer — left-aligned (horizontal footer) or stacked above Cancel+Apply (xsm) |\n| Behavior | Returns to page default state — not to zero (SPT-241) |\n| Minimum target | ≥24px (WCAG 2.5.8) |\n| Accessibility | `aria-label=\"Reset Filters\"` + tooltip |\n| Confirmation | None — immediate, non-destructive |\n| After reset | Undo snackbar (5s · \"Filters reset\" · \"Undo\") |\n\nNo \"Clear filters\" button beside the trigger. The trigger shows only the applied count, not which\nfilters are applied, so clearing from outside the drawer would be a blind action. Users open the\ndrawer to see and reset their filters.\n\n---\n\n## Standalone vs. Condensed Toolbar Drawer\n\nThe Condensed Filter Toolbar (SPT-225) uses a **derivative component piece** based on this\ndefinition — not a separate component. It reuses the container, sizes, anatomy, body, footer,\nbatch-Apply, and motion from this base definition and adds only the composition context.\n\n| Aspect | Base drawer (standalone) | Condensed Toolbar Drawer (derivative) |\n|--------|--------------------------|--------------------------------------|\n| Opens from | Its own trigger on the page | Filter Toolbar escalation (controls move in) |\n| Surface above | None — page content | Filter Toolbar: Search + Filters trigger + chip row |\n| Applied-state | Trigger badge (mandatory when no chip row) | Bar chip row + trigger badge |\n| State on open | Own state | Adopts live state from bar on escalation; persists |\n| Container / anatomy / footer | Defined here (base) | Inherited — unchanged |\n| Sizes / batch-Apply / motion | Defined here (base) | Inherited — unchanged |\n\n**Inheritance direction.** Base → derivative. The Condensed Toolbar Drawer never forks the specs\nowned here. If this definition changes, the derivative changes with it.\n\n**Guardrails for the derivative.** Reuse this component — no forked sizes, anatomy, footer, or\nmotion. Preserve selections and applied-state on escalation. Keep batch-Apply. Keep the SPT-241\nreset baseline.\n\n---\n\n## Accessibility\n\n| Requirement | Implementation |\n|-------------|----------------|\n| Focus-in on open | Focus moves to first interactive element inside drawer on open |\n| Focus-return on close | Focus returns to the trigger that opened the drawer |\n| `prefers-reduced-motion` | `animation: none` on panel and overlay |\n| Reset button target | ≥24px |\n| Reset button label | `aria-label=\"Reset Filters\"` + tooltip when icon-only |\n| Applied filter count | Screen reader must read badge count (\"3 filters applied\") |\n\n---\n\n## Common patterns inside the drawer body\n\n### Accordion filter groups\n\n```\nDrawer body\n├─ Group: Status (open by default) [3] ˅\n│ ├─ ☑ Pending\n│ ├─ ☐ Approved\n│ └─ ☐ Rejected\n├─ Group: Date Range (open by default) [1] ˅\n│ ├─ From: [DatePicker]\n│ └─ To: [DatePicker]\n└─ Group: Employee (open by default) [0] ˅\n ├─ [Search within group...]\n ├─ ☐ Jane Smith\n └─ ☐ ...\n```\n\n### Tab bar (optional, above accordion)\n\nUse when filter groups span fundamentally different contexts (e.g. active vs. archived data)\nand the user should navigate between them. Not required for standard multi-control filters.\n\n---\n\n## Related patterns\n\n| Pattern | Purpose |\n|---------|---------|\n| `filter-selection-guide` | When to use a drawer vs. inline toolbar |\n| `filter-toolbar-pattern` | Condensed Filter Bar and toolbar anatomy |\n| `filter-behavior-rules` | AND/OR logic, debounce, server/client threshold |\n| `filter-communicating-applied-guide` | Applied state: chips, badge, results count, zero results |\n| `filter-persistence-guide` | Draft state vs. applied state; URL param encoding |\n| `overlay-modal-vs-drawer-selection-guide` | General modal vs. drawer surface decision |\n\n---\n\n*Pattern ID: `filter-drawer-pattern` · Source: SPT-223 v1.2.1*\n",
552
561
  "components": [],
553
562
  "sourcePath": "content/recipes/filter-drawer-pattern.md"
554
563
  },
@@ -556,8 +565,8 @@
556
565
  "id": "filter-toolbar-pattern",
557
566
  "title": "Filter Toolbar Pattern",
558
567
  "category": "recipe",
559
- "description": "The standard filter surface for tables and lists in Spotlight. No `FilterToolbar` wrapper component exists yet — teams compose the pattern from existing Spotlight pieces arranged with flex CSS. This recipe defines the layout rules, component choices, and behavioral specifications so the composed res",
560
- "content": "# Filter Toolbar Pattern\n\nThe standard filter surface for tables and lists in Spotlight. No `FilterToolbar` wrapper\ncomponent exists yet — teams compose the pattern from existing Spotlight pieces arranged with flex\nCSS. This recipe defines the layout rules, component choices, and behavioral specifications so\nthe composed result is consistent across products.\n\nA flex-column container with two rows: a **Bar row** (Search + Filters slot) and a **Chip Row**\n(applied filter chips + overflow trigger + Reset button). Source: SPT-229 v0.4.0, SPT-225 v1.2.0,\nSPT-262 v1.0.0. Design spec: Patterns — Timeless V2.0.0, node 2029:6499.\n\n---\n\n## Anatomy\n\nThe Filter Toolbar is a two-row container positioned above a data table. The top Bar row holds the\nSearch field and a Filters slot; the bottom Chip Row holds applied-filter chips and controls. The\nChip Row renders only when at least one filter is applied.\n\n### Anatomy callouts\n\n| # | Element | Description |\n|---|---------|-------------|\n| 1 | **Toolbar Container** | Outer 48px strip |\n| 2 | **Filters** | Right-aligned slot |\n| 3 | **Search Field** | Always first control |\n| 4 | **Controls — Max Chip Count, Reset Filters** | Right-aligned buttons |\n| 5 | **Applied Filters Chips** | Left-aligned |\n\n### Structural specifications\n\n| Property | Value | Note |\n|----------|-------|------|\n| Height | 48px | Controls sit at 32px. 8px top + bottom padding. |\n| Padding — all sides | 8px | Consistent horizontal and vertical. Follows nested border radius rule. |\n| Border radius — contained | 18px | Derived: control border radius (10px) + padding (8px) = 18px. |\n| Border radius — full-width | 0px | Toolbar runs flush with page margins. Nested radius rule does not apply. |\n| Background — default | None (transparent) | Toolbar blends with page surface. Spacing and control styling define the toolbar area. |\n| Border — default | None | Default variant. See Toolbar Variants section below. |\n| Width | 100% | Always equals width of the content container it controls. |\n| Spacing below toolbar | 16px | Gap between toolbar bottom edge and table header top edge. Maps to `--space-md`. |\n\n**Nested border radius rule.** Padding is derived from the relationship between the toolbar\ncontainer's border radius and the controls' border radius: outer radius = control radius (10px) +\npadding (8px) = 18px for the contained variant.\n\n### Two-row structure\n\n| Row | Contents | Layout |\n|-----|----------|--------|\n| **Bar** (top) | **Search** (flex-1, always left) · **Filters slot** (right — composition slot for filter controls) | `flex · gap: 16px · items-center · full-width` |\n| **Chip Row** (bottom) | RemovableChips · \"+N more\" expand button · Reset Filters (far right). **Rendered only when ≥1 filter active.** | `flex · flex-nowrap · gap: 8px · items-center · px: 8px · h: 28px` — single row, no wrapping |\n\nThe Filters slot is a composition slot — consuming teams place filter controls here in the fixed\norder defined below. The slot does not prescribe which controls appear; it provides the container.\n\n### Zone structure\n\n| Zone | Position | Contents | Layout |\n|------|----------|----------|--------|\n| A — Filter Controls | Left | Search, Date Range, Status, Entity dropdowns | flex-start · 16px gap between controls |\n| B — Actions | Right | Filters trigger (condensed mode) · Reset All (conditional) · Primary action (conditional) | flex-end · Filters trigger leftmost in Zone B · Reset All left of primary action |\n\n**Filters trigger is right-aligned.** In condensed mode the Filters button moves to Zone B (right side of the bar), not Zone A. This keeps the left side clean for Search and any inline controls that remain visible at the current breakpoint.\n\n### Control alignment and sizing\n\nAll filter controls in the bar row must be vertically aligned at their midpoint. Two rules enforce this:\n\n**Rule — no floating labels on toolbar controls.** Filter controls must not render a visible label above the input. A floating label shifts the control's bounding box downward and breaks alignment with adjacent controls that have none (see screenshot: \"Offer Status\" label above the dropdown misaligns it with the search field). Use `placeholder` for the visible hint and `aria-label` (or `aria-labelledby` pointing to a visually-hidden `<label>`) for accessibility instead.\n\n```tsx\n{/* Correct — no visible label, accessible via aria-label */}\n<SelectInputField\n aria-label=\"Offer Status\"\n placeholder=\"Offer Status\"\n size=\"md\"\n /* ... */\n/>\n\n{/* Incorrect — floating label pushes control down, misaligns row */}\n<SelectInputField\n label=\"Offer Status\"\n size=\"md\"\n /* ... */\n/>\n```\n\n**Rule — all toolbar controls use `md` size.** Every input in the bar row (TextField, SelectInputField, MultiSelectInput, AutocompleteInput, DatePicker) must use `size=\"md\"`. Do not mix `sm` and `md` in the same toolbar.\n\n| Control | Required size |\n|---------|--------------|\n| `TextField` (search) | `md` |\n| `SelectInputField` | `md` |\n| `MultiSelectInput` | `md` |\n| `AutocompleteInput` | `md` |\n| `DatePicker` | `md` |\n\n---\n\n### Design-spec dimensions (implement manually until component ships)\n\nThe design spec defines these dimensions as future component props. Until `FilterToolbar` ships,\nimplement each of these as explicit CSS or conditional rendering in the consuming screen.\n\n| Dimension | Spec value | How to implement today |\n|-----------|-----------|----------------------|\n| Variant | `default` (transparent) · `contained` (secondary surface) · `float` (transparent, white page) | Apply background color and border-radius to the wrapper div per variant |\n| Density | `standard` (16px gap) · `condensed` (10px gap) · `drawer-trigger` | Control the `gap` value on the Filters flex row |\n| Show search | Toggle `TextField` render | Conditional render of `TextField` |\n| Max chip count | Threshold before \"+N more\" spillover | Slice the chip array; render `ToggleButton \"+N more\"` for overflow items |\n| Reset visibility | Show only when ≥1 filter deviates from page default | Conditional render of ghost `Button` |\n| Reset display | `label` (\"Reset Filters\") for standard; `icon` (⊗ ≥24px) for condensed | Swap between `Button` with text and icon-only `Button` with `aria-label` |\n\n**Spillover behavior.** When chips exceed the max count threshold, the \"N+ more\" trigger reveals\nhidden filters in a Popover — on hover (transient, dismisses on mouse-out) and on click (pinned,\npersists until dismissed). Use a `ToggleButton` + `Popover` + `MaxChipSpillOverList` +\n`SpillOverItem` composition.\n\n### Spacing tokens\n\n```css\n/* Confirmed in style.css */\n--table-filter-margin-bottom: var(--space-xl); /* gap below filter bar */\n--table-search-min-width-lg: 250px; /* desktop search field */\n--table-search-min-width-md: 200px; /* tablet ≤768px */\n--table-search-min-width-sm: 150px; /* mobile ≤480px */\n\n/* Proposed additions for FilterToolbar component */\n--filter-toolbar-padding: 8px;\n--filter-toolbar-gap: 16px; /* gap between search and slot in Bar row */\n--filter-toolbar-gap-condensed: 10px; /* condensed modifier gap */\n--filter-toolbar-row-gap: 16px; /* gap between Bar row and Chip Row */\n--filter-toolbar-chip-row-height: 28px; /* Chip Row height */\n--filter-toolbar-margin-bottom: 16px; /* gap to table header — --space-md */\n--filter-toolbar-drawer-width: var(--drawer-size-width-sm); /* 350px */\n```\n\n---\n\n## Best Practices\n\n> Always place Search first in the Filters slot — it is required on every toolbar instance. Place the filters that serve your screen's most critical workflows immediately after Search, and suppress any slot that has no corresponding workflow. Never reorder the remaining slots. Keep the chip-row controls lean: show Reset All only when one or more filters deviate from the screen's default state, and only include a Primary Action button on the page header when it directly relates to the table content below.\n> — Patterns — Timeless V2.0.0\n\n### Rules at a glance\n\n| Rule | Detail |\n|------|--------|\n| Search is always first | Required on every toolbar instance, regardless of screen type. Never omit. |\n| Chip Row is always present when filters are active | The chip row MUST appear below the toolbar when ≥1 filter is active. Never suppress chips when filters are applied. |\n| Reset All shows on the page | The \"Reset filters\" button must be visible in the chip row on the page — not hidden behind a drawer or panel. Show it when ≥1 filter deviates from the page's default state. Hide it when filters are already at default. |\n| One Primary Action maximum | The primary action button (e.g. \"+ Create Offer\") belongs in the page header, not the filter toolbar, and only when it directly relates to the table content below. |\n| No submit button on the toolbar | All Zone A controls (inline filter bar) apply immediately on change — no Apply button needed. |\n| Never add a filter slot without a workflow | Omit any control that doesn't map to a primary user workflow on this screen. |\n\n---\n\n## Component configuration props\n\nThe API surface for configuring toolbar behavior and visibility. These correspond to the props that the future `FilterToolbar` component will expose; implement them as conditional rendering until the component ships.\n\n| Prop | Type | Description |\n|------|------|-------------|\n| `density` | Enum: `standard` · `condensed` · `drawer-trigger` | Controls gap between filter controls. `standard` = 16px gap; `condensed` = 10px gap; `drawer-trigger` = controls move into drawer |\n| `showSearch` | Boolean | Toggles the Search field in the Bar row. Almost always `true`. |\n| `maxChipCount` | Enum / Number | Max chips shown in the Chip Row before the \"+N more\" spillover trigger appears. Overflow chips surface in a Popover on hover (transient) or click (pinned). |\n| `allowReset` | Boolean | Shows the Reset Filters button in the Chip Row when ≥1 filter deviates from the page's default state (SPT-241). Hidden when filters are already at default. |\n| `resetDisplay` | Enum: `text` · `icon` | `text` = \"Reset filters\" ghost button (standard). `icon` = icon-only ⊗ (≥24px touch target) with `aria-label=\"Reset filters\"` + tooltip — condensed bar only. |\n| children (Filters slot) | ReactNode | Accepts any filter control combination in fixed slot order. The slot does not prescribe which controls appear; it provides the flex container. |\n\n---\n\n## Toolbar variants\n\nThree variants control background and border treatment. The default variant is standard for most\nscreens.\n\n| Variant | Background | Use when |\n|---------|-----------|----------|\n| `default` | None (transparent) | Standard — works on any page surface. Spacing and control styling define the area. |\n| `contained` | Secondary surface token | Toolbar needs to read as a distinct component separate from the page and table. |\n| `float` | None (transparent) | Toolbar fully blends into a white page. Spacing alone does all separation work. |\n\n---\n\n## Control ordering — Filters slot (fixed order)\n\nBefore placing controls, identify the primary workflows your screen supports — what are users most\nfrequently trying to find or narrow down? Let that answer drive which filters you include and how\nprominent they are.\n\n**How to use:**\n1. Identify your screen's primary workflows — what are users most frequently trying to find or narrow down?\n2. Map each workflow to the filter slot below. Workflows that drive the most filtering decisions should appear leftmost in Zone A.\n3. Omit any slot that doesn't apply to your screen's data. Never reorder the slots that remain.\n\nTeams may omit controls not relevant to their screen's data, but **may not reorder** controls\nthat are present.\n\n| Slot | Control | Spotlight component | Include when… |\n|------|---------|---------------------|---------------|\n| 1 — Always first | Search | `TextField` (mode: search) | Always. Every toolbar needs a search field regardless of screen type. |\n| 2 | Date Range (From / To) | `DatePicker` × 2 | Date is a primary workflow on this screen — users routinely narrow results by time period (e.g. pay periods, deadlines, week ending). |\n| 3 | Status | `SelectInputField` | Status is a primary workflow — users regularly need to isolate records by approval state, progress, or lifecycle stage. |\n| 4+ | Entity dropdowns (Employee, Project, Dept.) | `SelectInputField` or `AutocompleteInput` | Filtering by person, project, or department is a primary workflow. Admin and manager views only — suppress on single-entity employee views. |\n| Zone A — Left | Reset All | `Button` (variant: ghost or link) | Show only when 1 or more filters deviate from the screen's default state. Hidden when all filters are at their default. |\n| Zone B — Rightmost | Primary action (e.g. \"+ Create Offer\") | `Button` (variant: primary) | Conditional. Appears only when the action is directly related to the content in the table below. |\n\n---\n\n## Component mapping — filter control → Spotlight component\n\n**Deprecation notice.** `Select` is deprecated. All filter dropdowns must use `SelectInputField`.\n\n| Filter type | Component | Size | Key rule |\n|-------------|-----------|------|---------|\n| Text search | `TextField` (mode: search) | **md** | Search mode · Start adornment: search icon · End adornment: clear X · 300ms debounce · `aria-label` required (no visible label) |\n| Single select, <20 items | `SelectInputField` | **md** | Single-select mode · `placeholder` + `aria-label` — no `label` prop · List scannable without search |\n| Multi select, <20 items | `MultiSelectInput` | **md** | OR logic within filter · `placeholder` + `aria-label` — no `label` prop · Select All sticky at top |\n| Searchable entity, 20+ items | `AutocompleteInput` | **md** | Async search for 100+ items · `placeholder` + `aria-label` — no `label` prop · Single select · Legacy Autocomplete deprecated |\n| Multi searchable entity, 20+ items | `MultiAutocompleteInput` | **md** | OR logic · `placeholder` + `aria-label` — no `label` prop · Selections shown as dismissible chips inside trigger field |\n| Date — single | `DatePicker` | **md** | Calendar popup · MM/DD/YYYY · 280–320px popup width · `aria-label` required |\n| Date range | `DatePicker` × 2 | **md** | From/To side-by-side · 105px each · From constrains To minimum · Paired \"Clear dates\" link resets both · `aria-label` on each |\n| Date presets (Today, This Week…) | `ToggleButtonGroup` | Verify in Storybook | Mutually exclusive · Preset auto-populates date range · Include Custom fallback |\n| Boolean toggle (e.g. Read/Unread) | `Switch` | sm: 34×20px · md: 44×24px | Label describes active state · Default off = unfiltered · Immediate update |\n| Mutually exclusive options (2–5) | `ToggleButtonGroup` | Verify in Storybook | All options visible simultaneously · Toolbar use only |\n| Quick filter, combinable (3–6 values) | `Chip` / `ChipGroup` | Verify in Storybook | OR logic within selection · No dropdown · Status is primary candidate |\n| Grouped / hierarchical | No component available | — | Unvalidated use case. Do not implement until research confirms need. |\n\n---\n\n## Chip Row elements\n\nThe chip row is a **single, non-wrapping row**. Chips never wrap to a second line; overflow is\nhandled by the \"+N more\" expand button.\n\n| Element | Component | Condition |\n|---------|-----------|-----------|\n| RemovableChips | `Chip` (dismissible) | One per active filter, up to the visible threshold. Label format: \"FilterName: Value ✕\". |\n| \"+N more\" expand button | `Button` (variant: ghost or link) | Shown when applied chips exceed the visible threshold. Clicking **expands the chip area in-place** to reveal all applied chips — it does not open a popover. When expanded, button changes to \"Show less\" (or collapses on second click). |\n| Reset Filters | `Button` (variant: ghost) · `margin-left: auto` · **far right** | Visible when ≥1 filter deviates from page default. Always pinned to the right edge of the chip row regardless of how many chips are showing. Returns to page default state (not zero). |\n\n**\"+N more\" expand behavior.** The chip row starts collapsed (single line). Clicking \"+N more\"\nexpands the row container to `flex-wrap`, revealing all chips. Clicking \"Show less\" (or the same\nbutton toggled) collapses back to single-line. Reset Filters stays visible and right-aligned in\nboth states.\n\n**Reset Filters positioning.** Reset Filters uses `margin-left: auto` to push itself to the far\nright of the chip row at all times — collapsed, expanded, and at every chip count.\n\n---\n\n## Reset behavior\n\n| Mechanism | What it clears | Confirmation |\n|-----------|---------------|--------------|\n| Chip ✕ button | That single filter only | None — immediate |\n| Reset All / Clear all | All active filters — returns to page default state | None — non-destructive |\n\n**Reset All rule.** Reset All returns to the page's default state — not to \"no filters.\" If a\ntable's default shows the current pay period, clearing returns to that default, not to all-time\nunfiltered. Reset All is hidden when no filters deviate from the default state.\n\n---\n\n## Surface variants — Filter Bar vs Condensed Filter Bar\n\n### Filter Bar (1–4 filter controls)\n\nControls sit inline in the Filters slot. Each control applies immediately on change — no Apply\nbutton. Gap between controls: 16px (standard).\n\n### Condensed Filter Bar (5+ filter controls)\n\nTriggered automatically by the component when it receives 5+ controls. Search stays in the Bar\nrow; all filter controls move into the Condensed Filter Drawer piece. A **Filters trigger** button\n(toggle + funnel icon + count badge) replaces the Filters slot.\n\n| Condensed element | Spec |\n|------------------|------|\n| Filters trigger | `ToggleButton` + funnel icon + applied-count badge (shows applied, never draft) |\n| Filter controls | All move into the Condensed Filter Drawer piece (sm · 350px) |\n| Apply model | Batch Apply / Cancel inside the drawer |\n| Chip Row height | 34px (vs 28px standard) |\n| MaxChipCount | Badge-only (32×17px) — no text label |\n| Reset filters | Icon-only ⊗ (≥24px WCAG target) + `aria-label=\"Reset filters\"` + tooltip |\n\n**Auto-adapt.** The component counts controls it receives (Search = 0; Date Range = 1; each other\ncontrol = 1) and switches layout automatically. No screen-level surface selection needed.\n\n---\n\n## Condensed Filter Drawer (piece)\n\nA distinct component piece owned by the Condensed Filter Toolbar (not an instance of the\nstandalone Filter Drawer SPT-223). Shares SPT-223's visual language but has its own header,\nfooter, and behavior.\n\n| Property | Value |\n|----------|-------|\n| Width | sm · 350px (`--drawer-size-width-sm`) |\n| Header | \"Filters\" + ✕ close |\n| Footer | Reset Filters (ghost) · Cancel (outlined) · Apply (solid) |\n| Apply model | Batch — data refreshes once on Apply |\n| Scrim tap / ✕ | Close + **preserve draft state** (reopening restores draft) |\n| Cancel | Discard draft · close |\n| Badge updates | Only on Apply — draft selections never increment the badge |\n\n**Interaction loop:**\n\n| Trigger | Behavior |\n|---------|----------|\n| Click Filters button | Drawer opens · controls pre-populated with applied state |\n| Make selections | Draft state — controls update visually; filters not applied yet |\n| Apply | Drawer closes · chip row updates · results refresh · badge = applied count |\n| Cancel | Drawer closes · draft discarded · chip row and results unchanged |\n| Scrim tap or ✕ | Drawer closes · draft **preserved** · results unchanged |\n| Reset Filters (drawer footer) | All controls cleared · drawer closes · chip row empties · Undo snackbar |\n| Remove chip (chip row) | That filter cleared immediately · Undo snackbar · drawer not required to be open |\n\n---\n\n## Responsive behavior\n\nThree states are defined. The toolbar collapses progressively — entity dropdowns move to the\nFilters drawer at tablet, and all filter controls collapse at mobile.\n\n| Spotlight breakpoint | Viewport | Search | Date Range | Status | Entity dropdowns |\n|---------------------|---------|--------|-----------|--------|-----------------|\n| Desktop + Laptop (≥1025px) | ≥1025px | ✓ Bar | ✓ Inline | ✓ Inline | ✓ Inline |\n| Tablet landscape (769–1024px) | 769–1024px | ✓ Bar | ✓ Inline | ✓ Inline | ✓ Inline (960px accommodates) |\n| Tablet portrait (601–768px) | 601–768px | ✓ Bar | ✓ Inline | ✓ Inline | → Drawer (sm) |\n| Phone (≤600px) | ≤600px | ✓ Bar | → Drawer (xsm) | → Drawer (xsm) | → Drawer (xsm) |\n\nWhen filter controls move to a Drawer at smaller breakpoints, the Drawer uses an explicit Apply\nbecause multiple filters may change simultaneously.\n\nThe Condensed Filter Bar uses its three size variants (`xs` / `sm` / `md`) for responsive behavior\nrather than this breakpoint table.\n\n---\n\n## Motion\n\nAll animations follow Spotlight Motion Guidance. Designers pick an intent — the system resolves\nduration and easing.\n\n| Animation | Classification | Intent | Duration | Feel | Motion | Reduced motion |\n|-----------|---------------|--------|----------|------|--------|---------------|\n| Filters button badge count update | Connect | State | md · 200ms | Productive | Background + border + text color transition simultaneously | Preserved — color transitions are motion-safe |\n| Applied filter chips row appear | Reveal | Enter | lg · 300ms | Expressive | Fade + translateY(-8px) | Instant appear |\n| Applied filter chips row dismiss (Reset All) | Reveal | Exit | md · 200ms | Productive | Fade out | Instant disappear |\n| Individual chip dismiss (✕) | Reveal | Exit | md · 200ms | Productive | Fade out | Instant disappear |\n| Reset All button appear | Reveal | Enter | lg · 300ms | Expressive | Fade in | Instant appear |\n| Reset All button disappear | Reveal | Exit | md · 200ms | Productive | Fade out | Instant disappear |\n| Drawer open | — | Enter | lg · 300ms | — | Slide from right + scrim fade | Instant appear · scrim still fades |\n| Drawer close | — | Exit | md · 200ms | — | Reverse slide + scrim fade | Instant disappear |\n\n**Motion principle — asymmetric timing.** Enter animations (lg · 300ms) are intentionally slower\nthan exit animations (md · 200ms). Users need a moment to orient to new content; they do not\nneed to watch it leave. This applies to the drawer, chip row, and Reset All button throughout\nthe toolbar.\n\n---\n\n## Composing the Filter Toolbar from Spotlight pieces\n\nNo `FilterToolbar` wrapper component exists. Compose the pattern from these existing Spotlight\ncomponents using the CSS structure below.\n\n### HTML/CSS structure\n\n```tsx\n{/* Outer wrapper — flex-column, padding 8px, gap 16px between rows */}\n<div className={styles.filterToolbar}>\n\n {/* Bar row — flex, gap 16px, items-center */}\n <div className={styles.filterBar}>\n {/* Zone A — left, flex-start, 16px gap */}\n <div className={styles.filterControls}>\n <TextField mode=\"search\" /* ... */ /> {/* Slot 1 — always first */}\n <DatePicker /* From */ /> {/* Slot 2 — date range start */}\n <DatePicker /* To */ /> {/* Slot 2 — date range end */}\n <SelectInputField /* Status */ /> {/* Slot 3 */}\n {/* Slot 4+ entity dropdowns if applicable */}\n </div>\n\n {/* Zone B — right, flex-end */}\n <div className={styles.filterActions}>\n {showReset && <Button variant=\"ghost\">Reset Filters</Button>}\n {primaryAction}\n </div>\n </div>\n\n {/* Chip Row — renders only when ≥1 filter active */}\n {hasActiveFilters && (\n <div className={`${styles.chipRow} ${isExpanded ? styles.chipRowExpanded : ''}`}>\n {/* Visible chips — all chips when expanded, capped at threshold when collapsed */}\n {(isExpanded ? allChips : visibleChips).map(chip => (\n <Chip dismissible key={chip.id}>{chip.label}</Chip>\n ))}\n\n {/* \"+N more\" / \"Show less\" expand toggle — only when overflow exists */}\n {overflowCount > 0 && (\n <Button variant=\"ghost\" onClick={() => setIsExpanded(e => !e)}>\n {isExpanded ? 'Show less' : `+${overflowCount} more`}\n </Button>\n )}\n\n {/* Reset Filters — always far right via margin-left: auto */}\n {showReset && (\n <Button variant=\"ghost\" className={styles.resetFilters}>Reset Filters</Button>\n )}\n </div>\n )}\n</div>\n```\n\n### CSS values\n\n```css\n.filterToolbar {\n display: flex;\n flex-direction: column;\n gap: 16px; /* between Bar row and Chip Row */\n padding: 8px; /* all sides */\n width: 100%;\n margin-bottom: 16px; /* gap to table header — var(--space-md) */\n}\n\n/* contained variant */\n.filterToolbar--contained {\n background: var(--color-surface-secondary);\n border-radius: 18px; /* control radius (10px) + padding (8px) */\n}\n\n.filterBar {\n display: flex;\n align-items: center;\n gap: 16px;\n width: 100%;\n}\n\n.filterControls {\n display: flex;\n align-items: center;\n gap: 16px; /* 10px for condensed density */\n flex: 1;\n}\n\n.filterActions {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-left: auto;\n}\n\n.chipRow {\n display: flex;\n align-items: center;\n flex-wrap: nowrap; /* single row — no wrapping by default */\n gap: 8px;\n min-height: 28px; /* 34px for condensed variant */\n padding: 0 8px;\n overflow: hidden;\n}\n\n/* Expanded state — reveals all chips */\n.chipRowExpanded {\n flex-wrap: wrap;\n overflow: visible;\n}\n\n/* Reset Filters always pinned to far right */\n.resetFilters {\n margin-left: auto;\n flex-shrink: 0;\n}\n```\n\n### Spotlight components used\n\n| Element | Spotlight component |\n|---------|-------------------|\n| Search field | `TextField` (mode: search, start adornment: search icon, end adornment: clear X) |\n| Single-select dropdown | `SelectInputField` |\n| Multi-select dropdown | `MultiSelectInput` |\n| Date picker | `DatePicker` (×2 for date range) |\n| Applied filter chip | `Chip` (dismissible) |\n| Chip container | `ChipGroup` (flex-wrap) |\n| Overflow trigger | `ToggleButton` + `Popover` + `MaxChipSpillOverList` + `SpillOverItem` |\n| Reset Filters | `Button` (variant: ghost) — standard; icon-only `Button` with aria-label — condensed |\n| Primary action | `Button` (variant: primary) — conditional |\n\n**DateRangePicker gap.** No single composed From/To component exists. Use two `DatePicker`\ninstances side-by-side with a paired \"Clear dates\" link. Will be replaced by `DateRangePicker`\n(FILT-12) when available.\n\n---\n\n## Component gaps\n\n| Gap | Status | Impact |\n|-----|--------|--------|\n| `FilterToolbar` composition component | Not yet built — compose manually per recipe | Teams build own layout using Spotlight pieces |\n| `DateRangePicker` | Missing (FILT-12) | Use 2× `DatePicker` interim |\n| `MultiAutocompleteInput` | New — verify in Storybook | Multi-select entity filters |\n| `Badge xs` size | Missing | Count badge for condensed bar uses `sm` (24px) as fallback |\n\n---\n\n## Related patterns\n\n| Pattern | Purpose |\n|---------|---------|\n| `filter-selection-guide` | Which surface to use (Filter Bar vs Condensed vs Drawer) |\n| `filter-behavior-rules` | AND/OR logic, debounce, server/client, sort/pagination rules |\n| `filter-drawer-pattern` | Standalone Filter Drawer recipe |\n| `filter-communicating-applied-guide` | Applied chips, count badge, results count, zero results |\n| `filter-persistence-guide` | Session, URL param, and saved-view persistence |\n\n---\n\n*Pattern ID: `filter-toolbar-pattern` · Source: SPT-229 v0.4.0, SPT-225 v1.2.0, SPT-262 v1.0.0 · Design spec: Patterns — Timeless V2.0.0*\n",
568
+ "description": "The standard filter surface for tables, card grids, and lists in Spotlight. Composed from a **Bar row** (Search field + inline filter controls or Filters trigger button) and a **Chip Row** (applied filter chips, \"+N more\" overflow button, and Reset Filters). The Chip Row renders only when at least o",
569
+ "content": "# Filter Toolbar Pattern\n\nThe standard filter surface for tables, card grids, and lists in Spotlight. Composed from a\n**Bar row** (Search field + inline filter controls or Filters trigger button) and a **Chip Row**\n(applied filter chips, \"+N more\" overflow button, and Reset Filters). The Chip Row renders only\nwhen at least one filter is active. All toolbar controls use `size=\"md\"` and must not render a\nvisible label — use `placeholder` and `aria-label` so every input sits on the same baseline.\nThe Filters trigger button is right-aligned (Zone B). \"+N more\" shows a transient hover popover\n(dismisses on mouse-out) and expands the chip row in-place on click.\n\n**Core layout rule — bar vs drawer threshold.** The number of filter controls (excluding Search)\ndetermines which layout to use. This rule is non-negotiable and must be applied before any other\ntoolbar decision:\n\n| Filter controls (excl. Search) | Layout |\n|-------------------------------|--------|\n| **1–4** | **Filter Bar** — controls sit inline in the Bar row. Immediate apply on change. No Apply button. |\n| **5+** | **Condensed Filter Bar** — Search stays in the Bar row; ALL other filter controls move into the Overflow Filter Drawer behind a Filters trigger button. Batch Apply/Cancel inside the drawer. |\n\nSearch (`TextField` in search mode) is always excluded from the count and never triggers surface\nescalation. Date Range (two `DatePicker` instances) counts as **1**. Every other control counts\nas 1 each, regardless of how many values it holds.\n\n**Runtime trigger is fit, not count.** The 5+ count is a design-time estimate and legibility\nceiling. At runtime the component measures whether the controls fit in the bar's container width\n(container-relative, not viewport-relative). When controls can no longer fit at standard sizing\non one row, the bar escalates to Condensed regardless of exact count. Fit is primary; the count\nceiling is the backstop. This requires container-query support in the Spotlight stack — engineering\nmust confirm (or fall back to viewport breakpoints and document the limitation).\n\n**In progress — middle inline-density tier.** A middle tier — controls staying inline with\ntightened spacing and abbreviated labels before escalating to the drawer — is under consideration\n(SPT-225). Its guidelines are being defined separately and are not yet specced.\n\nNo `FilterToolbar` wrapper component exists yet — teams compose the pattern from Spotlight pieces\n(`TextField`, `SelectInputField`, `MultiSelectInput`, `AutocompleteInput`, `DatePicker`, `Chip`,\n`ChipGroup`, `Button`, `ToggleButton`, `Popover`) arranged with flex CSS. Source: SPT-229 v0.4.0,\nSPT-225 v1.2.0, SPT-262 v1.0.0. Design spec: Patterns — Timeless V2.0.0, node 2029:6499.\n\n---\n\n## Anatomy\n\nThe Filter Toolbar is a two-row container positioned above a data table. The top Bar row holds the\nSearch field and a Filters slot; the bottom Chip Row holds applied-filter chips and controls. The\nChip Row renders only when at least one filter is applied.\n\n### Anatomy callouts\n\n| # | Element | Description |\n|---|---------|-------------|\n| 1 | **Toolbar Container** | Outer 48px strip |\n| 2 | **Filters** | Right-aligned slot |\n| 3 | **Search Field** | Always first control |\n| 4 | **Controls — Max Chip Count, Reset Filters** | Right-aligned buttons |\n| 5 | **Applied Filters Chips** | Left-aligned |\n\n### Structural specifications\n\n| Property | Value | Note |\n|----------|-------|------|\n| Height | 48px | Controls sit at 32px. 8px top + bottom padding. |\n| Padding — all sides | 8px | Consistent horizontal and vertical. Follows nested border radius rule. |\n| Border radius — contained | 18px | Derived: control border radius (10px) + padding (8px) = 18px. |\n| Border radius — full-width | 0px | Toolbar runs flush with page margins. Nested radius rule does not apply. |\n| Background — default | None (transparent) | Toolbar blends with page surface. Spacing and control styling define the toolbar area. |\n| Border — default | None | Default variant. See Toolbar Variants section below. |\n| Width | 100% | Always equals width of the content container it controls. |\n| Spacing below toolbar | 16px | Gap between toolbar bottom edge and table header top edge. Maps to `--space-md`. |\n\n**Nested border radius rule.** Padding is derived from the relationship between the toolbar\ncontainer's border radius and the controls' border radius: outer radius = control radius (10px) +\npadding (8px) = 18px for the contained variant.\n\n### Two-row structure\n\n| Row | Contents | Layout |\n|-----|----------|--------|\n| **Bar** (top) | **Search** (flex-1, always left) · **Filters slot** (right — composition slot for filter controls) | `flex · gap: 16px · items-center · full-width` |\n| **Chip Row** (bottom) | RemovableChips · \"+N more\" expand button · Reset Filters (far right). **Rendered only when ≥1 filter active.** | `flex · flex-nowrap · gap: 8px · items-center · px: 8px · h: 28px` — single row, no wrapping |\n\nThe Filters slot is a composition slot — consuming teams place filter controls here in the fixed\norder defined below. The slot does not prescribe which controls appear; it provides the container.\n\n**Slot content depends on the bar/drawer threshold.** When 1–4 controls are present (excl. Search),\nthe Filters slot contains those controls inline. When 5+ controls are present, the Filters slot\ncontains only the Filters trigger button — all filter controls move into the Overflow Filter Drawer.\n\n### Zone structure\n\nZone A contents depend on which mode is active (set by the bar/drawer threshold rule above).\n\n| Zone | Filter Bar (1–4 controls) | Condensed Filter Bar (5+ controls) | Layout |\n|------|--------------------------|-------------------------------------|--------|\n| A — Filter Controls | Search + all inline filter controls (Date Range, Status, entity dropdowns, etc.) | **Search only** — all other filter controls are in the Overflow Filter Drawer | flex-start · 16px gap between controls |\n| B — Actions | Reset All (conditional) · Primary action (conditional) | **Filters trigger** (leftmost) · Reset All (conditional) · Primary action (conditional) | flex-end · Reset All left of primary action |\n\n**Filters trigger is right-aligned.** In the Condensed Filter Bar the Filters button sits in Zone B (right side of the bar). Zone A holds Search only — every other filter control (including Date Range, Status, and entity dropdowns) moves into the Overflow Filter Drawer, regardless of viewport width.\n\n### Control alignment and sizing\n\nAll filter controls in the bar row must be vertically aligned at their midpoint. Two rules enforce this:\n\n**Rule — no floating labels on toolbar controls.** Filter controls must not render a visible label above the input. A floating label shifts the control's bounding box downward and breaks alignment with adjacent controls that have none (see screenshot: \"Offer Status\" label above the dropdown misaligns it with the search field). Use `placeholder` for the visible hint and `aria-label` (or `aria-labelledby` pointing to a visually-hidden `<label>`) for accessibility instead.\n\n```tsx\n{/* Correct — no visible label, accessible via aria-label */}\n<SelectInputField\n aria-label=\"Offer Status\"\n placeholder=\"Offer Status\"\n size=\"md\"\n /* ... */\n/>\n\n{/* Incorrect — floating label pushes control down, misaligns row */}\n<SelectInputField\n label=\"Offer Status\"\n size=\"md\"\n /* ... */\n/>\n```\n\n**Rule — all toolbar controls use `md` size.** Every input in the bar row (TextField, SelectInputField, MultiSelectInput, AutocompleteInput, DatePicker) must use `size=\"md\"`. Do not mix `sm` and `md` in the same toolbar.\n\n| Control | Required size |\n|---------|--------------|\n| `TextField` (search) | `md` |\n| `SelectInputField` | `md` |\n| `MultiSelectInput` | `md` |\n| `AutocompleteInput` | `md` |\n| `DatePicker` | `md` |\n\n---\n\n### Design-spec dimensions (implement manually until component ships)\n\nThe design spec defines these dimensions as future component props. Until `FilterToolbar` ships,\nimplement each of these as explicit CSS or conditional rendering in the consuming screen.\n\n| Dimension | Spec value | How to implement today |\n|-----------|-----------|----------------------|\n| Variant | `default` (transparent) · `contained` (secondary surface) · `float` (transparent, white page) | Apply background color and border-radius to the wrapper div per variant |\n| Density | `standard` (16px gap) · `condensed` (10px gap) · `drawer-trigger` | Control the `gap` value on the Filters flex row |\n| Show search | Toggle `TextField` render | Conditional render of `TextField` |\n| Max chip count | Threshold before \"+N more\" spillover | Slice the chip array; render `ToggleButton \"+N more\"` for overflow items |\n| Reset visibility | Show only when ≥1 filter deviates from page default | Conditional render of ghost `Button` |\n| Reset display | `label` (\"Reset Filters\") for standard; `icon` (⊗ ≥24px) for condensed | Swap between `Button` with text and icon-only `Button` with `aria-label` |\n\n**Spillover behavior.** When chips exceed the max count threshold, the \"N+ more\" trigger reveals\nhidden filters in a Popover — on hover (transient, dismisses on mouse-out) and on click (pinned,\npersists until dismissed). Use a `ToggleButton` + `Popover` + `MaxChipSpillOverList` +\n`SpillOverItem` composition.\n\n### Spacing tokens\n\n```css\n/* Confirmed in style.css */\n--table-filter-margin-bottom: var(--space-xl); /* gap below filter bar */\n--table-search-min-width-lg: 250px; /* desktop search field */\n--table-search-min-width-md: 200px; /* tablet ≤768px */\n--table-search-min-width-sm: 150px; /* mobile ≤480px */\n\n/* Proposed additions for FilterToolbar component */\n--filter-toolbar-padding: 8px;\n--filter-toolbar-gap: 16px; /* gap between search and slot in Bar row */\n--filter-toolbar-gap-condensed: 10px; /* condensed modifier gap */\n--filter-toolbar-row-gap: 16px; /* gap between Bar row and Chip Row */\n--filter-toolbar-chip-row-height: 28px; /* Chip Row height */\n--filter-toolbar-margin-bottom: 16px; /* gap to table header — --space-md */\n--filter-toolbar-drawer-width: var(--drawer-size-width-sm); /* 350px */\n```\n\n---\n\n## Best Practices\n\n> Always place Search first in the Filters slot — it is required on every toolbar instance. Place the filters that serve your screen's most critical workflows immediately after Search, and suppress any slot that has no corresponding workflow. Never reorder the remaining slots. Keep the chip-row controls lean: show Reset All only when one or more filters deviate from the screen's default state, and only include a Primary Action button on the page header when it directly relates to the table content below.\n> — Patterns — Timeless V2.0.0\n\n### Rules at a glance\n\n| Rule | Detail |\n|------|--------|\n| **Bar vs drawer threshold** | **1–4 controls → inline in bar. 5+ controls → ALL controls (except Search) move into the Overflow Filter Drawer.** This is the first decision; everything else follows from it. Search is excluded from the count. Date Range counts as 1. |\n| Search is always first | Required on every toolbar instance, regardless of screen type. Never omit. |\n| Chip Row is always present when filters are active | The chip row MUST appear below the toolbar when ≥1 filter is active. Never suppress chips when filters are applied. |\n| Reset All shows on the page | The \"Reset filters\" button must be visible in the chip row on the page — not hidden behind a drawer or panel. Show it when ≥1 filter deviates from the page's default state. Hide it when filters are already at default. |\n| One Primary Action maximum | The primary action button (e.g. \"+ Create Offer\") belongs in the page header, not the filter toolbar, and only when it directly relates to the table content below. |\n| No submit button on the toolbar | All Zone A controls (inline filter bar) apply immediately on change — no Apply button needed. |\n| Never add a filter slot without a workflow | Omit any control that doesn't map to a primary user workflow on this screen. |\n\n---\n\n## Component configuration props\n\nThe API surface for configuring toolbar behavior and visibility. These correspond to the props that the future `FilterToolbar` component will expose; implement them as conditional rendering until the component ships.\n\n| Prop | Type | Description |\n|------|------|-------------|\n| `density` | Enum: `standard` · `condensed` · `drawer-trigger` | Controls gap between filter controls. `standard` = 16px gap; `condensed` = 10px gap; `drawer-trigger` = controls move into drawer |\n| `showSearch` | Boolean | Toggles the Search field in the Bar row. Almost always `true`. |\n| `maxChipCount` | Enum / Number | Max chips shown in the Chip Row before the \"+N more\" spillover trigger appears. Overflow chips surface in a Popover on hover (transient) or click (pinned). |\n| `allowReset` | Boolean | Shows the Reset Filters button in the Chip Row when ≥1 filter deviates from the page's default state (SPT-241). Hidden when filters are already at default. |\n| `resetDisplay` | Enum: `text` · `icon` | `text` = \"Reset filters\" ghost button (standard). `icon` = icon-only ⊗ (≥24px touch target) with `aria-label=\"Reset filters\"` + tooltip — condensed bar only. |\n| children (Filters slot) | ReactNode | Accepts any filter control combination in fixed slot order. The slot does not prescribe which controls appear; it provides the flex container. |\n\n---\n\n## Toolbar variants\n\nOne active variant. The `contained` and `float` variants were archived at SPT-229 v0.4.0 —\nthe `filterToolbarVariant` prop has been removed entirely.\n\n| Variant | Background | Status |\n|---------|-----------|--------|\n| `default` | None (transparent) | **Active** — the only variant. Works on any page surface; spacing and control styling define the area. |\n| `contained` | Secondary surface token | **Archived** — toolbar always sits above a table that is already inside a section; boxing the toolbar creates a box-in-a-box. No screen in our products needs it. Do not implement. |\n| `float` | None (transparent) | **Archived** — removed alongside `contained`. Do not implement. |\n\n---\n\n## Control ordering — Filters slot (fixed order)\n\nBefore placing controls, identify the primary workflows your screen supports — what are users most\nfrequently trying to find or narrow down? Let that answer drive which filters you include and how\nprominent they are.\n\n**How to use:**\n1. Identify your screen's primary workflows — what are users most frequently trying to find or narrow down?\n2. Map each workflow to the filter slot below. Workflows that drive the most filtering decisions should appear leftmost in Zone A.\n3. Omit any slot that doesn't apply to your screen's data. Never reorder the slots that remain.\n\nTeams may omit controls not relevant to their screen's data, but **may not reorder** controls\nthat are present.\n\n| Slot | Control | Spotlight component | Include when… |\n|------|---------|---------------------|---------------|\n| 1 — Always first | Search | `TextField` (mode: search) | Always. Every toolbar needs a search field regardless of screen type. |\n| 2 | Date Range (From / To) | `DatePicker` × 2 | Date is a primary workflow on this screen — users routinely narrow results by time period (e.g. pay periods, deadlines, week ending). |\n| 3 | Status | `SelectInputField` | Status is a primary workflow — users regularly need to isolate records by approval state, progress, or lifecycle stage. |\n| 4+ | Entity dropdowns (Employee, Project, Dept.) | `SelectInputField` or `AutocompleteInput` | Filtering by person, project, or department is a primary workflow. Admin and manager views only — suppress on single-entity employee views. |\n| Zone A — Left | Reset All | `Button` (variant: ghost or link) | Show only when 1 or more filters deviate from the screen's default state. Hidden when all filters are at their default. |\n| Zone B — Rightmost | Primary action (e.g. \"+ Create Offer\") | `Button` (variant: primary) | Conditional. Appears only when the action is directly related to the content in the table below. |\n\n---\n\n## Component mapping — filter control → Spotlight component\n\n**Deprecation notice.** `Select` is deprecated. All filter dropdowns must use `SelectInputField`.\n\n| Filter type | Component | Size | Key rule |\n|-------------|-----------|------|---------|\n| Text search | `TextField` (mode: search) | **md** | Search mode · Start adornment: search icon · End adornment: clear X · 300ms debounce · `aria-label` required (no visible label) |\n| Single select, <20 items | `SelectInputField` | **md** | Single-select mode · `placeholder` + `aria-label` — no `label` prop · List scannable without search |\n| Multi select, <20 items | `MultiSelectInput` | **md** | OR logic within filter · `placeholder` + `aria-label` — no `label` prop · Select All sticky at top |\n| Searchable entity, 20+ items | `AutocompleteInput` | **md** | Async search for 100+ items · `placeholder` + `aria-label` — no `label` prop · Single select · Legacy Autocomplete deprecated |\n| Multi searchable entity, 20+ items | `MultiAutocompleteInput` | **md** | OR logic · `placeholder` + `aria-label` — no `label` prop · Selections shown as dismissible chips inside trigger field |\n| Date — single | `DatePicker` | **md** | Calendar popup · MM/DD/YYYY · 280–320px popup width · `aria-label` required |\n| Date range | `DatePicker` × 2 | **md** | From/To side-by-side · 105px each · From constrains To minimum · Paired \"Clear dates\" link resets both · `aria-label` on each |\n| Date presets (Today, This Week…) | `ToggleButtonGroup` | Verify in Storybook | Mutually exclusive · Preset auto-populates date range · Include Custom fallback |\n| Boolean toggle (e.g. Read/Unread) | `Switch` | sm: 34×20px · md: 44×24px | Label describes active state · Default off = unfiltered · Immediate update |\n| Mutually exclusive options (2–5) | `ToggleButtonGroup` | Verify in Storybook | All options visible simultaneously · Toolbar use only |\n| Quick filter, combinable (3–6 values) | `Chip` / `ChipGroup` | Verify in Storybook | OR logic within selection · No dropdown · Status is primary candidate |\n| Grouped / hierarchical | No component available | — | Unvalidated use case. Do not implement until research confirms need. |\n\n---\n\n## Chip Row elements\n\nThe chip row is a **single, non-wrapping row**. Chips never wrap to a second line; overflow is\nhandled by the \"+N more\" expand button.\n\n| Element | Component | Condition |\n|---------|-----------|-----------|\n| RemovableChips | `Chip` (dismissible) | One per active filter, up to the visible threshold. Label format: \"FilterName: Value ✕\". |\n| \"+N more\" overflow button | `ToggleButton` + `Popover` (hover) / expand toggle (click) | Shown when applied chips exceed the visible threshold. Two distinct interactions — see below. |\n| Reset Filters | `Button` (variant: ghost) · `margin-left: auto` · **far right** | Visible when ≥1 filter deviates from page default. Always pinned to the right edge of the chip row regardless of how many chips are showing. Returns to page default state (not zero). |\n\n**\"+N more\" — dual interaction model.**\n\n| Trigger | Behavior |\n|---------|----------|\n| **Hover** | Opens a transient `Popover` listing all hidden chips. Dismisses automatically on mouse-out. Provides a quick preview without committing to expanded state. |\n| **Click** | Expands the chip row **in-place** — toggles the row to `flex-wrap` so all chips are visible. Button label changes to \"Show less\". Clicking again collapses back to single-line. This is a persistent, pinned state — does not dismiss on mouse-out. |\n\nThe hover popover and click-expand are independent states. A user can hover to preview and then\nclick to pin — the two interactions do not interfere.\n\n**Reset Filters positioning.** Reset Filters uses `margin-left: auto` to push itself to the far\nright of the chip row at all times — collapsed, expanded, and at every chip count.\n\n---\n\n## Reset behavior\n\n| Mechanism | What it clears | Confirmation |\n|-----------|---------------|--------------|\n| Chip ✕ button | That single filter only | None — immediate |\n| Reset All / Clear all | All active filters — returns to page default state | None — non-destructive |\n\n**Reset All rule.** Reset All returns to the page's default state — not to \"no filters.\" If a\ntable's default shows the current pay period, clearing returns to that default, not to all-time\nunfiltered. Reset All is hidden when no filters deviate from the default state.\n\n---\n\n## Surface variants — Filter Bar vs Condensed Filter Bar\n\n### Filter Bar (1–4 filter controls)\n\nControls sit inline in the Filters slot. Each control applies immediately on change — no Apply\nbutton. Gap between controls: 16px (standard).\n\n### Condensed Filter Bar (5+ filter controls)\n\nTriggered automatically by the component when it receives 5+ controls. Search stays in the Bar\nrow; all filter controls move into the Overflow Filter Drawer piece. A **Filters trigger** button\n(toggle + funnel icon + count badge) replaces the Filters slot.\n\n| Condensed element | Spec |\n|------------------|------|\n| Filters trigger | `ToggleButton` + funnel icon + applied-count badge (shows applied, never draft) |\n| Filter controls | All move into the Overflow Filter Drawer piece (sm · 350px) |\n| Apply model | Batch Apply / Cancel inside the drawer |\n| Chip Row height | 34px (vs 28px standard) |\n| Chip styling | White/light fill · grey border · optional leading adornment (icon/dot) · trailing ✕ dismiss |\n| MaxChipCount overflow | **\"+N\" plain text** (activatable, not a filled badge; shortened from standard \"+N more\") — opens spillover Popover (hover: transient / click: pinned) listing hidden chips, each dismissible |\n| Reset filters | Icon-only ⊗ (≥24px WCAG target) + `aria-label=\"Reset filters\"` + tooltip |\n\n**Auto-adapt.** The component counts controls it receives (Search = 0; Date Range = 1; each other\ncontrol = 1) and switches layout automatically. No screen-level surface selection needed.\n\n**State survives the threshold transition.** When controls move into the drawer on escalation,\ncurrent selections and applied-state indicators (chip row + trigger badge) must persist — no reset,\nno hidden state. Losing selections on transition is a known trust-damaging failure.\n\n**Labels, not icons, for filter controls.** Any filter control that stays in the bar or appears\ninside the drawer uses text labels — never icon-only triggers. Filter concepts (Status, Employee,\nProject, Department) have no conventional icons and fail recognition when stripped to glyphs.\nIcon-only is reserved for the chip-row Reset control only.\n\n---\n\n## Overflow Filter Drawer (piece)\n\nA distinct component piece owned by the Condensed Filter Bar (not an instance of the\nstandalone Filter Drawer SPT-223). Shares SPT-223's visual language but has its own header,\nfooter, and behavior.\n\n| Property | Value |\n|----------|-------|\n| Width | sm · 350px (`--drawer-size-width-sm`) |\n| Header | \"Filters\" + ✕ close |\n| Footer | Reset Filters (ghost) · Cancel (outlined) · Apply (solid) |\n| Apply model | Batch — data refreshes once on Apply |\n| Scrim tap / ✕ | Close + **preserve draft state** (reopening restores draft) |\n| Cancel | Discard draft · close |\n| Badge updates | Only on Apply — draft selections never increment the badge |\n\n**Interaction loop:**\n\n| Trigger | Behavior |\n|---------|----------|\n| Click Filters button | Drawer opens · controls pre-populated with applied state |\n| Make selections | Draft state — controls update visually; filters not applied yet |\n| Apply | Drawer closes · chip row updates · results refresh · badge = applied count |\n| Cancel | Drawer closes · draft discarded · chip row and results unchanged |\n| Scrim tap or ✕ | Drawer closes · draft **preserved** · results unchanged |\n| Reset Filters (drawer footer) | All controls cleared · drawer closes · chip row empties · Undo snackbar |\n| Remove chip (chip row) | That filter cleared immediately · Undo snackbar · drawer not required to be open |\n\n**Touch ergonomics.** Condensed density is a desktop concept — it does not carry into the drawer\non touch. Controls inside the Overflow Filter Drawer use comfortable spacing and ≥44px touch\ntargets on touch devices. Hover-only affordances (e.g. the spillover popover preview) need a\ntap equivalent on touch.\n\n**Fast-follow — bottom sheet.** Industry best practice favors a bottom sheet for one-handed\nmobile filtering. Spotlight has no bottom-sheet component yet. Re-evaluate the small-screen\nfilter container as a bottom sheet once Spotlight adds one; until then the sm drawer piece is\nthe container.\n\n---\n\n## Responsive behavior\n\n### Filter Bar (1–4 controls) — breakpoint collapse\n\nApplies only when the screen uses the **Filter Bar** (1–4 controls inline). The toolbar collapses\nprogressively across breakpoints. This table does NOT apply to the Condensed Filter Bar — in that\nmode all controls are already in the Overflow Filter Drawer at every breakpoint.\n\n| Spotlight breakpoint | Viewport | Search | Date Range | Status | Entity dropdowns |\n|---------------------|---------|--------|-----------|--------|-----------------|\n| Desktop + Laptop (≥1025px) | ≥1025px | ✓ Bar | ✓ Inline | ✓ Inline | ✓ Inline |\n| Tablet landscape (769–1024px) | 769–1024px | ✓ Bar | ✓ Inline | ✓ Inline | ✓ Inline (960px accommodates) |\n| Tablet portrait (601–768px) | 601–768px | ✓ Bar | ✓ Inline | ✓ Inline | → Drawer (sm) |\n| Phone (≤600px) | ≤600px | ✓ Bar | → Drawer (xsm) | → Drawer (xsm) | → Drawer (xsm) |\n\nWhen filter controls move to a Drawer at smaller breakpoints, the Drawer uses an explicit Apply\nbecause multiple filters may change simultaneously.\n\n### Condensed Filter Bar (5+ controls) — responsive behavior\n\nAll filter controls (Date Range, Status, entity dropdowns, and everything else) are already in the\nOverflow Filter Drawer at every viewport. Only Search stays in the Bar row. The Condensed Filter\nBar uses its three size variants (`xs` / `sm` / `md`) for responsive behavior rather than the\nbreakpoint table above.\n\n---\n\n## Motion\n\nAll animations follow Spotlight Motion Guidance. Designers pick an intent — the system resolves\nduration and easing.\n\n| Animation | Classification | Intent | Duration | Feel | Motion | Reduced motion |\n|-----------|---------------|--------|----------|------|--------|---------------|\n| Filters button badge count update | Connect | State | md · 200ms | Productive | Background + border + text color transition simultaneously | Preserved — color transitions are motion-safe |\n| Applied filter chips row appear | Reveal | Enter | lg · 300ms | Expressive | Fade + translateY(-8px) | Instant appear |\n| Applied filter chips row dismiss (Reset All) | Reveal | Exit | md · 200ms | Productive | Fade out | Instant disappear |\n| Individual chip dismiss (✕) | Reveal | Exit | md · 200ms | Productive | Fade out | Instant disappear |\n| Reset All button appear | Reveal | Enter | lg · 300ms | Expressive | Fade in | Instant appear |\n| Reset All button disappear | Reveal | Exit | md · 200ms | Productive | Fade out | Instant disappear |\n| Drawer open | — | Enter | lg · 300ms | — | Slide from right + scrim fade | Instant appear · scrim still fades |\n| Drawer close | — | Exit | md · 200ms | — | Reverse slide + scrim fade | Instant disappear |\n\n**Motion principle — asymmetric timing.** Enter animations (lg · 300ms) are intentionally slower\nthan exit animations (md · 200ms). Users need a moment to orient to new content; they do not\nneed to watch it leave. This applies to the drawer, chip row, and Reset All button throughout\nthe toolbar.\n\n---\n\n## Composing the Filter Toolbar from Spotlight pieces\n\nNo `FilterToolbar` wrapper component exists. Compose the pattern from these existing Spotlight\ncomponents using the CSS structure below.\n\n### HTML/CSS structure\n\n```tsx\n{/* Outer wrapper — flex-column, padding 8px, gap 16px between rows */}\n<div className={styles.filterToolbar}>\n\n {/* Bar row — flex, gap 16px, items-center */}\n <div className={styles.filterBar}>\n {/* Zone A — left, flex-start, 16px gap */}\n <div className={styles.filterControls}>\n <TextField mode=\"search\" /* ... */ /> {/* Slot 1 — always first */}\n <DatePicker /* From */ /> {/* Slot 2 — date range start */}\n <DatePicker /* To */ /> {/* Slot 2 — date range end */}\n <SelectInputField /* Status */ /> {/* Slot 3 */}\n {/* Slot 4+ entity dropdowns if applicable */}\n </div>\n\n {/* Zone B — right, flex-end */}\n <div className={styles.filterActions}>\n {showReset && <Button variant=\"ghost\">Reset Filters</Button>}\n {primaryAction}\n </div>\n </div>\n\n {/* Chip Row — renders only when ≥1 filter active */}\n {hasActiveFilters && (\n <div className={`${styles.chipRow} ${isExpanded ? styles.chipRowExpanded : ''}`}>\n {/* Visible chips — all chips when expanded, capped at threshold when collapsed */}\n {(isExpanded ? allChips : visibleChips).map(chip => (\n <Chip dismissible key={chip.id}>{chip.label}</Chip>\n ))}\n\n {/* \"+N more\" / \"Show less\" expand toggle — only when overflow exists */}\n {overflowCount > 0 && (\n <Button variant=\"ghost\" onClick={() => setIsExpanded(e => !e)}>\n {isExpanded ? 'Show less' : `+${overflowCount} more`}\n </Button>\n )}\n\n {/* Reset Filters — always far right via margin-left: auto */}\n {showReset && (\n <Button variant=\"ghost\" className={styles.resetFilters}>Reset Filters</Button>\n )}\n </div>\n )}\n</div>\n```\n\n### CSS values\n\n```css\n.filterToolbar {\n display: flex;\n flex-direction: column;\n gap: 16px; /* between Bar row and Chip Row */\n padding: 8px; /* all sides */\n width: 100%;\n margin-bottom: 16px; /* gap to table header — var(--space-md) */\n}\n\n/* contained variant */\n.filterToolbar--contained {\n background: var(--color-surface-secondary);\n border-radius: 18px; /* control radius (10px) + padding (8px) */\n}\n\n.filterBar {\n display: flex;\n align-items: center;\n gap: 16px;\n width: 100%;\n}\n\n.filterControls {\n display: flex;\n align-items: center;\n gap: 16px; /* 10px for condensed density */\n flex: 1;\n}\n\n.filterActions {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-left: auto;\n}\n\n.chipRow {\n display: flex;\n align-items: center;\n flex-wrap: nowrap; /* single row — no wrapping by default */\n gap: 8px;\n min-height: 28px; /* 34px for condensed variant */\n padding: 0 8px;\n overflow: hidden;\n}\n\n/* Expanded state — reveals all chips */\n.chipRowExpanded {\n flex-wrap: wrap;\n overflow: visible;\n}\n\n/* Reset Filters always pinned to far right */\n.resetFilters {\n margin-left: auto;\n flex-shrink: 0;\n}\n```\n\n### Spotlight components used\n\n| Element | Spotlight component |\n|---------|-------------------|\n| Search field | `TextField` (mode: search, start adornment: search icon, end adornment: clear X) |\n| Single-select dropdown | `SelectInputField` |\n| Multi-select dropdown | `MultiSelectInput` |\n| Date picker | `DatePicker` (×2 for date range) |\n| Applied filter chip | `Chip` (dismissible) |\n| Chip container | `ChipGroup` (flex-wrap) |\n| Overflow trigger | `ToggleButton` + `Popover` + `MaxChipSpillOverList` + `SpillOverItem` |\n| Reset Filters | `Button` (variant: ghost) — standard; icon-only `Button` with aria-label — condensed |\n| Primary action | `Button` (variant: primary) — conditional |\n\n**DateRangePicker gap.** No single composed From/To component exists. Use two `DatePicker`\ninstances side-by-side with a paired \"Clear dates\" link. Will be replaced by `DateRangePicker`\n(FILT-12) when available.\n\n---\n\n## Component gaps\n\n| Gap | Status | Impact |\n|-----|--------|--------|\n| `FilterToolbar` composition component | Not yet built — compose manually per recipe | Teams build own layout using Spotlight pieces |\n| `DateRangePicker` | Missing (FILT-12) | Use 2× `DatePicker` interim |\n| `MultiAutocompleteInput` | New — verify in Storybook | Multi-select entity filters |\n| `Badge xs` size | Missing | Count badge for condensed bar uses `sm` (24px) as fallback |\n\n---\n\n## Related patterns\n\n| Pattern | Purpose |\n|---------|---------|\n| `filter-selection-guide` | Which surface to use (Filter Bar vs Condensed vs Drawer) |\n| `filter-behavior-rules` | AND/OR logic, debounce, server/client, sort/pagination rules |\n| `filter-drawer-pattern` | Standalone Filter Drawer recipe |\n| `filter-communicating-applied-guide` | Applied chips, count badge, results count, zero results |\n| `filter-persistence-guide` | Session, URL param, and saved-view persistence |\n\n---\n\n*Pattern ID: `filter-toolbar-pattern` · Source: SPT-229 v0.4.0, SPT-225 v1.2.0, SPT-262 v1.0.0 · Design spec: Patterns — Timeless V2.0.0*\n",
561
570
  "components": [],
562
571
  "sourcePath": "content/recipes/filter-toolbar-pattern.md"
563
572
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@castandcrew/spotlight-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "MCP server for the Spotlight Design System",
5
5
  "type": "module",
6
6
  "bin": {