From 395d9599645af459993e10cf94e906355cad6115 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 27 Oct 2026 08:56:29 +0000 Subject: [PATCH 1/4] feat(web): make the memory detail panel resizable and remember its width Drag the drawer's left edge (or focus it and use the arrow keys, Home/End) to resize; double-click resets. The width is stored per browser as a fraction of the viewport and rendered through a CSS clamp() whose bounds are viewport-relative (max(320px, 15vw) to 70vw), so it scales when moving between monitors. Default stays 512px. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- docs/key-files.md | 1 + docs/otel.md | 1 + .../lore/LessonDetailSheet.test.stories.tsx | 90 +++++++ .../src/components/lore/LessonDetailSheet.tsx | 42 +++- .../lore/LessonPanelResizeHandle.tsx | 238 ++++++++++++++++++ packages/web/src/content/docs/tags.mdx | 2 + packages/web/src/lib/analytics/lore-events.ts | 8 + packages/web/src/lib/analytics/track.spec.ts | 13 + packages/web/src/lib/analytics/track.ts | 17 ++ .../web/src/lib/hooks/useViewportWidth.ts | 40 +++ .../web/src/lib/lesson-panel-width.spec.ts | 151 +++++++++++ packages/web/src/lib/lesson-panel-width.ts | 148 +++++++++++ .../web/src/lib/persisted-preference.spec.ts | 29 +++ packages/web/src/lib/persisted-preference.ts | 29 +++ 14 files changed, 807 insertions(+), 2 deletions(-) create mode 100644 packages/web/src/components/lore/LessonPanelResizeHandle.tsx create mode 100644 packages/web/src/lib/hooks/useViewportWidth.ts create mode 100644 packages/web/src/lib/lesson-panel-width.spec.ts create mode 100644 packages/web/src/lib/lesson-panel-width.ts diff --git a/docs/key-files.md b/docs/key-files.md index 918f04ef9..50d0dd5e0 100644 --- a/docs/key-files.md +++ b/docs/key-files.md @@ -168,6 +168,7 @@ else. Grouped loosely by subsystem in migration/PR order. | `packages/web/src/components/ui/SegmentedControl.tsx` | The ONE segmented button group. Extracted from `RangePicker`, which owned the look privately, the moment a second surface needed it (the Explorer's Activity view toggle) — two copies of a control that must look identical is how it stops looking identical. `role="radiogroup"`/`role="radio"`, deliberately NOT `tablist`/`tab`: a tab OWNS the panel it reveals and promises arrow-key navigation plus a `tabpanel` relationship, while this is a filter on what a surface shows and sometimes has no panel at all (the range picker changes four numbers in place). The buttons stay individually tabbable, matching what `RangePicker` already did and what its tests pin. A `trailing` slot carries an out-of-set arm inside the same rail (`RangePicker`'s inert custom-window chip, which must look like a CHECKED segment or the group reads as unset — hence the exported `SEGMENTED_TRAILING_CLASS`), and `labels="wide"` hides the visible labels below the `@md` CONTAINER width so a dense header collapses to icons rather than wrapping. **Not for every one-of-N choice** — when width is scarce or an option needs a sentence of explanation, `Combobox` is right instead; `StatusControl` is the worked example of choosing that deliberately over this. Covered by `SegmentedControl.stories.tsx` | | `packages/web/src/components/ui/AnchoredTooltip.tsx` | A portaled readout positioned against an arbitrary `anchor: Element`. Exists because a chart's per-bucket readout was an `absolute` panel inside the bar, and in the Explorer that bar sits in `CollapsibleStatCard`'s `overflow: hidden` reveal region — so it was CLIPPED at the card's edge exactly when the chart was worth reading. `z-index` cannot fix that; `overflow` clips descendants regardless of stacking, so the only fix is to stop being a descendant. It reuses the pure `computeTooltipPosition` exported by `Tooltip.tsx` verbatim rather than growing a second positioner. What it cannot reuse is `Tooltip`'s SHAPE: `Tooltip` wraps its own trigger and owns its own hover state, so it is one component, one portal and one listener set PER TRIGGER — 364 of each for a 52-week heatmap. This is the content-only half: the caller says which element is anchoring and when, and ONE instance serves every bucket. Holds the element, not a rect, because a `fixed` panel is in viewport coordinates and re-measures on capture-phase scroll/resize while open. Consumers: `dashboard/Sparkbar.tsx`, `activity/ContributionHeatmap.tsx` | | `packages/web/src/lib/persisted-preference.ts` (+ `lib/hooks/usePersistedPreference.ts`) | Per-viewer UI preferences — the pure codec plus the `localStorage` shell. Three call sites had each rolled their own read+parse and re-derived the same two questions: what does an ABSENT value mean, and what does a CORRUPT one mean (both: the default). `PREFERENCE_KEYS` is the single registry, so a writer and a reader can never disagree by one character. The hook is `useSyncExternalStore` over a module-level registry, mirroring `useMediaQuery` rather than `useDismissedInviteIds`: a mount-effect always paints the DEFAULT first and corrects on the next tick, which for a disclosure state is the exact "expanded, then snap shut" flash the feature exists to remove. **`getServerSnapshot` returns `null` and is its only producer** — "no client store consulted" is a DIFFERENT fact from `''` "consulted, nothing stored", and collapsing the two brings the flash back; consumers render their NEUTRAL state while unresolved. Subscribes to `storage`, so two tabs agree for free. Every access is inside `try/catch` (`localStorage` throws on the GETTER under blocked site data) and failure degrades to "does not persist", never to a thrown render. Covered by `persisted-preference.spec.ts` | +| `packages/web/src/lib/lesson-panel-width.ts` (+ `components/lore/LessonPanelResizeHandle.tsx`) | The desktop memory detail panel's resizable width. The preference (`PREFERENCE_KEYS.lessonPanelWidth`) is a FRACTION of the viewport, not pixels, so a width chosen on a big monitor becomes the same share of a laptop screen; both bounds are viewport-relative too (≥ max(320px, 15vw), ≤ 70vw) and the default stays the historical 512px. The panel renders `panelWidthCss()`, a CSS `clamp()`, so a resize or a monitor change re-resolves in the browser with no re-render; every other function here mirrors that formula so `aria-valuenow` and the stored value match what is drawn. The handle writes `style.width` directly during a drag (the panel is too heavy to re-render per `pointermove`) and commits once per gesture. Covered by `lesson-panel-width.spec.ts` and the `DrawerResizesFromItsEdge` story | | `packages/web/src/lib/explorer-insights-view.ts` | The Activity panel's two bodies as a closed union with its labels, accessible names, icons and defaults in ONE module — the `status-filter.ts` pattern. `charts` is the default because it answers a question about the CURRENT selection, which is what the scope chips, filter bar and range picker beside it have just been used to express; opening on the heatmap (deliberately account-wide and unfiltered) would make the panel look unresponsive to every control around it. `DEFAULT_INSIGHTS_OPEN = true` — it opened COLLAPSED while it showed both bodies at once; showing one at a time roughly halved the expanded panel, which is what made the evidence affordable by default. Covered by `explorer-insights-view.spec.ts` | | `packages/web/src/lib/format-number.ts` | The dashboard's number renderings, all pinned to `en-US` so a server render and its hydration cannot format one figure two ways: `formatExact` (grouped, and what `AnimatedNumber` always announces), `formatHeadline` (a `StatCard` headline: exact below 10,000, three significant digits above — `1.57M` — so it never exceeds five characters and crowds the chip on its row; `isHeadlineAbbreviated` gates the exact-value hover title), `formatCompact` (a figure in a narrow slot, ≥10,000, ungrouped below), and `formatPercentDelta` (a `TrendChip`'s signed change, ≥1,000). One private `compactAbove` parameterised by threshold AND `Intl` instance, so a `K` means the same thing everywhere — the property that breaks the moment a second call site rolls its own abbreviation. The percentage threshold is an order of magnitude lower because the slot is: a chip shares a line with the figure it annotates and carries a sign and a `%` the figure does not, so `+8834%` was seven characters in a gap sized for five and collided with a `22,425` beside it. 1000 and not lower, because `+100%` means "doubled" and must survive verbatim. The percentage uses `maximumSignificantDigits: 2`, not `maximumFractionDigits: 1`, which is unbounded in the hundreds band (`883.4K`, longer than what it is shortening). `isPercentDeltaAbbreviated` lets `TrendChip` expose the exact figure — via the `AnimatedNumber` two-node pattern plus the title it already had — ONLY where precision was actually dropped. Covered by `format-number.spec.ts` | | `supabase/migrations/00103_reads_must_not_restamp_updated_at.sql` | The fix for the counter that was lying: `lorekit_record_memory_reads` (00099) bumped `read_count`/`last_read_at` with a plain UPDATE, which fires `lorekit_memories_set_updated_at`, so ONE 55-row `memory.list` page restamped 55 memories' `updated_at` with no write of any kind. `updated_at` is the DEFAULT SORT for the Explorer and `memory.list order=recency`, so the order agents received lore in was driven by what had recently been READ. Fixed inside the read-recording function via a session-local guard the trigger honours — not by dropping the trigger (writes need it) and not by making each caller restore the old value. Pinned by `migrations.test.sql` §101 | diff --git a/docs/otel.md b/docs/otel.md index a3778da5c..8b5523f1a 100644 --- a/docs/otel.md +++ b/docs/otel.md @@ -1013,6 +1013,7 @@ attribute here. | `lore.memory_content_tab` | Preview ⇄ Edit inside the panel | `lorekit.lore.memory.content_tab`, `.content_tab_source` (`click`/`shortcut`) | | `lore.memory_edit` | An edit was saved or discarded | `lorekit.lore.memory.edit_action`, `.edit_outcome` (saves only), `.changed_value`, `.changed_tags`, `.changed_ttl` | | `lore.memory_archive_toggled` | A memory was archived or restored | `lorekit.lore.memory.archive_action`, `.archive_outcome` | +| `lore.memory_panel_resized` | The desktop detail panel's width was changed (one event per drag, key press-and-hold, or reset) | `lorekit.lore.memory.panel_resize_source` (`drag`/`keyboard`/`reset`), `.panel_viewport_percent` (number: share of the viewport the panel now covers) | | `lore.filter_changed` | Any filter-bar mutation, age/activity thresholds included | `lorekit.lore.filter.action`, `.field` (optional), `.operator` (optional), `.count`, `.retention_count` | | `lore.status_changed` | Active / Archived / Expiring | `lorekit.lore.status` | | `lore.sort_changed` | The list order was picked (Newest / Most chosen / Recent read) | `lorekit.lore.sort` (`created_at`/`opened_count`/`last_opened_at`) | diff --git a/packages/web/src/components/lore/LessonDetailSheet.test.stories.tsx b/packages/web/src/components/lore/LessonDetailSheet.test.stories.tsx index ecd2d8652..10a7ac6b7 100644 --- a/packages/web/src/components/lore/LessonDetailSheet.test.stories.tsx +++ b/packages/web/src/components/lore/LessonDetailSheet.test.stories.tsx @@ -6,6 +6,13 @@ import { LessonDetailSheet } from './LessonDetailSheet'; import type { ContentTab } from './content-tabs'; import type { LessonEntry } from './LessonCard'; import { withQueryClient } from '@/mocks/decorators'; +import { + PANEL_KEYBOARD_STEP_PX, + panelFractionFromWidth, + panelWidthBounds, + panelWidthPx, +} from '@/lib/lesson-panel-width'; +import { PREFERENCE_KEYS, parseFractionPreference } from '@/lib/persisted-preference'; /** * Interaction tests for {@link LessonDetailSheet} — the mobile bottom-sheet @@ -306,6 +313,89 @@ export const DrawerOpenDoesNotStealFocus: Story = { }, }; +// ── Resizing (desktop drawer) ──────────────────────────────────────────────── + +/** + * Clears the stored width so the story starts from the default AND leaves none + * behind: the visual stories share this origin's `localStorage`, and a leftover + * width would move every drawer baseline. + */ +function resetPanelWidth() { + try { + window.localStorage.removeItem(PREFERENCE_KEYS.lessonPanelWidth); + } catch { + // Blocked site data behaves like "nothing stored", which is the same start. + } +} + +/** + * The drawer's left edge is a focusable window splitter: arrow keys and Home + * resize it, the result is stored as a share of the screen (so it survives a + * reload and scales across monitors), and a double-click goes back to the + * default. Expected widths are derived from the iframe's own viewport through + * the same pure functions the panel renders with, rather than hard-coded for + * one iframe size. The pointer-drag path is the same commit with a different + * source; its geometry is covered by `lesson-panel-width.spec.ts`. + */ +export const DrawerResizesFromItsEdge: Story = { + render: (args) => , + beforeEach: () => { + resetPanelWidth(); + return resetPanelWidth; + }, + play: async ({ step }) => { + const dialog = await body().findByRole('dialog', { name: /memory detail/i }); + await waitFor(() => expect(dialog).toBeVisible()); + const separator = body().getByRole('separator', { name: /resize memory panel/i }); + const viewport = window.innerWidth; + const defaultWidth = Math.round(panelWidthPx(null, viewport)); + + await step('starts at the default width with nothing stored', async () => { + await expect(separator).toHaveAttribute('aria-valuenow', String(defaultWidth)); + await expect(window.localStorage.getItem(PREFERENCE_KEYS.lessonPanelWidth)).toBeNull(); + }); + + await step('ArrowLeft widens the panel one step and stores it as a share of the screen', async () => { + const storedFraction = panelFractionFromWidth(defaultWidth + PANEL_KEYBOARD_STEP_PX, viewport); + const widened = Math.round(panelWidthPx(storedFraction, viewport)); + separator.focus(); + await userEvent.keyboard('{ArrowLeft}'); + await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', String(widened))); + await expect(Math.abs(dialog.getBoundingClientRect().width - widened)).toBeLessThanOrEqual(1); + await expect( + parseFractionPreference(window.localStorage.getItem(PREFERENCE_KEYS.lessonPanelWidth)), + ).toBe(storedFraction); + }); + + await step('Home takes it to the smallest width this screen allows', async () => { + await userEvent.keyboard('{Home}'); + const smallest = Math.round(panelWidthBounds(viewport).min); + await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', String(smallest))); + await expect(Math.abs(dialog.getBoundingClientRect().width - smallest)).toBeLessThanOrEqual(1); + }); + + await step('double-clicking the edge resets to the default', async () => { + await userEvent.dblClick(separator); + await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', String(defaultWidth))); + await expect(Math.abs(dialog.getBoundingClientRect().width - defaultWidth)).toBeLessThanOrEqual(1); + // `''` is the store's "nothing stored" — it parses back as the default. + await expect( + parseFractionPreference(window.localStorage.getItem(PREFERENCE_KEYS.lessonPanelWidth)), + ).toBeNull(); + }); + }, +}; + +/** The mobile sheet spans the screen, so it has no resize edge at all. */ +export const SheetHasNoResizeEdge: Story = { + play: async ({ step }) => { + await body().findByRole('dialog', { name: /memory detail/i }); + await step('no separator renders on the bottom sheet', async () => { + await expect(body().queryByRole('separator', { name: /resize memory panel/i })).not.toBeInTheDocument(); + }); + }, +}; + // ── Content tabs ───────────────────────────────────────────────────────────── export const PreviewIsDefault: Story = { diff --git a/packages/web/src/components/lore/LessonDetailSheet.tsx b/packages/web/src/components/lore/LessonDetailSheet.tsx index 06dd67965..5f17809b2 100644 --- a/packages/web/src/components/lore/LessonDetailSheet.tsx +++ b/packages/web/src/components/lore/LessonDetailSheet.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { AnimatePresence, motion, useDragControls } from 'motion/react'; import { X, Bot, Zap, Clock, CalendarClock, Archive, RotateCcw, Github, Users, UserCircle, Timer, Layers, Cpu, Repeat, BookOpenCheck, MousePointerClick, Quote } from 'lucide-react'; @@ -40,6 +40,14 @@ import { listMemberIdentities } from '@/lib/org-members'; import { scopeRepoUrl } from '@/lib/scope'; import { originLinks } from '@/lib/origin'; import { useIsMobile } from '@/lib/hooks/useMediaQuery'; +import { usePersistedPreference } from '@/lib/hooks/usePersistedPreference'; +import { + PREFERENCE_KEYS, + parseFractionPreference, + serializeFractionPreference, +} from '@/lib/persisted-preference'; +import { panelWidthCss } from '@/lib/lesson-panel-width'; +import { LessonPanelResizeHandle, type PanelWidthChange } from './LessonPanelResizeHandle'; import { shouldDismissSheet } from '@/components/ui/bottom-sheet'; import { track } from '@/lib/analytics/track'; import { toast } from 'sonner'; @@ -461,6 +469,23 @@ export function LessonDetailSheet({ lesson, onClose, onMutated, layout = 'auto', const belowMd = useIsMobile(); const isSheet = layout === 'sheet' || (layout === 'auto' && belowMd); const dragControls = useDragControls(); + // The desktop drawer's width: a per-browser preference stored as a fraction of + // the viewport, rendered through a CSS `clamp()` whose bounds also follow the + // viewport (`lib/lesson-panel-width.ts`). Unresolved or absent both mean the + // default width — unlike a disclosure, there is no "neutral" width to hold + // while the store is unread, and the default is what the panel always was. + const panelWidthPref = usePersistedPreference(PREFERENCE_KEYS.lessonPanelWidth); + const panelWidthFraction = parseFractionPreference(panelWidthPref.raw); + const { write: writePanelWidth } = panelWidthPref; + const handlePanelWidthCommit = useCallback( + ({ fraction, source, viewportPercent }: PanelWidthChange) => { + // `''` is the store's "nothing stored", so a reset goes back to the default + // in this tab and, through the `storage` event, in every other one. + writePanelWidth(fraction === null ? '' : serializeFractionPreference(fraction)); + track({ name: 'lore.memory_panel_resized', source, viewportPercent }); + }, + [writePanelWidth], + ); const archiveMutation = useArchiveLesson(); const restoreMutation = useRestoreLesson(); const isPending = archiveMutation.isPending || restoreMutation.isPending; @@ -788,8 +813,12 @@ export function LessonDetailSheet({ lesson, onClose, onMutated, layout = 'auto', 'fixed z-50 flex flex-col bg-[var(--color-bg-raised)] shadow-2xl', isSheet ? 'inset-x-0 bottom-0 max-h-[90vh] rounded-t-2xl border-t border-[var(--color-border)] pb-[env(safe-area-inset-bottom)]' - : 'inset-y-0 right-0 w-full max-w-lg border-l border-[var(--color-border)]', + : 'inset-y-0 right-0 border-l border-[var(--color-border)]', ].join(' ')} + // The drawer's width is inline because its bounds are viewport- + // relative and the preferred value is the reader's — see + // `panelWidthCss`. The sheet spans the screen and has no width. + style={isSheet ? undefined : { width: panelWidthCss(panelWidthFraction) }} role="dialog" // Only the mobile sheet is modal — see the backdrop above (R1). // `undefined` (not `false`) omits the attribute entirely for the @@ -803,6 +832,15 @@ export function LessonDetailSheet({ lesson, onClose, onMutated, layout = 'auto', // announcing that selecting a row updates this panel. id="lesson-detail-panel" > + {/* Resize edge — desktop drawer only. Drag the left edge (or focus it + and use the arrow keys) to change the width; double-click resets. */} + {!isSheet && ( + + )} + {/* Drag handle — bottom sheet only. Grabbing it starts the drag; the body scrolls independently (dragListener is off). */} {isSheet && ( diff --git a/packages/web/src/components/lore/LessonPanelResizeHandle.tsx b/packages/web/src/components/lore/LessonPanelResizeHandle.tsx new file mode 100644 index 000000000..4a21b89ca --- /dev/null +++ b/packages/web/src/components/lore/LessonPanelResizeHandle.tsx @@ -0,0 +1,238 @@ +'use client'; + +/** + * The draggable left edge of the desktop memory detail panel. + * + * A WAI-ARIA window splitter (`role="separator"`, focusable, with a value): drag + * it, or focus it and use ← / → (Shift for bigger steps), Home / End; double- + * click it to go back to the default width. + * + * ## Why the drag writes the DOM directly + * + * The panel (`LessonDetailSheet`) is a large component — a rendered markdown + * body, a form, a dozen metadata rows. Re-rendering it on every `pointermove` + * would make the drag stutter on exactly the long memories a reader widens the + * panel for. So while a drag (or a held arrow key) is in progress, this writes + * the panel's `style.width` itself, through the same `panelWidthCss` formatter + * the panel renders with, and commits ONCE when the gesture ends: one storage + * write, one panel render, one analytics event. The committed render produces the + * same string the drag wrote, so React and the DOM agree afterwards. + * + * ## Focus + * + * `pointerdown` is `preventDefault`ed. That stops the drag from starting a text + * selection, and it also keeps a pointer drag from moving focus onto the handle — + * which matters because the desktop drawer is non-modal and the Explorer list is + * meant to keep keyboard ownership (see the focus notes in `LessonDetailSheet`). + * Keyboard users reach the handle with Tab, like any other control. + */ + +import { + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, +} from 'react'; + +import type { MemoryPanelResizeSource } from '@/lib/analytics/lore-events'; +import { useViewportWidth } from '@/lib/hooks/useViewportWidth'; +import { + keyboardPanelWidth, + panelFractionFromWidth, + panelWidthBounds, + panelWidthCss, + panelWidthPx, + viewportPercent, +} from '@/lib/lesson-panel-width'; + +/** A pointer must travel this far before a press becomes a drag. */ +const DRAG_THRESHOLD_PX = 3; + +const RESIZE_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'Home', 'End']); + +export interface PanelWidthChange { + /** The new stored fraction of the viewport, or `null` to go back to the default. */ + fraction: number | null; + source: MemoryPanelResizeSource; + /** The share of the viewport the panel now covers — for analytics. */ + viewportPercent: number; +} + +interface LessonPanelResizeHandleProps { + /** The stored width fraction the panel is rendering, or `null` for the default. */ + fraction: number | null; + /** Persist a finished resize. Called once per gesture, never per pointer move. */ + onCommit: (change: PanelWidthChange) => void; +} + +interface DragState { + pointerId: number; + startX: number; + /** + * Where inside the handle the pointer grabbed it, relative to the panel's + * edge — so the edge does not jump to the cursor on the first move. + */ + grabOffset: number; + moved: boolean; +} + +export function LessonPanelResizeHandle({ fraction, onCommit }: LessonPanelResizeHandleProps) { + const handleRef = useRef(null); + const dragRef = useRef(null); + // The fraction the DOM is showing mid-gesture but that has not been committed. + const pendingRef = useRef(null); + // Mirrors `pendingRef` for rendering, so `aria-valuenow` follows the drag. Only + // this small component re-renders on each step — never the panel. + const [previewFraction, setPreviewFraction] = useState(null); + const [dragging, setDragging] = useState(false); + const viewportWidth = useViewportWidth(); + + const shownFraction = previewFraction ?? fraction; + + function panel(): HTMLElement | null { + return handleRef.current?.closest('[role="dialog"]') ?? null; + } + + function preview(next: number) { + const element = panel(); + if (!element) return; + pendingRef.current = next; + element.style.width = panelWidthCss(next); + setPreviewFraction(next); + } + + function commit(source: MemoryPanelResizeSource) { + const next = pendingRef.current; + pendingRef.current = null; + setPreviewFraction(null); + if (next === null) return; + onCommit({ fraction: next, source, viewportPercent: Math.round(next * 100) }); + } + + /** Abandon an uncommitted gesture and put the rendered width back. */ + function revert() { + pendingRef.current = null; + setPreviewFraction(null); + const element = panel(); + if (element) element.style.width = panelWidthCss(fraction); + } + + function handlePointerDown(event: ReactPointerEvent) { + if (event.button !== 0) return; + const element = panel(); + if (!element) return; + event.preventDefault(); + try { + // Capture keeps the moves coming while the cursor is over the list. + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // `NotFoundError` for a pointer the browser no longer considers active + // (and for synthetic test pointers). The drag still works while the + // cursor stays over the handle; it must never throw out of a handler. + } + const edgeX = window.innerWidth - element.getBoundingClientRect().width; + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + grabOffset: event.clientX - edgeX, + moved: false, + }; + setDragging(true); + } + + function handlePointerMove(event: ReactPointerEvent) { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + // A double-click's two presses jitter by a pixel or two; without a threshold + // each one would commit a "drag" before the reset. + if (!drag.moved && Math.abs(event.clientX - drag.startX) < DRAG_THRESHOLD_PX) return; + drag.moved = true; + const viewport = window.innerWidth; + if (viewport <= 0) return; + const widthPx = viewport - (event.clientX - drag.grabOffset); + preview(panelFractionFromWidth(widthPx, viewport)); + } + + function endDrag(event: ReactPointerEvent, outcome: 'commit' | 'revert') { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + dragRef.current = null; + setDragging(false); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + if (outcome === 'commit') commit('drag'); + else revert(); + } + + function handleDoubleClick() { + pendingRef.current = null; + setPreviewFraction(null); + const viewport = window.innerWidth; + onCommit({ + fraction: null, + source: 'reset', + viewportPercent: viewportPercent(panelWidthPx(null, viewport), viewport), + }); + } + + function handleKeyDown(event: ReactKeyboardEvent) { + const viewport = window.innerWidth; + if (viewport <= 0) return; + const current = panelWidthPx(pendingRef.current ?? fraction, viewport); + const next = keyboardPanelWidth(event.key, { shiftKey: event.shiftKey }, current, viewport); + if (next === null) return; + event.preventDefault(); + preview(panelFractionFromWidth(next, viewport)); + } + + // A held arrow key repeats `keydown`; committing on `keyup` (or on blur, if + // focus leaves mid-hold) makes one press-and-hold one change, like one drag. + function handleKeyUp(event: ReactKeyboardEvent) { + if (RESIZE_KEYS.has(event.key)) commit('keyboard'); + } + + const bounds = viewportWidth === null ? null : panelWidthBounds(viewportWidth); + const valueNow = viewportWidth === null ? null : Math.round(panelWidthPx(shownFraction, viewportWidth)); + + return ( +
endDrag(event, 'commit')} + onPointerCancel={(event) => endDrag(event, 'revert')} + onLostPointerCapture={(event) => endDrag(event, 'commit')} + onDoubleClick={handleDoubleClick} + onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} + onBlur={() => { + // A pointer drag finishes on pointerup, not here; this only catches a + // held arrow key whose keyup never arrives because focus moved away. + if (!dragRef.current) commit('keyboard'); + }} + // 24px wide (the hit-target floor), centred on the panel's left border: + // half over the list, half over the panel's 20px header/body padding, so + // it never sits on top of a control. Invisible until hovered, focused or + // dragged — the cursor is the affordance, as in most editors. + className="group/resize absolute inset-y-0 -left-3 z-10 w-6 cursor-col-resize touch-none select-none focus-visible:outline-none" + > + +
+ ); +} diff --git a/packages/web/src/content/docs/tags.mdx b/packages/web/src/content/docs/tags.mdx index 5ddac95d2..271922584 100644 --- a/packages/web/src/content/docs/tags.mdx +++ b/packages/web/src/content/docs/tags.mdx @@ -101,6 +101,8 @@ Tags are the one dimension that can hold **two pills at once** — one that incl You can also filter straight from a memory's detail panel. Hover any tag, kind, host, agent, trigger, repository, branch or pull request and two icons appear: **filter in** (funnel +) shows only memories with that value, **filter out** (funnel −) hides them. Hover an icon to see exactly what it will do. The filter is added to whatever is already applied — filtering out a tag while another tag is filtered in gives you both conditions — and a value you have already filtered in or out shows that icon pressed, so clicking it again takes the filter back off. From the keyboard, focus the value and press `Enter` to filter in or `Shift+Enter` to filter out. The scope offers filter in only. +On a desktop screen the detail panel can be made wider or narrower: drag its left edge, or focus the edge and use `←` / `→` (hold `Shift` for bigger steps). Double-click the edge to go back to the default width. Your width is remembered in this browser as a share of the screen, so it scales when you move between a large monitor and a laptop. + Two shortcuts worth knowing: - **Type the value, not the dimension.** With the menu open, typing `main` surfaces `Branch → main` directly, so you never have to choose "Branch" first. diff --git a/packages/web/src/lib/analytics/lore-events.ts b/packages/web/src/lib/analytics/lore-events.ts index 316fd5a7c..7567a14f7 100644 --- a/packages/web/src/lib/analytics/lore-events.ts +++ b/packages/web/src/lib/analytics/lore-events.ts @@ -159,6 +159,14 @@ export type LoreInstrumentAction = export type MemoryContentTab = 'preview' | 'edit'; export type MemoryContentTabSource = 'click' | 'shortcut'; +/** + * How the desktop detail panel's width was changed: dragging its edge, the + * arrow/Home/End keys on that edge, or a double-click back to the default. + * Kept apart because whether the keyboard path is used at all is one of the + * things the event exists to answer. + */ +export type MemoryPanelResizeSource = 'drag' | 'keyboard' | 'reset'; + /** An edit either lands or is thrown away. */ export type MemoryEditAction = 'save' | 'discard'; export type MemoryMutationOutcome = 'success' | 'error'; diff --git a/packages/web/src/lib/analytics/track.spec.ts b/packages/web/src/lib/analytics/track.spec.ts index 0236305f7..18ec074ce 100644 --- a/packages/web/src/lib/analytics/track.spec.ts +++ b/packages/web/src/lib/analytics/track.spec.ts @@ -361,6 +361,19 @@ describe('track — lore.memory_archive_toggled', () => { }); }); +describe('track — lore.memory_panel_resized', () => { + it('sends how the width changed and the share of the screen as a number', () => { + // A number, not a string: "how wide do people make it" is averaged and + // percentiled in Dash0, which a stringified percentage could only group by. + track({ name: 'lore.memory_panel_resized', source: 'keyboard', viewportPercent: 45 }); + expect(lastCall().name).toBe('lore.memory_panel_resized'); + expect(lastCall().attributes).toEqual({ + 'lorekit.lore.memory.panel_resize_source': 'keyboard', + 'lorekit.lore.memory.panel_viewport_percent': 45, + }); + }); +}); + describe('track — lore.filter_changed', () => { it('sends the dimension and the operator, never the value', () => { track({ diff --git a/packages/web/src/lib/analytics/track.ts b/packages/web/src/lib/analytics/track.ts index 3f6bdbaf2..41ce0aa53 100644 --- a/packages/web/src/lib/analytics/track.ts +++ b/packages/web/src/lib/analytics/track.ts @@ -44,6 +44,7 @@ * - `lore.memory_content_tab` — Preview ⇄ Edit inside the detail panel. * - `lore.memory_edit` — an edit was saved or discarded. * - `lore.memory_archive_toggled` — a memory was archived or restored. + * - `lore.memory_panel_resized` — the desktop detail panel's width was changed. * - `lore.filter_changed` — any filter-bar mutation, thresholds included. * - `lore.status_changed` — Active / Archived / Expiring. * - `lore.sort_changed` — the list order (Newest / Most chosen / Recent read). @@ -120,6 +121,7 @@ import type { MemoryEditAction, MemoryMutationOutcome, MemoryOpenSurface, + MemoryPanelResizeSource, RangeTelemetryPreset, ScopeSelectionType, } from './lore-events'; @@ -271,6 +273,16 @@ export type AnalyticsEvent = } | { name: 'lore.memory_closed'; reason: MemoryCloseReason } | { name: 'lore.memory_content_tab'; tab: MemoryContentTab; source: MemoryContentTabSource } + | { + name: 'lore.memory_panel_resized'; + source: MemoryPanelResizeSource; + /** + * The share of the viewport the panel covers AFTER the change, as a whole + * percentage — a measure, and the panel's width is stored as exactly this + * kind of share. Pixels would mostly describe the reader's monitor. + */ + viewportPercent: number; + } | { name: 'lore.memory_edit'; action: MemoryEditAction; @@ -502,6 +514,11 @@ function toAttributes(event: AnalyticsEvent): Record { 'lorekit.lore.memory.content_tab': event.tab, 'lorekit.lore.memory.content_tab_source': event.source, }; + case 'lore.memory_panel_resized': + return { + 'lorekit.lore.memory.panel_resize_source': event.source, + 'lorekit.lore.memory.panel_viewport_percent': event.viewportPercent, + }; case 'lore.memory_edit': { const attrs: Record = { 'lorekit.lore.memory.edit_action': event.action, diff --git a/packages/web/src/lib/hooks/useViewportWidth.ts b/packages/web/src/lib/hooks/useViewportWidth.ts new file mode 100644 index 000000000..a95a84fb6 --- /dev/null +++ b/packages/web/src/lib/hooks/useViewportWidth.ts @@ -0,0 +1,40 @@ +'use client'; + +import { useSyncExternalStore } from 'react'; + +/** + * The viewport's width in CSS pixels (`window.innerWidth`, the box `vw` units + * resolve against), re-rendering on resize. + * + * Same shape as `useMediaQuery` next door: `useSyncExternalStore` over ONE shared + * `resize` listener, so N consumers cost one listener. Unlike a media query this + * changes on every resize step, so subscribe to it from SMALL components only — + * the memory panel's resize handle, not the panel itself (the panel's width is a + * CSS `clamp()` and needs no re-render to follow the window). + * + * The server snapshot is `null`: there is no viewport to measure, and `0` would + * read as a real (and absurd) width. + */ + +const subscribers = new Set<() => void>(); + +function notifyAll(): void { + subscribers.forEach((notify) => notify()); +} + +function subscribe(onStoreChange: () => void): () => void { + if (subscribers.size === 0) window.addEventListener('resize', notifyAll); + subscribers.add(onStoreChange); + return () => { + subscribers.delete(onStoreChange); + if (subscribers.size === 0) window.removeEventListener('resize', notifyAll); + }; +} + +export function useViewportWidth(): number | null { + return useSyncExternalStore( + subscribe, + () => window.innerWidth, + () => null, + ); +} diff --git a/packages/web/src/lib/lesson-panel-width.spec.ts b/packages/web/src/lib/lesson-panel-width.spec.ts new file mode 100644 index 000000000..541e4ebce --- /dev/null +++ b/packages/web/src/lib/lesson-panel-width.spec.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; + +import { + PANEL_DEFAULT_WIDTH_PX, + PANEL_KEYBOARD_LARGE_STEP_PX, + PANEL_KEYBOARD_STEP_PX, + PANEL_MIN_WIDTH_PX, + clampPanelWidth, + keyboardPanelWidth, + panelFractionFromWidth, + panelWidthBounds, + panelWidthCss, + panelWidthPx, + viewportPercent, +} from './lesson-panel-width'; + +/** + * The panel's width is drawn by a CSS `clamp()` and reported/stored by these + * functions, so the two must agree at every screen size the drawer appears at + * (768px and up). The viewports below are the common laptop and monitor widths. + */ +const LAPTOP = 1440; +const DESKTOP = 1920; +const QHD = 2560; +const ULTRAWIDE = 3440; + +describe('panelWidthBounds', () => { + it('keeps an absolute floor on small screens', () => { + expect(panelWidthBounds(768).min).toBe(PANEL_MIN_WIDTH_PX); + expect(panelWidthBounds(LAPTOP).min).toBe(PANEL_MIN_WIDTH_PX); + }); + + it('raises the floor with the screen once 15% of it passes the absolute floor', () => { + expect(panelWidthBounds(QHD).min).toBeCloseTo(384); + expect(panelWidthBounds(ULTRAWIDE).min).toBeCloseTo(516); + }); + + it('caps the panel at 70% of the screen', () => { + expect(panelWidthBounds(768).max).toBeCloseTo(537.6); + expect(panelWidthBounds(LAPTOP).max).toBeCloseTo(1008); + expect(panelWidthBounds(ULTRAWIDE).max).toBeCloseTo(2408); + }); + + it('never lets the ceiling fall below the floor (CSS clamp lets the minimum win)', () => { + const { min, max } = panelWidthBounds(400); + expect(min).toBe(PANEL_MIN_WIDTH_PX); + expect(max).toBe(min); + }); +}); + +describe('panelWidthPx', () => { + it('renders the historical 512px default when nothing is stored', () => { + for (const viewport of [768, LAPTOP, DESKTOP, QHD]) { + expect(panelWidthPx(null, viewport)).toBe(PANEL_DEFAULT_WIDTH_PX); + } + }); + + it('lifts the default to the floor on a screen wide enough for the floor to pass it', () => { + expect(panelWidthPx(null, ULTRAWIDE)).toBeCloseTo(516); + }); + + it('scales a stored fraction with the screen — the point of storing a fraction', () => { + // Widened to 900px on a 2560px monitor… + const fraction = panelFractionFromWidth(900, QHD); + // …is the same share of a laptop screen, and the same 900px back on the monitor. + expect(panelWidthPx(fraction, LAPTOP)).toBeCloseTo(506, 0); + expect(panelWidthPx(fraction, QHD)).toBeCloseTo(900, 0); + }); + + it('holds a stored fraction inside the bounds of the current screen', () => { + // 10% is under the floor everywhere; 95% is over the ceiling everywhere. + expect(panelWidthPx(0.1, LAPTOP)).toBe(PANEL_MIN_WIDTH_PX); + expect(panelWidthPx(0.95, LAPTOP)).toBeCloseTo(1008); + }); +}); + +describe('panelFractionFromWidth', () => { + it('stores the dragged width as a share of the screen, rounded to four places', () => { + expect(panelFractionFromWidth(720, LAPTOP)).toBe(0.5); + expect(panelFractionFromWidth(544, 1200)).toBe(0.4533); + }); + + it('stores the bound, not the overshoot, when a drag goes past either edge', () => { + expect(panelFractionFromWidth(100, LAPTOP)).toBe(Math.round((320 / LAPTOP) * 10_000) / 10_000); + expect(panelFractionFromWidth(5000, LAPTOP)).toBe(0.7); + }); + + it('returns 0 — which never parses back as a width — for an unmeasurable viewport', () => { + expect(panelFractionFromWidth(500, 0)).toBe(0); + }); +}); + +describe('panelWidthCss', () => { + it('draws the default as 512px inside viewport-relative bounds', () => { + expect(panelWidthCss(null)).toBe('clamp(max(320px, 15vw), 512px, 70vw)'); + }); + + it('draws a stored fraction in vw so a resize re-resolves it without JavaScript', () => { + expect(panelWidthCss(0.4533)).toBe('clamp(max(320px, 15vw), 45.33vw, 70vw)'); + }); +}); + +describe('clampPanelWidth', () => { + it('passes a width inside the bounds through unchanged', () => { + expect(clampPanelWidth(600, LAPTOP)).toBe(600); + }); +}); + +describe('keyboardPanelWidth', () => { + it('widens on ArrowLeft and narrows on ArrowRight (the panel sits on the right)', () => { + expect(keyboardPanelWidth('ArrowLeft', { shiftKey: false }, 512, LAPTOP)).toBe( + 512 + PANEL_KEYBOARD_STEP_PX, + ); + expect(keyboardPanelWidth('ArrowRight', { shiftKey: false }, 512, LAPTOP)).toBe( + 512 - PANEL_KEYBOARD_STEP_PX, + ); + }); + + it('takes a larger step with Shift', () => { + expect(keyboardPanelWidth('ArrowLeft', { shiftKey: true }, 512, LAPTOP)).toBe( + 512 + PANEL_KEYBOARD_LARGE_STEP_PX, + ); + }); + + it('stops at the bounds', () => { + expect(keyboardPanelWidth('ArrowRight', { shiftKey: true }, 330, LAPTOP)).toBe(PANEL_MIN_WIDTH_PX); + expect(keyboardPanelWidth('ArrowLeft', { shiftKey: true }, 1000, LAPTOP)).toBeCloseTo(1008); + }); + + it('jumps to the smallest size on Home and the largest on End', () => { + expect(keyboardPanelWidth('Home', { shiftKey: false }, 700, LAPTOP)).toBe(PANEL_MIN_WIDTH_PX); + expect(keyboardPanelWidth('End', { shiftKey: false }, 700, LAPTOP)).toBeCloseTo(1008); + }); + + it('ignores every other key', () => { + for (const key of ['ArrowUp', 'ArrowDown', 'Enter', ' ', 'Escape', 'a']) { + expect(keyboardPanelWidth(key, { shiftKey: false }, 512, LAPTOP)).toBeNull(); + } + }); +}); + +describe('viewportPercent', () => { + it('reports a whole percentage of the screen', () => { + expect(viewportPercent(512, 1200)).toBe(43); + expect(viewportPercent(720, LAPTOP)).toBe(50); + }); + + it('reports 0 rather than dividing by zero', () => { + expect(viewportPercent(512, 0)).toBe(0); + }); +}); diff --git a/packages/web/src/lib/lesson-panel-width.ts b/packages/web/src/lib/lesson-panel-width.ts new file mode 100644 index 000000000..68038e118 --- /dev/null +++ b/packages/web/src/lib/lesson-panel-width.ts @@ -0,0 +1,148 @@ +/** + * The memory detail panel's width — the pure half. + * + * On desktop the panel is a right-side drawer the reader can widen or narrow by + * dragging its left edge. Two requirements shape everything here: + * + * 1. **The bounds follow the screen.** A floor and a ceiling in pixels would be + * wrong at one end of the monitor range or the other: a ceiling that leaves + * room for the list on a 1440px laptop is needlessly tight on a 3440px + * ultrawide. So both bounds are fractions of the viewport, with an absolute + * floor so the panel never gets too narrow to read. + * 2. **The choice scales when the screen changes.** The stored preference is a + * FRACTION of the viewport, not a pixel width. A reader who widens the panel + * to 900px on a 2560px monitor gets about 506px on a 1440px laptop and their + * 900px back when they return — the same share of the screen, rather than a + * pixel width that is huge on the small screen or lost on the big one. + * + * The rendered width is a CSS `clamp()` ({@link panelWidthCss}), so a window + * resize or a move between monitors re-resolves it in the browser with no + * JavaScript and no re-render. Every function here mirrors that same formula, so + * what the resize handle reports (`aria-valuenow`) and stores is exactly what the + * browser draws. + */ + +/** The narrowest the panel ever gets, whatever the screen. */ +export const PANEL_MIN_WIDTH_PX = 320; + +/** The floor also grows with the screen: never under this share of it. */ +export const PANEL_MIN_VIEWPORT_FRACTION = 0.15; + +/** The panel never covers more than this share of the screen. */ +export const PANEL_MAX_VIEWPORT_FRACTION = 0.7; + +/** + * The width before the reader has chosen one. The panel's historical width + * (`max-w-lg`), so nobody who never drags the edge sees a different panel. + */ +export const PANEL_DEFAULT_WIDTH_PX = 512; + +/** One arrow-key press on the resize handle. */ +export const PANEL_KEYBOARD_STEP_PX = 32; + +/** One arrow-key press with Shift held. */ +export const PANEL_KEYBOARD_LARGE_STEP_PX = 128; + +export interface PanelWidthBounds { + min: number; + max: number; +} + +/** + * The allowed width range, in pixels, for a viewport this wide. + * + * `max` never drops below `min`: CSS `clamp()` lets the minimum win when the two + * cross, and this has to agree with what the browser draws. They only cross + * under ~457px, where the panel is a bottom sheet anyway. + */ +export function panelWidthBounds(viewportWidth: number): PanelWidthBounds { + const min = Math.max(PANEL_MIN_WIDTH_PX, viewportWidth * PANEL_MIN_VIEWPORT_FRACTION); + const max = Math.max(min, viewportWidth * PANEL_MAX_VIEWPORT_FRACTION); + return { min, max }; +} + +/** `widthPx` held inside the bounds for this viewport. */ +export function clampPanelWidth(widthPx: number, viewportWidth: number): number { + const { min, max } = panelWidthBounds(viewportWidth); + return Math.min(max, Math.max(min, widthPx)); +} + +/** + * The width the panel renders at for a stored fraction (or `null`: nothing + * stored, so the default), in pixels. + */ +export function panelWidthPx(fraction: number | null, viewportWidth: number): number { + const preferred = fraction === null ? PANEL_DEFAULT_WIDTH_PX : fraction * viewportWidth; + return clampPanelWidth(preferred, viewportWidth); +} + +/** + * The fraction to store for a width the reader dragged to. + * + * Clamped first, so a drag past either bound stores the bound rather than a + * value that only CSS keeps in check. Rounded to four places — 0.01% of a 4K + * screen is well under a pixel, and the stored string stays short. + */ +export function panelFractionFromWidth(widthPx: number, viewportWidth: number): number { + // No measurable viewport: 0 is the one fraction `parseFractionPreference` + // rejects, so even if it were stored it would read back as the default. + if (viewportWidth <= 0) return 0; + const fraction = clampPanelWidth(widthPx, viewportWidth) / viewportWidth; + return Math.round(fraction * 10_000) / 10_000; +} + +/** + * The panel's CSS `width` for a stored fraction (or `null` for the default). + * + * The bounds are expressed in `vw` here as well, so the browser re-resolves them + * on every resize. The resize handle writes this same string to the element + * while dragging, so the drag and the committed render can never disagree. + */ +export function panelWidthCss(fraction: number | null): string { + const preferred = + fraction === null ? `${PANEL_DEFAULT_WIDTH_PX}px` : `${roundTo(fraction * 100, 2)}vw`; + const minVw = roundTo(PANEL_MIN_VIEWPORT_FRACTION * 100, 2); + const maxVw = roundTo(PANEL_MAX_VIEWPORT_FRACTION * 100, 2); + return `clamp(max(${PANEL_MIN_WIDTH_PX}px, ${minVw}vw), ${preferred}, ${maxVw}vw)`; +} + +/** + * The width a key press on the resize handle asks for, or `null` when the key + * does not resize. + * + * Follows the WAI-ARIA window-splitter pattern for a panel on the RIGHT: moving + * the splitter left (`ArrowLeft`) widens the panel, `Home` gives the panel its + * smallest size and `End` its largest. + */ +export function keyboardPanelWidth( + key: string, + options: { shiftKey: boolean }, + currentWidthPx: number, + viewportWidth: number, +): number | null { + const step = options.shiftKey ? PANEL_KEYBOARD_LARGE_STEP_PX : PANEL_KEYBOARD_STEP_PX; + const { min, max } = panelWidthBounds(viewportWidth); + switch (key) { + case 'ArrowLeft': + return clampPanelWidth(currentWidthPx + step, viewportWidth); + case 'ArrowRight': + return clampPanelWidth(currentWidthPx - step, viewportWidth); + case 'Home': + return min; + case 'End': + return max; + default: + return null; + } +} + +/** The share of the viewport a width covers, as a whole percentage. */ +export function viewportPercent(widthPx: number, viewportWidth: number): number { + if (viewportWidth <= 0) return 0; + return Math.round((widthPx / viewportWidth) * 100); +} + +function roundTo(value: number, places: number): number { + const factor = 10 ** places; + return Math.round(value * factor) / factor; +} diff --git a/packages/web/src/lib/persisted-preference.spec.ts b/packages/web/src/lib/persisted-preference.spec.ts index 1513be807..06b889253 100644 --- a/packages/web/src/lib/persisted-preference.spec.ts +++ b/packages/web/src/lib/persisted-preference.spec.ts @@ -6,7 +6,9 @@ import { isResolved, parseBooleanPreference, parseEnumPreference, + parseFractionPreference, serializeBooleanPreference, + serializeFractionPreference, } from './persisted-preference'; /** @@ -92,6 +94,33 @@ describe('parseEnumPreference', () => { }); }); +describe('parseFractionPreference', () => { + it('reads a stored fraction', () => { + expect(parseFractionPreference('0.4533')).toBe(0.4533); + expect(parseFractionPreference(' 0.5 ')).toBe(0.5); + expect(parseFractionPreference('1')).toBe(1); + }); + + it('means "use the default" for absent and unresolved', () => { + // `null`, not a fallback fraction: the memory panel's default is a pixel + // width, and a sentinel fraction would become that share of any screen. + expect(parseFractionPreference('')).toBeNull(); + expect(parseFractionPreference(UNRESOLVED)).toBeNull(); + }); + + it('means "use the default" for anything that is not a fraction in (0, 1]', () => { + for (const raw of ['0', '-0.2', '1.01', '45', 'NaN', 'Infinity', 'wide', '0.5px', '[]']) { + expect(parseFractionPreference(raw)).toBeNull(); + } + }); + + it('round-trips what it serialises', () => { + for (const value of [0.2667, 0.5, 0.7]) { + expect(parseFractionPreference(serializeFractionPreference(value))).toBe(value); + } + }); +}); + describe('isResolved', () => { it('separates "not consulted yet" from "absent"', () => { // The distinction the whole no-flash guarantee rests on. diff --git a/packages/web/src/lib/persisted-preference.ts b/packages/web/src/lib/persisted-preference.ts index 3c941f5a3..28f45ef6c 100644 --- a/packages/web/src/lib/persisted-preference.ts +++ b/packages/web/src/lib/persisted-preference.ts @@ -46,6 +46,12 @@ export const PREFERENCE_KEYS = { * `enabled` on this, so a folded panel issues no clustering request at all. */ explorerClustersOpen: 'lorekit:explorer-clusters-open', + /** + * How wide the reader left the desktop memory detail panel, as a FRACTION of + * the viewport (see `lib/lesson-panel-width.ts` for why not pixels). Absent + * means the default width. + */ + lessonPanelWidth: 'lorekit:lesson-panel-width', } as const; export type PreferenceKey = (typeof PREFERENCE_KEYS)[keyof typeof PREFERENCE_KEYS]; @@ -108,3 +114,26 @@ export function parseEnumPreference( const value = raw.trim(); return (allowed as readonly string[]).includes(value) ? (value as T) : fallback; } + +/** + * A stored fraction in `(0, 1]`, or `null` — "use the default" — for anything + * else: absent, unresolved, not a number, or out of range. + * + * `null` rather than a fallback number because the caller's default is often + * not a fraction at all (the memory panel's is a pixel width), and a sentinel + * fraction would quietly turn into "that share of whatever screen you are on". + * Zero is rejected with the rest: a zero-width panel is never a choice. + */ +export function parseFractionPreference(raw: string | null): number | null { + if (raw === null) return null; + const value = raw.trim(); + if (value === '') return null; + const fraction = Number(value); + if (!Number.isFinite(fraction) || fraction <= 0 || fraction > 1) return null; + return fraction; +} + +/** The canonical on-disk spelling of a fraction preference. */ +export function serializeFractionPreference(value: number): string { + return String(value); +} From b49afa5697219964ba00016c5d684ee7e876007d Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 27 Oct 2026 09:08:28 +0000 Subject: [PATCH 2/4] fix(web): keep the resizable panel inside narrow frames and skip no-op resizes - max-w-full on the drawer so a containing block narrower than the vw bounds (the stories' 360px device frames) still holds it - measure drags from the start width via a pure dragPanelWidth, with a spec and a pointer-drag interaction story - no storage write or event when a gesture ends at the same width, or on a reset that is already the default - ignore Alt/Ctrl/Cmd+arrow and a second pointer mid-drag - rely on the global Storybook preference reset; document Home/End Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- .../lore/LessonDetailSheet.test.stories.tsx | 87 +++++++++++++------ .../src/components/lore/LessonDetailSheet.tsx | 5 +- .../lore/LessonPanelResizeHandle.tsx | 34 +++++--- packages/web/src/content/docs/tags.mdx | 2 +- .../web/src/lib/lesson-panel-width.spec.ts | 22 +++++ packages/web/src/lib/lesson-panel-width.ts | 16 ++++ 6 files changed, 127 insertions(+), 39 deletions(-) diff --git a/packages/web/src/components/lore/LessonDetailSheet.test.stories.tsx b/packages/web/src/components/lore/LessonDetailSheet.test.stories.tsx index 10a7ac6b7..ae6ba9e62 100644 --- a/packages/web/src/components/lore/LessonDetailSheet.test.stories.tsx +++ b/packages/web/src/components/lore/LessonDetailSheet.test.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; -import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; +import { expect, fireEvent, fn, userEvent, waitFor, within } from 'storybook/test'; import { LessonDetailSheet } from './LessonDetailSheet'; import type { ContentTab } from './content-tabs'; @@ -315,18 +315,13 @@ export const DrawerOpenDoesNotStealFocus: Story = { // ── Resizing (desktop drawer) ──────────────────────────────────────────────── -/** - * Clears the stored width so the story starts from the default AND leaves none - * behind: the visual stories share this origin's `localStorage`, and a leftover - * width would move every drawer baseline. - */ -function resetPanelWidth() { - try { - window.localStorage.removeItem(PREFERENCE_KEYS.lessonPanelWidth); - } catch { - // Blocked site data behaves like "nothing stored", which is the same start. - } -} +// Each story starts with no stored width: `.storybook/vitest.setup.ts` clears +// every `PREFERENCE_KEYS` entry before each one, so nothing here leaks into the +// drawer screenshots either. + +const resizeEdge = () => body().getByRole('separator', { name: /resize memory panel/i }); +const storedPanelWidth = () => + parseFractionPreference(window.localStorage.getItem(PREFERENCE_KEYS.lessonPanelWidth)); /** * The drawer's left edge is a focusable window splitter: arrow keys and Home @@ -334,19 +329,14 @@ function resetPanelWidth() { * reload and scales across monitors), and a double-click goes back to the * default. Expected widths are derived from the iframe's own viewport through * the same pure functions the panel renders with, rather than hard-coded for - * one iframe size. The pointer-drag path is the same commit with a different - * source; its geometry is covered by `lesson-panel-width.spec.ts`. + * one iframe size. */ export const DrawerResizesFromItsEdge: Story = { render: (args) => , - beforeEach: () => { - resetPanelWidth(); - return resetPanelWidth; - }, play: async ({ step }) => { const dialog = await body().findByRole('dialog', { name: /memory detail/i }); await waitFor(() => expect(dialog).toBeVisible()); - const separator = body().getByRole('separator', { name: /resize memory panel/i }); + const separator = resizeEdge(); const viewport = window.innerWidth; const defaultWidth = Math.round(panelWidthPx(null, viewport)); @@ -362,9 +352,7 @@ export const DrawerResizesFromItsEdge: Story = { await userEvent.keyboard('{ArrowLeft}'); await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', String(widened))); await expect(Math.abs(dialog.getBoundingClientRect().width - widened)).toBeLessThanOrEqual(1); - await expect( - parseFractionPreference(window.localStorage.getItem(PREFERENCE_KEYS.lessonPanelWidth)), - ).toBe(storedFraction); + await expect(storedPanelWidth()).toBe(storedFraction); }); await step('Home takes it to the smallest width this screen allows', async () => { @@ -379,9 +367,56 @@ export const DrawerResizesFromItsEdge: Story = { await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', String(defaultWidth))); await expect(Math.abs(dialog.getBoundingClientRect().width - defaultWidth)).toBeLessThanOrEqual(1); // `''` is the store's "nothing stored" — it parses back as the default. - await expect( - parseFractionPreference(window.localStorage.getItem(PREFERENCE_KEYS.lessonPanelWidth)), - ).toBeNull(); + await expect(storedPanelWidth()).toBeNull(); + }); + }, +}; + +/** + * The pointer path: a press that wobbles under the drag threshold stores + * nothing (a double-click's two presses must not commit a "drag" first), and a + * real drag widens the panel by exactly the distance moved — measured from + * where the drag started, so grabbing the 24px edge off-centre does not make + * the panel jump — and stores the result once, on release. + * + * `fireEvent` rather than `userEvent.pointer`: the handle needs a press, moves + * at chosen x positions and a release on the SAME element, which is exactly + * what these dispatch. The synthetic pointer is not one the browser can + * capture; the handle tolerates that, and the events target the edge anyway. + */ +export const DrawerResizesByDragging: Story = { + render: (args) => , + play: async ({ step }) => { + const dialog = await body().findByRole('dialog', { name: /memory detail/i }); + await waitFor(() => expect(dialog).toBeVisible()); + const separator = resizeEdge(); + const viewport = window.innerWidth; + const defaultWidth = Math.round(panelWidthPx(null, viewport)); + const pointer = { pointerId: 1, isPrimary: true, button: 0 }; + const startX = Math.round(separator.getBoundingClientRect().left) + 4; + + await step('a press that wobbles under the threshold stores nothing', async () => { + fireEvent.pointerDown(separator, { ...pointer, clientX: startX }); + fireEvent.pointerMove(separator, { ...pointer, clientX: startX - 2 }); + fireEvent.pointerUp(separator, { ...pointer, clientX: startX - 2 }); + await expect(separator).toHaveAttribute('aria-valuenow', String(defaultWidth)); + await expect(window.localStorage.getItem(PREFERENCE_KEYS.lessonPanelWidth)).toBeNull(); + }); + + await step('dragging 100px left widens the panel by 100px and stores it on release', async () => { + const expectedFraction = panelFractionFromWidth(defaultWidth + 100, viewport); + const expectedWidth = Math.round(panelWidthPx(expectedFraction, viewport)); + fireEvent.pointerDown(separator, { ...pointer, clientX: startX }); + fireEvent.pointerMove(separator, { ...pointer, clientX: startX - 50 }); + fireEvent.pointerMove(separator, { ...pointer, clientX: startX - 100 }); + // Mid-drag the DOM already shows the new width, but nothing is stored yet. + await waitFor(() => + expect(Math.abs(dialog.getBoundingClientRect().width - expectedWidth)).toBeLessThanOrEqual(1), + ); + await expect(window.localStorage.getItem(PREFERENCE_KEYS.lessonPanelWidth)).toBeNull(); + fireEvent.pointerUp(separator, { ...pointer, clientX: startX - 100 }); + await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', String(expectedWidth))); + await expect(storedPanelWidth()).toBe(expectedFraction); }); }, }; diff --git a/packages/web/src/components/lore/LessonDetailSheet.tsx b/packages/web/src/components/lore/LessonDetailSheet.tsx index 5f17809b2..9a47750ea 100644 --- a/packages/web/src/components/lore/LessonDetailSheet.tsx +++ b/packages/web/src/components/lore/LessonDetailSheet.tsx @@ -813,11 +813,14 @@ export function LessonDetailSheet({ lesson, onClose, onMutated, layout = 'auto', 'fixed z-50 flex flex-col bg-[var(--color-bg-raised)] shadow-2xl', isSheet ? 'inset-x-0 bottom-0 max-h-[90vh] rounded-t-2xl border-t border-[var(--color-border)] pb-[env(safe-area-inset-bottom)]' - : 'inset-y-0 right-0 border-l border-[var(--color-border)]', + : 'inset-y-0 right-0 max-w-full border-l border-[var(--color-border)]', ].join(' ')} // The drawer's width is inline because its bounds are viewport- // relative and the preferred value is the reader's — see // `panelWidthCss`. The sheet spans the screen and has no width. + // `max-w-full` is inert against the viewport (70vw is the cap) but + // keeps the drawer inside a NARROWER containing block, which `vw` + // cannot see — a transformed frame, like the stories' device frames. style={isSheet ? undefined : { width: panelWidthCss(panelWidthFraction) }} role="dialog" // Only the mobile sheet is modal — see the backdrop above (R1). diff --git a/packages/web/src/components/lore/LessonPanelResizeHandle.tsx b/packages/web/src/components/lore/LessonPanelResizeHandle.tsx index 4a21b89ca..9a351e497 100644 --- a/packages/web/src/components/lore/LessonPanelResizeHandle.tsx +++ b/packages/web/src/components/lore/LessonPanelResizeHandle.tsx @@ -37,6 +37,7 @@ import { import type { MemoryPanelResizeSource } from '@/lib/analytics/lore-events'; import { useViewportWidth } from '@/lib/hooks/useViewportWidth'; import { + dragPanelWidth, keyboardPanelWidth, panelFractionFromWidth, panelWidthBounds, @@ -68,11 +69,8 @@ interface LessonPanelResizeHandleProps { interface DragState { pointerId: number; startX: number; - /** - * Where inside the handle the pointer grabbed it, relative to the panel's - * edge — so the edge does not jump to the cursor on the first move. - */ - grabOffset: number; + /** The panel's rendered width when the drag began — see `dragPanelWidth`. */ + startWidthPx: number; moved: boolean; } @@ -103,10 +101,18 @@ export function LessonPanelResizeHandle({ fraction, onCommit }: LessonPanelResiz function commit(source: MemoryPanelResizeSource) { const next = pendingRef.current; + if (next === null) return; + const viewport = window.innerWidth; + const nextWidth = panelWidthPx(next, viewport); + // Home at the minimum, an arrow at a bound, a drag that ended where it + // began: nothing changed, so nothing is stored and no event says otherwise. + if (Math.round(nextWidth) === Math.round(panelWidthPx(fraction, viewport))) { + revert(); + return; + } pendingRef.current = null; setPreviewFraction(null); - if (next === null) return; - onCommit({ fraction: next, source, viewportPercent: Math.round(next * 100) }); + onCommit({ fraction: next, source, viewportPercent: viewportPercent(nextWidth, viewport) }); } /** Abandon an uncommitted gesture and put the rendered width back. */ @@ -118,7 +124,9 @@ export function LessonPanelResizeHandle({ fraction, onCommit }: LessonPanelResiz } function handlePointerDown(event: ReactPointerEvent) { - if (event.button !== 0) return; + // One drag at a time, from the primary pointer's main button: a second + // finger or pen arriving mid-drag must not take over the first one's state. + if (event.button !== 0 || !event.isPrimary || dragRef.current) return; const element = panel(); if (!element) return; event.preventDefault(); @@ -130,11 +138,10 @@ export function LessonPanelResizeHandle({ fraction, onCommit }: LessonPanelResiz // (and for synthetic test pointers). The drag still works while the // cursor stays over the handle; it must never throw out of a handler. } - const edgeX = window.innerWidth - element.getBoundingClientRect().width; dragRef.current = { pointerId: event.pointerId, startX: event.clientX, - grabOffset: event.clientX - edgeX, + startWidthPx: element.getBoundingClientRect().width, moved: false, }; setDragging(true); @@ -149,7 +156,7 @@ export function LessonPanelResizeHandle({ fraction, onCommit }: LessonPanelResiz drag.moved = true; const viewport = window.innerWidth; if (viewport <= 0) return; - const widthPx = viewport - (event.clientX - drag.grabOffset); + const widthPx = dragPanelWidth(drag.startWidthPx, drag.startX, event.clientX); preview(panelFractionFromWidth(widthPx, viewport)); } @@ -168,6 +175,8 @@ export function LessonPanelResizeHandle({ fraction, onCommit }: LessonPanelResiz function handleDoubleClick() { pendingRef.current = null; setPreviewFraction(null); + // Already the default: a reset would store nothing new. + if (fraction === null) return; const viewport = window.innerWidth; onCommit({ fraction: null, @@ -177,6 +186,9 @@ export function LessonPanelResizeHandle({ fraction, onCommit }: LessonPanelResiz } function handleKeyDown(event: ReactKeyboardEvent) { + // Alt+← is browser Back and Cmd/Ctrl+← are text/OS shortcuts; leave them be. + // Shift alone is ours (the larger step). + if (event.altKey || event.ctrlKey || event.metaKey) return; const viewport = window.innerWidth; if (viewport <= 0) return; const current = panelWidthPx(pendingRef.current ?? fraction, viewport); diff --git a/packages/web/src/content/docs/tags.mdx b/packages/web/src/content/docs/tags.mdx index 271922584..6f0add5f2 100644 --- a/packages/web/src/content/docs/tags.mdx +++ b/packages/web/src/content/docs/tags.mdx @@ -101,7 +101,7 @@ Tags are the one dimension that can hold **two pills at once** — one that incl You can also filter straight from a memory's detail panel. Hover any tag, kind, host, agent, trigger, repository, branch or pull request and two icons appear: **filter in** (funnel +) shows only memories with that value, **filter out** (funnel −) hides them. Hover an icon to see exactly what it will do. The filter is added to whatever is already applied — filtering out a tag while another tag is filtered in gives you both conditions — and a value you have already filtered in or out shows that icon pressed, so clicking it again takes the filter back off. From the keyboard, focus the value and press `Enter` to filter in or `Shift+Enter` to filter out. The scope offers filter in only. -On a desktop screen the detail panel can be made wider or narrower: drag its left edge, or focus the edge and use `←` / `→` (hold `Shift` for bigger steps). Double-click the edge to go back to the default width. Your width is remembered in this browser as a share of the screen, so it scales when you move between a large monitor and a laptop. +On a desktop screen the detail panel can be made wider or narrower: drag its left edge, or focus the edge and use `←` / `→` (hold `Shift` for bigger steps; `Home` and `End` jump to the narrowest and widest). Double-click the edge to go back to the default width. Your width is remembered in this browser as a share of the screen, so it scales when you move between a large monitor and a laptop. Two shortcuts worth knowing: diff --git a/packages/web/src/lib/lesson-panel-width.spec.ts b/packages/web/src/lib/lesson-panel-width.spec.ts index 541e4ebce..02b32af52 100644 --- a/packages/web/src/lib/lesson-panel-width.spec.ts +++ b/packages/web/src/lib/lesson-panel-width.spec.ts @@ -6,6 +6,7 @@ import { PANEL_KEYBOARD_STEP_PX, PANEL_MIN_WIDTH_PX, clampPanelWidth, + dragPanelWidth, keyboardPanelWidth, panelFractionFromWidth, panelWidthBounds, @@ -106,6 +107,27 @@ describe('clampPanelWidth', () => { }); }); +describe('dragPanelWidth', () => { + it('widens by however far the pointer moved left since the drag began', () => { + expect(dragPanelWidth(512, 900, 800)).toBe(612); + }); + + it('narrows when the pointer moves right', () => { + expect(dragPanelWidth(512, 900, 950)).toBe(462); + }); + + it('does not depend on where in the edge the drag was grabbed', () => { + // Grabbing the 24px edge at its left or right side moves the panel the same + // amount for the same movement — no jump to the cursor on the first move. + expect(dragPanelWidth(512, 1000, 960)).toBe(dragPanelWidth(512, 1020, 980)); + }); + + it('leaves clamping to the caller', () => { + expect(dragPanelWidth(512, 900, 0)).toBe(1412); + expect(clampPanelWidth(dragPanelWidth(512, 900, 0), LAPTOP)).toBeCloseTo(1008); + }); +}); + describe('keyboardPanelWidth', () => { it('widens on ArrowLeft and narrows on ArrowRight (the panel sits on the right)', () => { expect(keyboardPanelWidth('ArrowLeft', { shiftKey: false }, 512, LAPTOP)).toBe( diff --git a/packages/web/src/lib/lesson-panel-width.ts b/packages/web/src/lib/lesson-panel-width.ts index 68038e118..2009a3f45 100644 --- a/packages/web/src/lib/lesson-panel-width.ts +++ b/packages/web/src/lib/lesson-panel-width.ts @@ -106,6 +106,22 @@ export function panelWidthCss(fraction: number | null): string { return `clamp(max(${PANEL_MIN_WIDTH_PX}px, ${minVw}vw), ${preferred}, ${maxVw}vw)`; } +/** + * The width a drag asks for: the width when the drag started, plus however far + * the pointer has moved LEFT since (the panel is anchored to the right edge, so + * moving left widens it). + * + * Relative to the start rather than computed from the pointer's position in the + * viewport, so the edge does not jump to the cursor on the first move (the + * handle is 24px wide and can be grabbed anywhere in it), and so it holds under + * a transformed ancestor or a classic scrollbar, where "viewport width minus + * pointer x" is not the panel's width. Unclamped — the caller clamps when it + * turns the width into a stored fraction. + */ +export function dragPanelWidth(startWidthPx: number, startX: number, clientX: number): number { + return startWidthPx + (startX - clientX); +} + /** * The width a key press on the resize handle asks for, or `null` when the key * does not resize. From 949cd58fc88fe1ee82faa91e14545c58b814bf9f Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 27 Oct 2026 10:23:14 +0000 Subject: [PATCH 3/4] style(web): fade the panel resize line in as an accent gradient The hover/focus/drag indicator was a hard full-height accent bar. It is now a transparent -> accent -> transparent vertical gradient (the TopBar ActivityIndicator's stops) that fades in via opacity. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- .../src/components/lore/LessonPanelResizeHandle.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/lore/LessonPanelResizeHandle.tsx b/packages/web/src/components/lore/LessonPanelResizeHandle.tsx index 9a351e497..eb2031446 100644 --- a/packages/web/src/components/lore/LessonPanelResizeHandle.tsx +++ b/packages/web/src/components/lore/LessonPanelResizeHandle.tsx @@ -241,9 +241,17 @@ export function LessonPanelResizeHandle({ fraction, onCommit }: LessonPanelResiz // dragged — the cursor is the affordance, as in most editors. className="group/resize absolute inset-y-0 -left-3 z-10 w-6 cursor-col-resize touch-none select-none focus-visible:outline-none" > + {/* + The indicator: an accent line that fades out toward the top and bottom + (the same transparent → accent → transparent stops as the TopBar's + ActivityIndicator) and fades IN on hover, focus or drag. A hard, + full-height bar reads as a second border on top of the panel's own; + the gradient reads as "this edge is live". Opacity, not colour, is + what transitions — a gradient's stops cannot be animated. + */} ); From a16b90a91d70e43dcf0c9fd9718ac6265cf90123 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 27 Oct 2026 10:56:33 +0000 Subject: [PATCH 4/4] docs(web): say where the panel's default width departs from 512px Addresses review comment by dash0-dev[bot]: https://github.com/mthines/lorekit/pull/728#discussion_r4387783196 Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- docs/key-files.md | 2 +- packages/web/src/components/lore/LessonDetailSheet.tsx | 3 ++- packages/web/src/lib/lesson-panel-width.ts | 7 +++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/key-files.md b/docs/key-files.md index 50d0dd5e0..d6599a33e 100644 --- a/docs/key-files.md +++ b/docs/key-files.md @@ -168,7 +168,7 @@ else. Grouped loosely by subsystem in migration/PR order. | `packages/web/src/components/ui/SegmentedControl.tsx` | The ONE segmented button group. Extracted from `RangePicker`, which owned the look privately, the moment a second surface needed it (the Explorer's Activity view toggle) — two copies of a control that must look identical is how it stops looking identical. `role="radiogroup"`/`role="radio"`, deliberately NOT `tablist`/`tab`: a tab OWNS the panel it reveals and promises arrow-key navigation plus a `tabpanel` relationship, while this is a filter on what a surface shows and sometimes has no panel at all (the range picker changes four numbers in place). The buttons stay individually tabbable, matching what `RangePicker` already did and what its tests pin. A `trailing` slot carries an out-of-set arm inside the same rail (`RangePicker`'s inert custom-window chip, which must look like a CHECKED segment or the group reads as unset — hence the exported `SEGMENTED_TRAILING_CLASS`), and `labels="wide"` hides the visible labels below the `@md` CONTAINER width so a dense header collapses to icons rather than wrapping. **Not for every one-of-N choice** — when width is scarce or an option needs a sentence of explanation, `Combobox` is right instead; `StatusControl` is the worked example of choosing that deliberately over this. Covered by `SegmentedControl.stories.tsx` | | `packages/web/src/components/ui/AnchoredTooltip.tsx` | A portaled readout positioned against an arbitrary `anchor: Element`. Exists because a chart's per-bucket readout was an `absolute` panel inside the bar, and in the Explorer that bar sits in `CollapsibleStatCard`'s `overflow: hidden` reveal region — so it was CLIPPED at the card's edge exactly when the chart was worth reading. `z-index` cannot fix that; `overflow` clips descendants regardless of stacking, so the only fix is to stop being a descendant. It reuses the pure `computeTooltipPosition` exported by `Tooltip.tsx` verbatim rather than growing a second positioner. What it cannot reuse is `Tooltip`'s SHAPE: `Tooltip` wraps its own trigger and owns its own hover state, so it is one component, one portal and one listener set PER TRIGGER — 364 of each for a 52-week heatmap. This is the content-only half: the caller says which element is anchoring and when, and ONE instance serves every bucket. Holds the element, not a rect, because a `fixed` panel is in viewport coordinates and re-measures on capture-phase scroll/resize while open. Consumers: `dashboard/Sparkbar.tsx`, `activity/ContributionHeatmap.tsx` | | `packages/web/src/lib/persisted-preference.ts` (+ `lib/hooks/usePersistedPreference.ts`) | Per-viewer UI preferences — the pure codec plus the `localStorage` shell. Three call sites had each rolled their own read+parse and re-derived the same two questions: what does an ABSENT value mean, and what does a CORRUPT one mean (both: the default). `PREFERENCE_KEYS` is the single registry, so a writer and a reader can never disagree by one character. The hook is `useSyncExternalStore` over a module-level registry, mirroring `useMediaQuery` rather than `useDismissedInviteIds`: a mount-effect always paints the DEFAULT first and corrects on the next tick, which for a disclosure state is the exact "expanded, then snap shut" flash the feature exists to remove. **`getServerSnapshot` returns `null` and is its only producer** — "no client store consulted" is a DIFFERENT fact from `''` "consulted, nothing stored", and collapsing the two brings the flash back; consumers render their NEUTRAL state while unresolved. Subscribes to `storage`, so two tabs agree for free. Every access is inside `try/catch` (`localStorage` throws on the GETTER under blocked site data) and failure degrades to "does not persist", never to a thrown render. Covered by `persisted-preference.spec.ts` | -| `packages/web/src/lib/lesson-panel-width.ts` (+ `components/lore/LessonPanelResizeHandle.tsx`) | The desktop memory detail panel's resizable width. The preference (`PREFERENCE_KEYS.lessonPanelWidth`) is a FRACTION of the viewport, not pixels, so a width chosen on a big monitor becomes the same share of a laptop screen; both bounds are viewport-relative too (≥ max(320px, 15vw), ≤ 70vw) and the default stays the historical 512px. The panel renders `panelWidthCss()`, a CSS `clamp()`, so a resize or a monitor change re-resolves in the browser with no re-render; every other function here mirrors that formula so `aria-valuenow` and the stored value match what is drawn. The handle writes `style.width` directly during a drag (the panel is too heavy to re-render per `pointermove`) and commits once per gesture. Covered by `lesson-panel-width.spec.ts` and the `DrawerResizesFromItsEdge` story | +| `packages/web/src/lib/lesson-panel-width.ts` (+ `components/lore/LessonPanelResizeHandle.tsx`) | The desktop memory detail panel's resizable width. The preference (`PREFERENCE_KEYS.lessonPanelWidth`) is a FRACTION of the viewport, not pixels, so a width chosen on a big monitor becomes the same share of a laptop screen; both bounds are viewport-relative too (≥ max(320px, 15vw), ≤ 70vw) and the default is the historical 512px except where the 15vw floor is wider (screens over ~3413px). The panel renders `panelWidthCss()`, a CSS `clamp()`, so a resize or a monitor change re-resolves in the browser with no re-render; every other function here mirrors that formula so `aria-valuenow` and the stored value match what is drawn. The handle writes `style.width` directly during a drag (the panel is too heavy to re-render per `pointermove`) and commits once per gesture. Covered by `lesson-panel-width.spec.ts` and the `DrawerResizesFromItsEdge` story | | `packages/web/src/lib/explorer-insights-view.ts` | The Activity panel's two bodies as a closed union with its labels, accessible names, icons and defaults in ONE module — the `status-filter.ts` pattern. `charts` is the default because it answers a question about the CURRENT selection, which is what the scope chips, filter bar and range picker beside it have just been used to express; opening on the heatmap (deliberately account-wide and unfiltered) would make the panel look unresponsive to every control around it. `DEFAULT_INSIGHTS_OPEN = true` — it opened COLLAPSED while it showed both bodies at once; showing one at a time roughly halved the expanded panel, which is what made the evidence affordable by default. Covered by `explorer-insights-view.spec.ts` | | `packages/web/src/lib/format-number.ts` | The dashboard's number renderings, all pinned to `en-US` so a server render and its hydration cannot format one figure two ways: `formatExact` (grouped, and what `AnimatedNumber` always announces), `formatHeadline` (a `StatCard` headline: exact below 10,000, three significant digits above — `1.57M` — so it never exceeds five characters and crowds the chip on its row; `isHeadlineAbbreviated` gates the exact-value hover title), `formatCompact` (a figure in a narrow slot, ≥10,000, ungrouped below), and `formatPercentDelta` (a `TrendChip`'s signed change, ≥1,000). One private `compactAbove` parameterised by threshold AND `Intl` instance, so a `K` means the same thing everywhere — the property that breaks the moment a second call site rolls its own abbreviation. The percentage threshold is an order of magnitude lower because the slot is: a chip shares a line with the figure it annotates and carries a sign and a `%` the figure does not, so `+8834%` was seven characters in a gap sized for five and collided with a `22,425` beside it. 1000 and not lower, because `+100%` means "doubled" and must survive verbatim. The percentage uses `maximumSignificantDigits: 2`, not `maximumFractionDigits: 1`, which is unbounded in the hundreds band (`883.4K`, longer than what it is shortening). `isPercentDeltaAbbreviated` lets `TrendChip` expose the exact figure — via the `AnimatedNumber` two-node pattern plus the title it already had — ONLY where precision was actually dropped. Covered by `format-number.spec.ts` | | `supabase/migrations/00103_reads_must_not_restamp_updated_at.sql` | The fix for the counter that was lying: `lorekit_record_memory_reads` (00099) bumped `read_count`/`last_read_at` with a plain UPDATE, which fires `lorekit_memories_set_updated_at`, so ONE 55-row `memory.list` page restamped 55 memories' `updated_at` with no write of any kind. `updated_at` is the DEFAULT SORT for the Explorer and `memory.list order=recency`, so the order agents received lore in was driven by what had recently been READ. Fixed inside the read-recording function via a session-local guard the trigger honours — not by dropping the trigger (writes need it) and not by making each caller restore the old value. Pinned by `migrations.test.sql` §101 | diff --git a/packages/web/src/components/lore/LessonDetailSheet.tsx b/packages/web/src/components/lore/LessonDetailSheet.tsx index 9a47750ea..a3fe68c93 100644 --- a/packages/web/src/components/lore/LessonDetailSheet.tsx +++ b/packages/web/src/components/lore/LessonDetailSheet.tsx @@ -473,7 +473,8 @@ export function LessonDetailSheet({ lesson, onClose, onMutated, layout = 'auto', // the viewport, rendered through a CSS `clamp()` whose bounds also follow the // viewport (`lib/lesson-panel-width.ts`). Unresolved or absent both mean the // default width — unlike a disclosure, there is no "neutral" width to hold - // while the store is unread, and the default is what the panel always was. + // while the store is unread, and the default is the panel's historical 512px + // (lifted by the 15vw floor only on screens wider than ~3413px). const panelWidthPref = usePersistedPreference(PREFERENCE_KEYS.lessonPanelWidth); const panelWidthFraction = parseFractionPreference(panelWidthPref.raw); const { write: writePanelWidth } = panelWidthPref; diff --git a/packages/web/src/lib/lesson-panel-width.ts b/packages/web/src/lib/lesson-panel-width.ts index 2009a3f45..356d6c154 100644 --- a/packages/web/src/lib/lesson-panel-width.ts +++ b/packages/web/src/lib/lesson-panel-width.ts @@ -32,8 +32,11 @@ export const PANEL_MIN_VIEWPORT_FRACTION = 0.15; export const PANEL_MAX_VIEWPORT_FRACTION = 0.7; /** - * The width before the reader has chosen one. The panel's historical width - * (`max-w-lg`), so nobody who never drags the edge sees a different panel. + * The width before the reader has chosen one. It is the panel's historical + * width (`max-w-lg`), so a reader who never drags the edge sees the panel they + * always did — except on screens wider than ~3413px, where the 15vw floor + * (`PANEL_MIN_VIEWPORT_FRACTION`) is wider than 512px and lifts it (516px at + * 3440px). */ export const PANEL_DEFAULT_WIDTH_PX = 512;