Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/key-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 |
Expand Down
1 change: 1 addition & 0 deletions docs/otel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
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';
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
Expand Down Expand Up @@ -306,6 +313,124 @@ export const DrawerOpenDoesNotStealFocus: Story = {
},
};

// ── Resizing (desktop drawer) ────────────────────────────────────────────────

// 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
* 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.
*/
export const DrawerResizesFromItsEdge: Story = {
render: (args) => <Harness onClose={args.onClose} layout="drawer" />,
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));

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(storedPanelWidth()).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(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) => <Harness onClose={args.onClose} layout="drawer" />,
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);
});
},
};

/** 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 = {
Expand Down
Loading
Loading