) {
+ 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"
+ >
+ {/*
+ 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.
+ */}
+
+
+ );
+}
diff --git a/packages/web/src/content/docs/tags.mdx b/packages/web/src/content/docs/tags.mdx
index 5ddac95d2..6f0add5f2 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; `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:
- **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..02b32af52
--- /dev/null
+++ b/packages/web/src/lib/lesson-panel-width.spec.ts
@@ -0,0 +1,173 @@
+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,
+ dragPanelWidth,
+ 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('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(
+ 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..356d6c154
--- /dev/null
+++ b/packages/web/src/lib/lesson-panel-width.ts
@@ -0,0 +1,167 @@
+/**
+ * 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. 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;
+
+/** 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 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.
+ *
+ * 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);
+}