diff --git a/.claude/superpowers/specs/2026-04-20-base-view-draft-design.md b/.claude/superpowers/specs/2026-04-20-base-view-draft-design.md new file mode 100644 index 00000000..b2bf4ab0 --- /dev/null +++ b/.claude/superpowers/specs/2026-04-20-base-view-draft-design.md @@ -0,0 +1,465 @@ +# Base View Draft (Local-First Filter & Sort) — Design Spec + +**Date:** 2026-04-20 +**Status:** Draft +**Feature area:** `apps/client/src/features/base` (client-only) + +## Goal + +Make filter and sort changes on a base view **local-first**: they apply instantly for the editing user, are scoped to their own browser/profile, and never touch the server baseline until the user explicitly clicks "Save for everyone". A banner at the top of the table surfaces the draft state and lets the user either promote the draft to the shared baseline or discard it. + +This removes the current Notion-unlike behavior where every filter/sort tweak is auto-persisted and immediately inflicted on every teammate viewing the same view. + +## Non-goals (v1) + +- **Column layout in draft mode.** Column visibility, order, and widths continue to flow through the existing debounced `persistViewConfig` path in [use-base-table.ts:371-396](../../../apps/client/src/features/base/hooks/use-base-table.ts). No draft behavior for them. (Listed as a future extension.) +- **Server-side per-user drafts.** localStorage only. A user clearing their browser storage, switching devices, or using a different browser profile loses drafts — by design. +- **"Save as new view".** The screenshot hints at a dropdown caret next to the Save button for a "save as new view" split-action. Not in v1. +- **Kanban / calendar.** Only the `table` view type exists today; spec scopes to it but the hook is type-agnostic and will apply trivially when other view types land. +- **Automatic garbage collection of stale drafts.** Drafts persist indefinitely until the user resets or saves. No TTL, no eager cleanup when baseline values match the draft. +- **Conflict UI.** If another user writes a new baseline while I have local drafts, my draft silently wins on my client. No "baseline changed" warning. + +## UX overview + +### Draft banner + +Placement: **between** the page title and [BaseToolbar](../../../apps/client/src/features/base/components/base-toolbar.tsx), inside [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx) above the `` node (around [base-table.tsx:192](../../../apps/client/src/features/base/components/base-table.tsx)). The banner is part of the table's own layout, not a workspace-level chrome element, because it's tied to a specific view. + +Render condition: `isDirty === true` (see "Dirty check"). + +Layout (match the reference screenshot): + +- Mantine `` with a soft background (`bg="yellow.0"` or `bg="orange.0"` depending on theme palette — pick whichever tolerates dark mode) and a small info icon on the left. +- Left region: short message — `t("Filter and sort changes are visible only to you.")`. +- Right region (a ``): + - `` — underline-on-hover "text link" feel; wipes the draft. + - `` — primary accent (project's default theme color — orange in the screenshot maps to Mantine's configured `primaryColor`, so `color` is omitted and the theme default is used). +- The "Save for everyone" button is **omitted entirely** for users without edit permission (see "Permission gating"). "Reset" always shows. +- The banner never animates in/out on every keystroke — it only appears/disappears when `isDirty` flips. Add a Mantine `` wrap if the flip is jarring; otherwise mount unconditionally with a `{isDirty && ...}` guard. + +### Filter/sort editors in draft mode + +No UI affordance changes inside the filter or sort popovers themselves. They keep the same open-on-click, add/remove/edit flow. The only behavioral change is that their `onChange` callback writes to the draft store rather than firing `updateView` — completely transparent to the editor components. + +### Reset behavior + +Click Reset → the draft hook removes its localStorage entry → the table re-renders reading filter/sorts from `activeView.config` (the server baseline). Any currently-open filter/sort popover closes on outside click as usual; if it's open when the user clicks Reset, the next render shows the baseline values. No notification — the banner disappearing is sufficient feedback. + +### Save for everyone + +Click Save → call the existing `useUpdateViewMutation` from [base-view-query.ts:43-112](../../../apps/client/src/features/base/queries/base-view-query.ts) with `{ viewId, baseId, config: { ...serverBaseline, filter: draft.filter, sorts: draft.sorts } }`. On success, clear the localStorage key and show a Mantine notification `t("View updated for everyone")`. On error, keep the draft; the mutation already wires the error toast. + +### Permission gating + +A user can edit this base iff their space membership grants `SpaceCaslAction.Edit, SpaceCaslSubject.Base` — the same check the server enforces in [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts). Viewers still get local drafts (the entire point is that local changes don't require edit permission), but their "Save for everyone" button is hidden. + +**Client caveat:** [permissions.type.ts](../../../apps/client/src/features/space/permissions/permissions.type.ts) currently only exports `Settings`, `Member`, and `Page` subjects. The server enum has `Base` but the client enum doesn't. The spec adds `Base = "base"` to `SpaceCaslSubject` and widens the `SpaceAbility` union — that's a one-line change plus import fix. + +## Data model + +### localStorage key + +``` +docmost:base-view-draft:v1:{userId}:{baseId}:{viewId} +``` + +- Namespace prefix `docmost:base-view-draft:` keeps us from colliding with other consumers. +- `v1` is the schema version so a future breaking change can shed old entries by skipping. +- `{userId}` scopes drafts so a shared-device login-swap doesn't leak drafts across accounts. `userId` comes from `useAtomValue(userAtom)` ([current-user-atom.ts:11-25](../../../apps/client/src/features/user/atoms/current-user-atom.ts)). +- `{baseId}` and `{viewId}` together uniquely identify which table state the draft applies to. + +### Value shape + +```ts +// apps/client/src/features/base/types/base.types.ts (additive) +export type BaseViewDraft = { + filter?: FilterGroup; + sorts?: ViewSortConfig[]; + updatedAt: string; // ISO timestamp, written on each put — used only for diagnostics +}; +``` + +Both `filter` and `sorts` are optional, independently. An absent field means "inherit baseline for that axis". That matters because a user who's only dirtied sorts but not filters should see the baseline filter unchanged if the baseline's filter later shifts. + +Serialized as JSON via `JSON.stringify` / `JSON.parse`. No schema validation on read — if the parse fails or the shape looks wrong, the hook drops it silently and falls back to baseline. + +## Client architecture + +### New hook: `useViewDraft` + +**File:** `apps/client/src/features/base/hooks/use-view-draft.ts` + +```ts +export type ViewDraftState = { + draft: BaseViewDraft | null; + // The filter/sorts that should actually drive the table and row query. + // `draft.X ?? baseline.X` — i.e. draft wins per-axis, baseline fills gaps. + effectiveFilter: FilterGroup | undefined; + effectiveSorts: ViewSortConfig[] | undefined; + isDirty: boolean; + setFilter: (filter: FilterGroup | undefined) => void; + setSorts: (sorts: ViewSortConfig[] | undefined) => void; + reset: () => void; + // Used by the Save handler — returns the composed config to pass to updateView. + buildPromotedConfig: (baseline: ViewConfig) => ViewConfig; +}; + +export function useViewDraft(args: { + userId: string | undefined; + baseId: string | undefined; + viewId: string | undefined; + baselineFilter: FilterGroup | undefined; + baselineSorts: ViewSortConfig[] | undefined; +}): ViewDraftState; +``` + +**Behavior:** + +1. Compute the storage key `docmost:base-view-draft:v1:{userId}:{baseId}:{viewId}`. If any of the three ids is undefined, the hook returns a "passthrough" state (`draft=null`, `isDirty=false`, all setters no-op, effective* falls through to baseline). +2. On mount and whenever the key changes, read the value from `localStorage` and `JSON.parse`. Invalid or missing → `draft=null`. +3. `setFilter` / `setSorts` merge into the current draft, write to `localStorage`, update React state. An update that sets both axes back to `undefined` (i.e. no local divergence remaining) **removes the key entirely** rather than writing an empty `{}` — this keeps `isDirty` clean when the user manually undoes all their changes. +4. `reset` is `localStorage.removeItem(key)` + `setDraft(null)`. +5. `isDirty` is computed as: any draft key present, AND `!shallowEqualFilter(draft.filter, baselineFilter) || !shallowEqualSorts(draft.sorts, baselineSorts)`. The "orphan" rule (draft values matching baseline → banner hidden) is enforced here; see "Dirty check". +6. Subscribes to `window.addEventListener("storage", ...)` with a callback that re-reads on matching key changes from other tabs (see "Cross-tab sync"). +7. Writes use a synchronous `localStorage.setItem` — no debouncing. localStorage writes are cheap and the filter/sort popovers commit in discrete user actions (clicking Save inside the popover), not keystroke-by-keystroke. + +**Return composition:** + +- `effectiveFilter = draft?.filter ?? baselineFilter` +- `effectiveSorts = draft?.sorts ?? baselineSorts` + +### Integration into `useBaseTable` and `base-table.tsx` + +`useBaseTable` at [use-base-table.ts:224](../../../apps/client/src/features/base/hooks/use-base-table.ts) currently derives the table's initial sort from `activeView.config.sorts`. In the new world the table's sort/filter state must come from the **effective** values (draft-or-baseline), not the raw `activeView.config`. + +Two cut options were considered: + +**Option A (chosen): drive from effective values via props.** `useBaseTable` takes an additional `effectiveConfig?: ViewConfig` parameter (or, cleaner, the caller passes a shallow-merged `activeView` whose `config` is `{ ...activeView.config, filter: effective.filter, sorts: effective.sorts }`). `buildSortingState` and the row query already read from `activeView.config`, so the cleanest shape is to mutate the config the hook receives, not to introduce a new parameter. + +**Option B (rejected): thread draft deep into `useBaseTable`.** Adds the concept of drafts to a hook that only cares about the rendered state. Muddies responsibilities. + +Going with A. In [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx): + +```ts +// NEW: wire the draft hook +const { data: user } = useCurrentUser(); +const { draft, effectiveFilter, effectiveSorts, isDirty, setFilter, setSorts, reset, buildPromotedConfig } = + useViewDraft({ + userId: user?.user.id, + baseId, + viewId: activeView?.id, + baselineFilter: activeView?.config?.filter, + baselineSorts: activeView?.config?.sorts, + }); + +// Swap the raw `activeView` for a view with effective config so the table and row query see drafts. +const effectiveView = useMemo( + () => + activeView + ? { ...activeView, config: { ...activeView.config, filter: effectiveFilter, sorts: effectiveSorts } } + : undefined, + [activeView, effectiveFilter, effectiveSorts], +); + +// Row query reads effective filter/sorts. +const { data: rowsData, ... } = useBaseRowsQuery( + base ? baseId : undefined, + effectiveFilter, + effectiveSorts, +); + +// Table is seeded from effectiveView. +const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView); +``` + +The server-roundtrip `persistViewConfig` keeps being called for column layout changes — it reads from `activeView.config` via `buildViewConfigFromTable`, and we pass it `activeView` (not `effectiveView`) to make sure a pending layout write doesn't accidentally bake the draft filter into the server baseline. See next subsection. + +### Filter & sort write-path changes + +Today, filter/sort editors feed `BaseToolbar`'s handlers: + +- [base-toolbar.tsx:135-148](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleSortsChange` → builds config via `buildViewConfigFromTable(table, activeView.config, { sorts: newSorts })` → `updateViewMutation.mutate(...)`. +- [base-toolbar.tsx:150-169](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleFiltersChange` → same pattern with `{ filter }`. + +Both write directly to the server. That's the exact site to branch. + +**New `base-toolbar.tsx`:** accept two new callbacks from `base-table.tsx`: + +```ts +onDraftSortsChange: (sorts: ViewSortConfig[]) => void; +onDraftFiltersChange: (filter: FilterGroup | undefined) => void; +``` + +The toolbar drops its internal `updateViewMutation.mutate` calls for sort/filter (retains them for view tabs / view type flip if any exists elsewhere). `handleSortsChange` becomes: + +```ts +const handleSortsChange = useCallback( + (newSorts: ViewSortConfig[]) => { + onDraftSortsChange(newSorts); // writes to useViewDraft via base-table + }, + [onDraftSortsChange], +); +``` + +Same for filters — the FilterCondition[]→FilterGroup wrapping logic at [base-toolbar.tsx:152-157](../../../apps/client/src/features/base/components/base-toolbar.tsx) stays; only the final dispatch target changes. + +**`base-table.tsx`** wires those callbacks to the draft hook: + +```ts +const handleDraftSortsChange = useCallback( + (sorts: ViewSortConfig[]) => setSorts(sorts.length ? sorts : undefined), + [setSorts], +); +const handleDraftFiltersChange = useCallback( + (filter: FilterGroup | undefined) => setFilter(filter), + [setFilter], +); +``` + +The "normalize empty to undefined" rule is how we let the draft go clean after the user deletes every filter — the draft hook's "remove key if both axes are undefined" rule then kicks in. + +**Toolbar badge counts:** [base-toolbar.tsx:118-128](../../../apps/client/src/features/base/components/base-toolbar.tsx) currently derives `sorts` and `conditions` from `activeView.config`. Switch these to read from the **effective** config (`effectiveView.config`) so the toolbar badges reflect the draft's count, not the baseline. The toolbar already accepts `activeView` — pass it `effectiveView` instead, since everything the toolbar reads from `activeView` (name, sorts, filter) should be in the effective form. + +**The `buildViewConfigFromTable` call site in `handleColumnReorder` / `handleResizeEnd` / field-visibility:** these continue reading from `activeView.config` (the real baseline) and going through `updateViewMutation`. They do **not** read from the draft. This is deliberate — column layout stays auto-persisted. + +However: `buildViewConfigFromTable` currently spreads its `base` argument and emits `sorts` from the live table state. For the debounced `persistViewConfig` call at [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts), the `base` arg is the effective config (because we pass `effectiveView` into `useBaseTable`), but the emitted `sorts` comes from the table's live state — which was seeded from effective. That means if the user drafts a sort and then reorders a column, the debounced persist would write `{ ...effectiveConfig, sorts: draftSorts }` back to the server. **Bug.** + +Fix: when building the config for the auto-persist path in `persistViewConfig`, override the emitted `sorts` and `filter` with the **baseline** values, not the effective ones. Concretely, change [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts) to + +```ts +const config = buildViewConfigFromTable(table, activeView.config, { + sorts: activeView.config?.sorts, + filter: activeView.config?.filter, +}); +``` + +where `activeView` in that callsite is the **real** activeView (not the effective one). So `useBaseTable` needs both: the effective view for seeding and rendering, and the real baseline for the persist path. + +Simplest refactor: give `useBaseTable` an optional `baselineConfig?: ViewConfig` argument. If omitted (existing callers), behave as today. If provided, `persistViewConfig` uses `baselineConfig` for sort/filter overrides. `base-table.tsx` passes `activeView.config` as the baseline and the effective-wrapped view as the active. + +This keeps `useBaseTable`'s own responsibilities tidy and makes the "drafts don't leak into the layout write-path" rule explicit. + +## Banner component + +**File:** `apps/client/src/features/base/components/base-view-draft-banner.tsx` + +```ts +type BaseViewDraftBannerProps = { + isDirty: boolean; + canSave: boolean; + onReset: () => void; + onSave: () => void; + saving: boolean; +}; + +export function BaseViewDraftBanner({ isDirty, canSave, onReset, onSave, saving }: BaseViewDraftBannerProps) { + const { t } = useTranslation(); + if (!isDirty) return null; + return ( + + + + + {t("Filter and sort changes are visible only to you.")} + + + + {canSave && ( + + )} + + + + ); +} +``` + +Wiring in [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx), inserted between the existing page chrome and ``: + +```ts +const { data: space } = useSpaceQuery(base?.spaceId ?? ""); +const spaceAbility = useSpaceAbility(space?.membership?.permissions); +const canSave = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Base); +const updateViewMutation = useUpdateViewMutation(); +const handleSaveDraft = useCallback(async () => { + if (!activeView || !base) return; + const config = buildPromotedConfig(activeView.config); + await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config }); + reset(); + notifications.show({ message: t("View updated for everyone") }); +}, [activeView, base, buildPromotedConfig, reset, updateViewMutation, t]); + +return ( +
+ + + +
+); +``` + +The `useSpaceQuery`/`useSpaceAbility` pair follows the same pattern as [use-history-restore.tsx:35-41](../../../apps/client/src/features/page-history/hooks/use-history-restore.tsx). + +## Cross-tab sync + +The draft hook subscribes to the browser `storage` event: + +```ts +useEffect(() => { + const handler = (e: StorageEvent) => { + if (e.key !== storageKey) return; + // e.newValue is the serialized draft or null if the key was removed. + if (e.newValue === null) { + setDraftState(null); + } else { + try { + setDraftState(JSON.parse(e.newValue)); + } catch { + setDraftState(null); + } + } + }; + window.addEventListener("storage", handler); + return () => window.removeEventListener("storage", handler); +}, [storageKey]); +``` + +The `storage` event fires in *other* tabs of the same origin when this tab writes (not in the writing tab itself), which is exactly what we need: the writing tab already updated its own state synchronously inside `setFilter`/`setSorts`, and the subscription catches the echo elsewhere. + +No explicit rebroadcast is required — `localStorage.setItem` in the source tab triggers the storage event in every other tab automatically. The hook in those tabs re-parses and re-renders the table with updated draft values. React Query's row cache keyed by `(baseId, filter, sorts, search)` rehydrates the new filter/sort as a fresh infinite query, so rows reload via the normal path. + +Edge case: two tabs editing simultaneously — both writes land in localStorage, each emits a storage event to the other, and the most recent write wins. This is acceptable given the single-user scope (multi-tab same-user). + +## Save flow (pseudocode) + +```ts +async function onSaveForEveryone() { + if (!activeView || !base) return; + // 1. Compose the promoted config from the server baseline + draft values. + // baseline is activeView.config (NOT effectiveView.config) because the + // baseline might include layout fields (propertyWidths, propertyOrder, + // hiddenPropertyIds, visiblePropertyIds) that we must preserve verbatim. + const config: ViewConfig = { + ...activeView.config, + filter: draft.filter ?? activeView.config.filter, + sorts: draft.sorts ?? activeView.config.sorts, + }; + // 2. Fire the existing mutation. `updateViewMutation` already: + // - optimistically updates the ["bases", baseId] query cache + // - rolls back on error + // - writes the server response back on success + await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config }); + // 3. Clear the draft. Because the baseline has now caught up to what the + // draft said, isDirty flips to false and the banner unmounts. + reset(); + notifications.show({ message: t("View updated for everyone") }); +} +``` + +Error handling: `useUpdateViewMutation` already shows a red toast and rolls back the optimistic cache update on failure. We do *not* call `reset()` in that case — the draft stays, the banner stays, the user can retry. + +## Dirty check + +`isDirty` lives inside `useViewDraft`. Returns `true` iff the draft file exists AND at least one of these is true: + +- `draft.filter !== undefined` AND `!deepEqualFilter(draft.filter, baselineFilter)` +- `draft.sorts !== undefined` AND `!deepEqualSorts(draft.sorts, baselineSorts)` + +**Deep equality:** the codebase has no `lodash` or `fast-deep-equal` in [client package.json](../../../apps/client/package.json). Options: + +1. **`JSON.stringify` both sides and compare strings.** Trivially correct for `FilterGroup` (a pure data tree) and `ViewSortConfig[]`. Key ordering inside objects is deterministic in V8+ for non-numeric keys, which is the case here. Pick this — it's 4 lines and good enough for this shape. +2. Hand-written structural compare — overkill for two types with known finite shapes. + +Go with option 1. Helpers live in `use-view-draft.ts`: + +```ts +function filterEq(a: FilterGroup | undefined, b: FilterGroup | undefined) { + return JSON.stringify(a ?? null) === JSON.stringify(b ?? null); +} +function sortsEq(a: ViewSortConfig[] | undefined, b: ViewSortConfig[] | undefined) { + return JSON.stringify(a ?? null) === JSON.stringify(b ?? null); +} +``` + +**Orphan suppression.** The agreed rule: when the draft's values equal the baseline, the banner hides. The dirty check above already does that — a draft with `filter: X` where baseline is also `X` yields `filterEq === true` for that axis, and if the sorts axis is also equal (or absent), `isDirty === false`. The key stays in localStorage (no eager GC), but the banner is invisible until the user next diverges or another tab updates the baseline. + +## Testing + +Per [CLAUDE.md](../../../CLAUDE.md), the client has no test infrastructure (no `vitest` in the workspace). This spec does not block on adding one. Testing is primarily manual QA + optional unit tests if Vitest is introduced alongside this feature. + +### Unit tests (proposed, Vitest — gated on harness being added) + +`use-view-draft.test.ts`: + +- **Initialize with no stored value.** Hook returns `draft=null`, `isDirty=false`, effective values fall through to baseline. +- **`setFilter` writes to localStorage and updates state.** After `setFilter(X)`, `localStorage.getItem(key)` parses back to `{ filter: X, updatedAt: ... }`, `draft.filter === X`, `isDirty === true`. +- **`setSorts` writes independently.** `draft.filter` stays undefined even after `setSorts(...)`, and vice versa. +- **`setFilter(undefined)` then `setSorts(undefined)` removes the key.** After both axes are cleared, `localStorage.getItem(key)` is null. +- **`reset` clears both state and storage.** +- **Draft values equal to baseline → `isDirty === false` without clearing storage.** Set baseline to `B`, set draft filter to `B`, assert `isDirty === false` and `localStorage.getItem(key)` is still non-null (no eager GC). +- **Baseline change while draft exists.** Baseline shifts from `B1` to `B2`, draft filter is `X`. Effective filter stays `X`, `isDirty` stays `true`. Then baseline shifts again to `X` — `isDirty` flips to `false` without draft being cleared. +- **Cross-tab storage event.** Dispatch `new StorageEvent('storage', { key, newValue: JSON.stringify(newDraft) })`, assert hook state picks up the new draft. Dispatch with `newValue: null` and assert hook resets to `null`. +- **Malformed storage value.** Seed localStorage with garbage → hook reads `draft=null`, `isDirty=false`, table receives baseline. +- **`userId` missing → passthrough.** All setters are no-ops, `isDirty=false`, effective = baseline. + +### Manual QA checklist + +**Single user, single tab.** +- Apply a filter. Banner appears. Row list updates locally. +- Click Reset. Banner disappears. Filter in the popover reverts to baseline. Row list reverts. +- Apply a filter and a sort. Click Save for everyone. Banner disappears. Refresh the page — the filter/sort is now the new baseline (i.e. came back from the server). +- Apply a filter, then manually delete it via the filter popover. Banner disappears. Subsequent refresh does not restore the deleted filter (baseline untouched). + +**Single user, multiple tabs.** +- Open base in tab A and tab B. In tab A, add a sort. Tab B re-renders with the same sort applied (verified by checking the sort popover badge and the row order). Tab B shows the banner. +- In tab B, click Reset. Tab A's banner disappears and sort reverts. + +**Multi-user baseline race.** +- User X (editor) opens base. Applies a filter (draft). User Y (editor) in another session saves a brand-new baseline via their own Save flow. User X's client receives the websocket `base:schema:bumped` → `["bases", baseId]` invalidates → `activeView.config` updates. User X's `effectiveFilter` still shows X's draft filter (draft wins). Banner stays. No UI prompt. If X now clicks Reset, they see Y's new baseline. + +**Permission gating.** +- As a space Viewer (who has Read but not Edit on `Base`): open base, apply a filter. Banner appears but shows only "Reset" — no "Save for everyone" button. +- Server check: attempting Save as a viewer would have been blocked by [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts) anyway; the UI gate is belt-and-suspenders. + +**Reset with popover open.** +- Open the filter popover and add conditions. Without closing the popover, click Reset (the banner is visible behind the popover dropdown — it's positioned above). Popover closes on outside-click, baseline conditions show next open. + +**Save clears draft + updates server.** +- Save. Banner vanishes. localStorage key for `{user,base,view}` is absent. Re-open the base in an incognito/second-account browser — the filter/sort shows too (from the server). + +**Browser storage cleared.** +- In DevTools, wipe `localStorage`. Base re-renders with baseline. Banner gone. Expected. + +## Rollout + +- **No DB migration.** No server change. +- **No feature flag.** Behavior change ships as-is. +- **No data migration.** Existing users have no drafts; the system starts empty. +- **Behavioral change vs. today.** Existing users' muscle memory is "touch a filter → auto-saves for everyone". After this ships, that becomes "touch a filter → only I see it until I hit Save for everyone". This is the entire point of the feature but will surprise power users on day one. + - Mitigation: none in v1. A one-time popover/tooltip pointing at the banner ("New: filter and sort changes are now a draft until you save") is worth doing, but falls squarely in YAGNI territory for the first ship. + - **Followup:** consider a dismissible one-time in-product hint the first time a user diverges from baseline after the deploy. Flag this as a follow-up task; do not ship with v1. + +## Risks & open questions + +- **localStorage quota.** `FilterGroup` + `ViewSortConfig[]` is tiny — a realistic draft is under 2KB. A worst-case malicious user with thousands of views could hit the 5–10MB per-origin cap, but practically negligible. No cleanup logic needed. +- **Users losing drafts via browser data clear.** Expected. The banner is a live indicator, not a durable source of truth. Flagged in non-goals. +- **Multi-device divergence.** Same user on laptop and phone: drafts don't sync. Expected and flagged. +- **Dropdown caret ("Save as new view") in the screenshot.** Explicitly out of scope for v1. If we add it, the caret menu would include: + 1. "Save for everyone" (current behavior) + 2. "Save as new view" (creates a new `IBaseView` with draft values baked into `config`) +- **Baseline layout fields overriding draft.** Save flow does `{ ...activeView.config, filter: X, sorts: Y }`. If another user changed column widths right before Save, those widths land in the Save's payload (we already read the latest optimistic cache). Acceptable — the alternative (send a sparse patch with only `{filter, sorts}`) would require a server-side partial-update endpoint we don't have. +- **Invalid draft for stale schema.** If a property is deleted while a user's draft references it by id, the predicate/sort engine on the server silently drops unknown property ids. Client-side, the sort/filter popover shows the condition with a missing-property label (existing behavior — the toolbar already does `properties.find((p) => p.id === …)` and tolerates the `undefined` case). No special handling needed here; the draft just falls away when the user next edits and doesn't re-add the dead condition. +- **`SpaceCaslSubject.Base` missing from client enum.** Single-line fix at [permissions.type.ts:12](../../../apps/client/src/features/space/permissions/permissions.type.ts). Flagged so reviewers notice. + +## Future extension + +1. **Draft column layout.** Extend the draft shape to carry `propertyWidths`, `propertyOrder`, `hiddenPropertyIds`, `visiblePropertyIds`. Column reorder / hide / resize call the draft hook instead of `persistViewConfig`. `useBaseTable` then seeds column state from effective values. Mechanically identical to filter/sort — the hook already takes arbitrary ViewConfig fragments. The only reason this isn't in v1 is to minimize behavioral change surface and keep the spec scope narrow. +2. **Server-side per-user drafts.** For cross-device sync, add a `base_view_drafts` table keyed by `(userId, viewId)` storing the same shape. The client hook swaps localStorage for a paired mutation + query. The banner UX stays identical. +3. **Split-button save.** Dropdown caret next to "Save for everyone" offering "Save as new view" — creates an `IBaseView` via `createView` with the effective config. Deepens the Notion parallel. +4. **Draft conflict hint.** When baseline changes while I have drafts, show a subtle "Baseline has changed since your last edit" line inside the banner with a "Discard draft and load latest" affordance. Expected to be low value in practice — flag once real users report it.