0.29.1原版

This commit is contained in:
anian
2026-07-02 19:14:14 +08:00
commit d94008f0fb
947 changed files with 174905 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,738 @@
# Calendar-Date Memo Prefill — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** When the user picks a date in the activity calendar, the home memo editor pre-fills its `createTime`/`updateTime` to that date and exposes the existing `TimestampPopover` so the user can adjust before saving. Empty calendar dates also become clickable.
**Architecture:** Frontend-only. A new pure helper derives a `Date` from the `displayTime` filter; `PagedMemoList` reads `MemoFilterContext` and passes the derived `Date` into `MemoEditor` via a new `defaultCreateTime` prop; `MemoEditor` seeds its reducer state from the prop, renders the popover when the prop is set in create mode, and re-syncs on prop change. `CalendarCell` drops the `count > 0` gate on click handling.
**Tech Stack:** React 18 + TypeScript, Vite 7, Vitest + jsdom + @testing-library/react, Tailwind v4, react-router. State via React Context + `useReducer`.
**Spec:** `docs/superpowers/specs/2026-05-02-calendar-date-prefill-design.md`
---
## File map
**Created**
- `web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts` — pure helper `(filters, now?) => Date | undefined`.
- `web/tests/derive-default-create-time.test.ts` — Vitest unit tests for the helper.
- `web/tests/calendar-cell-empty-clickable.test.tsx` — RTL test that count=0 in-month cells are clickable.
**Modified**
- `web/src/components/ActivityCalendar/CalendarCell.tsx` — drop `day.count > 0` gate from click/interactivity/tooltip.
- `web/src/components/MemoEditor/types/components.ts` — add `defaultCreateTime?: Date` to `MemoEditorProps`.
- `web/src/components/MemoEditor/hooks/useMemoInit.ts` — accept optional `defaultCreateTime`; in create mode, dispatch `SET_TIMESTAMPS` to seed `{ createTime, updateTime }`.
- `web/src/components/MemoEditor/index.tsx` — accept the new prop, pass it to `useMemoInit`, add a `useEffect` that re-syncs timestamps when the prop changes in create mode, render `TimestampPopover` when `(!memo && state.timestamps.createTime)`.
- `web/src/components/PagedMemoList/PagedMemoList.tsx` — read `useMemoFilterContext`, derive `defaultCreateTime`, pass to `<MemoEditor>` at line ~155.
---
## Task 1: Pure helper `deriveDefaultCreateTimeFromFilters`
**Files:**
- Create: `web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts`
- Test: `web/tests/derive-default-create-time.test.ts`
The helper takes the `filters` array from `MemoFilterContext` plus an injectable `now`, finds any `displayTime` filter (value format `YYYY-MM-DD`, local-date — produced by `useDateFilterNavigation` which forwards the `dayjs().format("YYYY-MM-DD")` string from `CalendarDayCell.date`), and returns a `Date` of `selected_date + now's hh:mm:ss`. Returns `undefined` if no `displayTime` filter or the value is malformed.
- [ ] **Step 1: Write the failing tests**
Create `web/tests/derive-default-create-time.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
import type { MemoFilter } from "@/contexts/MemoFilterContext";
describe("deriveDefaultCreateTimeFromFilters", () => {
const now = new Date(2026, 4, 2, 14, 32, 10); // 2026-05-02 14:32:10 local
it("returns undefined when no filters are set", () => {
expect(deriveDefaultCreateTimeFromFilters([], now)).toBeUndefined();
});
it("returns undefined when no displayTime filter is present", () => {
const filters: MemoFilter[] = [
{ factor: "tagSearch", value: "work" },
{ factor: "pinned", value: "true" },
];
expect(deriveDefaultCreateTimeFromFilters(filters, now)).toBeUndefined();
});
it("merges the displayTime date with the current local hh:mm:ss", () => {
const filters: MemoFilter[] = [{ factor: "displayTime", value: "2025-05-01" }];
const result = deriveDefaultCreateTimeFromFilters(filters, now);
expect(result).toBeDefined();
expect(result!.getFullYear()).toBe(2025);
expect(result!.getMonth()).toBe(4); // May (0-indexed)
expect(result!.getDate()).toBe(1);
expect(result!.getHours()).toBe(14);
expect(result!.getMinutes()).toBe(32);
expect(result!.getSeconds()).toBe(10);
});
it("ignores extra non-displayTime filters", () => {
const filters: MemoFilter[] = [
{ factor: "tagSearch", value: "work" },
{ factor: "displayTime", value: "2025-05-01" },
{ factor: "pinned", value: "true" },
];
const result = deriveDefaultCreateTimeFromFilters(filters, now);
expect(result?.getDate()).toBe(1);
});
it("returns undefined for a malformed YYYY-MM-DD value", () => {
const cases: MemoFilter[][] = [
[{ factor: "displayTime", value: "not-a-date" }],
[{ factor: "displayTime", value: "2025-13-40" }],
[{ factor: "displayTime", value: "" }],
[{ factor: "displayTime", value: "2025-5-1" }], // single-digit month/day
];
for (const filters of cases) {
expect(deriveDefaultCreateTimeFromFilters(filters, now)).toBeUndefined();
}
});
it("uses real `new Date()` when `now` is omitted", () => {
const filters: MemoFilter[] = [{ factor: "displayTime", value: "2025-05-01" }];
const before = new Date();
const result = deriveDefaultCreateTimeFromFilters(filters);
const after = new Date();
expect(result).toBeDefined();
// Time-of-day should fall between before and after (within 1s tolerance).
const resultTimeOnly = result!.getHours() * 3600 + result!.getMinutes() * 60 + result!.getSeconds();
const beforeTimeOnly = before.getHours() * 3600 + before.getMinutes() * 60 + before.getSeconds();
const afterTimeOnly = after.getHours() * 3600 + after.getMinutes() * 60 + after.getSeconds();
// Handle midnight rollover by allowing any value if before > after.
if (beforeTimeOnly <= afterTimeOnly) {
expect(resultTimeOnly).toBeGreaterThanOrEqual(beforeTimeOnly);
expect(resultTimeOnly).toBeLessThanOrEqual(afterTimeOnly);
}
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && pnpm test derive-default-create-time`
Expected: FAIL — module `@/components/MemoEditor/utils/deriveDefaultCreateTime` does not exist.
- [ ] **Step 3: Implement the helper**
Create `web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts`:
```ts
import type { MemoFilter } from "@/contexts/MemoFilterContext";
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
/**
* Derive a default `createTime` for a new memo from the active memo filters.
* If a `displayTime:YYYY-MM-DD` filter is present, returns that local date
* combined with `now`'s wall-clock hh:mm:ss. Returns undefined otherwise or
* when the filter value is malformed.
*/
export function deriveDefaultCreateTimeFromFilters(
filters: MemoFilter[],
now: Date = new Date(),
): Date | undefined {
const dateFilter = filters.find((f) => f.factor === "displayTime");
if (!dateFilter) return undefined;
const match = DATE_RE.exec(dateFilter.value);
if (!match) return undefined;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
// Construct a local-time Date and verify the components round-trip
// (catches things like 2025-13-40 that JS would silently roll forward).
const candidate = new Date(year, month - 1, day, now.getHours(), now.getMinutes(), now.getSeconds());
if (
candidate.getFullYear() !== year ||
candidate.getMonth() !== month - 1 ||
candidate.getDate() !== day
) {
return undefined;
}
return candidate;
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd web && pnpm test derive-default-create-time`
Expected: PASS — all 6 cases.
- [ ] **Step 5: Run lint**
Run: `cd web && pnpm lint`
Expected: PASS (TypeScript + Biome happy).
- [ ] **Step 6: Commit**
```bash
git add web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts web/tests/derive-default-create-time.test.ts
git commit -m "feat(memo-editor): add deriveDefaultCreateTimeFromFilters helper"
```
---
## Task 2: Make empty calendar cells clickable
**Files:**
- Modify: `web/src/components/ActivityCalendar/CalendarCell.tsx`
- Test: `web/tests/calendar-cell-empty-clickable.test.tsx`
`CalendarCell` currently gates `handleClick`, `isInteractive`, and `shouldShowTooltip` on `day.count > 0`. Drop those gates so any in-month cell is clickable when `onClick` is provided. Out-of-month cells (early-returned at line ~38) stay unclickable.
- [ ] **Step 1: Write the failing test**
Create `web/tests/calendar-cell-empty-clickable.test.tsx`:
```tsx
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { CalendarCell } from "@/components/ActivityCalendar/CalendarCell";
import type { CalendarDayCell } from "@/components/ActivityCalendar/types";
const makeDay = (overrides: Partial<CalendarDayCell> = {}): CalendarDayCell => ({
date: "2025-05-01",
label: "1",
count: 0,
isCurrentMonth: true,
isToday: false,
isSelected: false,
...overrides,
});
describe("CalendarCell empty-day clickability", () => {
it("fires onClick for an in-month day with count=0", () => {
const onClick = vi.fn();
render(<CalendarCell day={makeDay()} maxCount={5} tooltipText="May 1, 2025" onClick={onClick} />);
const button = screen.getByRole("button", { name: /May 1, 2025/ });
fireEvent.click(button);
expect(onClick).toHaveBeenCalledWith("2025-05-01");
});
it("renders an empty in-month day as interactive (tabIndex 0, not aria-disabled)", () => {
render(<CalendarCell day={makeDay()} maxCount={5} tooltipText="May 1, 2025" onClick={() => {}} />);
const button = screen.getByRole("button", { name: /May 1, 2025/ });
expect(button).toHaveAttribute("tabindex", "0");
expect(button).toHaveAttribute("aria-disabled", "false");
});
it("still renders a populated in-month day as interactive", () => {
const onClick = vi.fn();
render(<CalendarCell day={makeDay({ count: 3 })} maxCount={5} tooltipText="May 1, 2025" onClick={onClick} />);
fireEvent.click(screen.getByRole("button", { name: /May 1, 2025/ }));
expect(onClick).toHaveBeenCalledWith("2025-05-01");
});
it("does not render out-of-month days as interactive (no role=button)", () => {
render(
<CalendarCell
day={makeDay({ isCurrentMonth: false })}
maxCount={5}
tooltipText="May 1, 2025"
onClick={() => {}}
/>,
);
expect(screen.queryByRole("button")).toBeNull();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd web && pnpm test calendar-cell-empty-clickable`
Expected: FAIL — first two tests fail because the empty cell currently has `tabindex="-1"`, `aria-disabled="true"`, and `onClick` is not invoked.
- [ ] **Step 3: Edit `CalendarCell.tsx` to drop the count gate**
Modify `web/src/components/ActivityCalendar/CalendarCell.tsx`. Three edits:
(a) Replace `handleClick`:
```tsx
const handleClick = () => {
if (onClick) {
onClick(day.date);
}
};
```
(b) Replace `isInteractive`:
```tsx
const isInteractive = Boolean(onClick);
```
(c) Replace `shouldShowTooltip`:
```tsx
const shouldShowTooltip = tooltipText && !disableTooltip;
```
Leave the out-of-month early return (`if (!day.isCurrentMonth) { ... }`) untouched — out-of-month cells remain non-buttons.
- [ ] **Step 4: Run test to verify it passes**
Run: `cd web && pnpm test calendar-cell-empty-clickable`
Expected: PASS — all 4 cases.
- [ ] **Step 5: Run the full test suite to catch regressions**
Run: `cd web && pnpm test`
Expected: PASS. The existing `activity-calendar-tooltip.test.ts` covers `getTooltipText` (a separate utility) and shouldn't be affected.
- [ ] **Step 6: Run lint**
Run: `cd web && pnpm lint`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add web/src/components/ActivityCalendar/CalendarCell.tsx web/tests/calendar-cell-empty-clickable.test.tsx
git commit -m "feat(activity-calendar): allow clicking empty in-month dates"
```
---
## Task 3: Add `defaultCreateTime` prop to `MemoEditorProps`
**Files:**
- Modify: `web/src/components/MemoEditor/types/components.ts`
Type-only change. No tests at this step — TypeScript compiler is the gate. Subsequent tasks consume the prop.
- [ ] **Step 1: Add the prop**
Modify `web/src/components/MemoEditor/types/components.ts`. Replace the `MemoEditorProps` interface (lines 616) with:
```ts
export interface MemoEditorProps {
className?: string;
cacheKey?: string;
placeholder?: string;
/** Existing memo to edit. When provided, the editor initializes from it without fetching. */
memo?: Memo;
parentMemoName?: string;
autoFocus?: boolean;
/**
* Default `createTime` for a *new* memo (create mode only). When set, the
* editor seeds both `createTime` and `updateTime` to this value and renders
* the timestamp popover so the user can adjust before saving. Tracked live:
* if the prop changes after mount, the editor's timestamps re-sync. Ignored
* in edit mode (when `memo` is set).
*/
defaultCreateTime?: Date;
onConfirm?: (memoName: string) => void;
onCancel?: () => void;
}
```
- [ ] **Step 2: Verify compilation**
Run: `cd web && pnpm lint`
Expected: PASS (no consumer changes yet, prop is optional).
- [ ] **Step 3: Commit**
```bash
git add web/src/components/MemoEditor/types/components.ts
git commit -m "feat(memo-editor): add defaultCreateTime prop type"
```
---
## Task 4: Seed editor timestamps in `useMemoInit` (create mode)
**Files:**
- Modify: `web/src/components/MemoEditor/hooks/useMemoInit.ts`
`useMemoInit` currently handles edit mode (`if (memo)`) by calling `memoService.fromMemo(memo)` and `actions.initMemo(...)`. In create mode it only restores cached content and sets default visibility — it never touches timestamps. Extend the create branch so that when `defaultCreateTime` is set, it dispatches `SET_TIMESTAMPS` with `{ createTime: defaultCreateTime, updateTime: defaultCreateTime }`. This handles the *initial mount* case.
- [ ] **Step 1: Update `UseMemoInitOptions` and `useMemoInit`**
Modify `web/src/components/MemoEditor/hooks/useMemoInit.ts`. Replace the entire file with:
```ts
import { useEffect, useRef, useState } from "react";
import type { Memo, Visibility } from "@/types/proto/api/v1/memo_service_pb";
import type { EditorRefActions } from "../Editor";
import { cacheService, memoService } from "../services";
import { useEditorContext } from "../state";
interface UseMemoInitOptions {
editorRef: React.RefObject<EditorRefActions | null>;
memo?: Memo;
cacheKey?: string;
username: string;
autoFocus?: boolean;
defaultVisibility?: Visibility;
defaultCreateTime?: Date;
}
export const useMemoInit = ({
editorRef,
memo,
cacheKey,
username,
autoFocus,
defaultVisibility,
defaultCreateTime,
}: UseMemoInitOptions) => {
const { actions, dispatch } = useEditorContext();
const initializedRef = useRef(false);
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
if (initializedRef.current) return;
initializedRef.current = true;
const key = cacheService.key(username, cacheKey);
if (memo) {
const initialState = memoService.fromMemo(memo);
cacheService.clear(key);
dispatch(actions.initMemo(initialState));
} else {
const cachedContent = cacheService.load(key);
if (cachedContent) {
dispatch(actions.updateContent(cachedContent));
}
if (defaultVisibility !== undefined) {
dispatch(actions.setMetadata({ visibility: defaultVisibility }));
}
if (defaultCreateTime) {
dispatch(actions.setTimestamps({ createTime: defaultCreateTime, updateTime: defaultCreateTime }));
}
}
if (autoFocus) {
setTimeout(() => editorRef.current?.focus(), 100);
}
setIsInitialized(true);
}, [memo, cacheKey, username, autoFocus, defaultVisibility, defaultCreateTime, actions, dispatch, editorRef]);
return { isInitialized };
};
```
Notes:
- The `defaultCreateTime` dependency is added to the effect's deps to satisfy the linter, but `initializedRef` ensures the body runs only once. Live re-sync after mount is handled by a separate effect in Task 5.
- Edit mode is unchanged — `defaultCreateTime` is intentionally ignored when `memo` is set.
- [ ] **Step 2: Verify compilation**
Run: `cd web && pnpm lint`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add web/src/components/MemoEditor/hooks/useMemoInit.ts
git commit -m "feat(memo-editor): seed timestamps from defaultCreateTime on init"
```
---
## Task 5: Wire `defaultCreateTime` through `MemoEditor` and render popover
**Files:**
- Modify: `web/src/components/MemoEditor/index.tsx`
Three changes:
1. Destructure `defaultCreateTime` from props.
2. Pass it into `useMemoInit`.
3. Add a `useEffect` that dispatches `setTimestamps` whenever `defaultCreateTime` changes after mount (live re-sync per design Q3-A). Skip when `memo` is set.
4. Update the popover render condition so it shows in create mode too when timestamps are seeded.
- [ ] **Step 1: Destructure the prop and pass it to `useMemoInit`**
In `web/src/components/MemoEditor/index.tsx`, update the `MemoEditorImpl` destructuring (around line 42):
```tsx
const MemoEditorImpl: React.FC<MemoEditorProps> = ({
className,
cacheKey,
memo,
parentMemoName,
autoFocus,
placeholder,
defaultCreateTime,
onConfirm,
onCancel,
}) => {
```
And update the `useMemoInit` call (around line 71):
```tsx
const { isInitialized } = useMemoInit({
editorRef,
memo,
cacheKey,
username: currentUser?.name ?? "",
autoFocus,
defaultVisibility,
defaultCreateTime,
});
```
- [ ] **Step 2: Add the live re-sync effect**
In the same file, add a new `useEffect` after `useMemoInit` (and after `useAutoSave`) that re-syncs timestamps when `defaultCreateTime` changes in create mode. Place it just before the existing `useEffect` that fetches AI settings (around line 80):
```tsx
// Live-sync the draft's createTime/updateTime to the calendar-derived prop.
// Only applies in create mode; edit mode owns its own timestamps. Runs after
// initial mount (the seed value is set in useMemoInit), and again whenever
// the prop changes — e.g., when the user picks a different calendar date
// while the editor is open.
useEffect(() => {
if (memo) return;
if (!isInitialized) return;
dispatch(
actions.setTimestamps({
createTime: defaultCreateTime,
updateTime: defaultCreateTime,
}),
);
}, [defaultCreateTime, memo, isInitialized, actions, dispatch]);
```
Notes:
- We pass `undefined` through when the prop becomes undefined (filter cleared) — this resets timestamps to undefined so the editor falls back to "server-stamped now" on save, exactly the pre-feature behavior.
- The `isInitialized` guard avoids racing with `useMemoInit`'s one-shot seed.
- [ ] **Step 3: Update the popover render condition**
In the same file, find the existing block (around line 294):
```tsx
{memoName && (
<div className="w-full -mb-1">
<TimestampPopover />
</div>
)}
```
Replace with:
```tsx
{(memoName || (!memo && state.timestamps.createTime)) && (
<div className="w-full -mb-1">
<TimestampPopover />
</div>
)}
```
Now the popover renders in edit mode (unchanged) AND in create mode whenever a default timestamp has been seeded.
- [ ] **Step 4: Verify compilation**
Run: `cd web && pnpm lint`
Expected: PASS.
- [ ] **Step 5: Run all tests**
Run: `cd web && pnpm test`
Expected: PASS (no editor-specific tests added; existing tests continue to pass).
- [ ] **Step 6: Commit**
```bash
git add web/src/components/MemoEditor/index.tsx
git commit -m "feat(memo-editor): live-sync timestamps and reveal popover from defaultCreateTime"
```
---
## Task 6: Wire calendar selection through `PagedMemoList`
**Files:**
- Modify: `web/src/components/PagedMemoList/PagedMemoList.tsx`
The home memo editor is rendered at line ~155 of `PagedMemoList.tsx`. Read the current `MemoFilterContext` filters, derive the `defaultCreateTime`, and pass it to `<MemoEditor>`. Wrap in `useMemo` so the reference stays stable when filters don't change (avoids re-firing the live-sync effect).
- [ ] **Step 1: Add the imports and derivation**
Modify `web/src/components/PagedMemoList/PagedMemoList.tsx`. Add to the existing imports near the top:
```tsx
import { useMemo } from "react";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
```
If `useMemo` is already imported from `react` in this file, merge into the existing import rather than duplicating.
Inside the component body (above the `children` JSX, near the top of the function), add:
```tsx
const { filters } = useMemoFilterContext();
const defaultCreateTime = useMemo(
() => deriveDefaultCreateTimeFromFilters(filters),
[filters],
);
```
- [ ] **Step 2: Pass the prop to `<MemoEditor>`**
Replace the existing line ~155:
```tsx
{showMemoEditor ? <MemoEditor className="mb-2" cacheKey="home-memo-editor" placeholder={t("editor.any-thoughts")} /> : null}
```
with:
```tsx
{showMemoEditor ? (
<MemoEditor
className="mb-2"
cacheKey="home-memo-editor"
placeholder={t("editor.any-thoughts")}
defaultCreateTime={defaultCreateTime}
/>
) : null}
```
- [ ] **Step 3: Verify compilation**
Run: `cd web && pnpm lint`
Expected: PASS.
- [ ] **Step 4: Run all tests**
Run: `cd web && pnpm test`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add web/src/components/PagedMemoList/PagedMemoList.tsx
git commit -m "feat(home): pass calendar-selected date as default createTime to memo editor"
```
---
## Task 7: Manual smoke test
No code changes. Per `CLAUDE.md`: "For UI or frontend changes, start the dev server and use the feature in a browser before reporting the task as complete." Walk through the user-visible flows.
- [ ] **Step 1: Start the backend and frontend**
In one terminal:
```bash
go run ./cmd/memos --port 8081
```
In another:
```bash
cd web && pnpm dev
```
Open `http://localhost:3001`.
- [ ] **Step 2: Smoke — populated past date**
1. Sign in. Confirm the activity calendar is visible (statistics view).
2. Click a *past* date that already has memos.
3. Confirm the URL gains `?filter=displayTime:YYYY-MM-DD`.
4. Confirm the home memo editor shows the timestamp popover above the textarea, populated with the selected date.
5. Type a memo and click Save.
6. Clear the date filter (chip X). Reapply by clicking the same date.
7. Confirm the new memo appears under that date.
- [ ] **Step 3: Smoke — empty past date**
1. Pick a past date with **zero memos** in the calendar (lightest cell).
2. Confirm it is now clickable, the URL filter applies, and the empty-state shows.
3. Type and save a memo.
4. Confirm the memo appears for that date and the calendar cell tints up.
- [ ] **Step 4: Smoke — future date**
1. Click a future date in the current month.
2. Confirm the popover shows that date.
3. Save a memo. Confirm it appears under that date.
- [ ] **Step 5: Smoke — clear filter mid-draft**
1. Pick May 1 (or any non-today date). Type some content, do **not** save.
2. Click the filter chip X to clear the date filter.
3. Confirm the popover disappears and the draft content is preserved (autoSave behavior).
4. Save and confirm the memo gets a server-stamped "now" timestamp (i.e., appears under today).
- [ ] **Step 6: Smoke — change filter mid-draft**
1. Pick May 1. Type content.
2. Without saving, click May 3.
3. Confirm the popover updates to May 3.
4. Save. Confirm the memo appears under May 3.
- [ ] **Step 7: Smoke — comment editor unaffected**
1. Open any memo's detail view (or open the comments thread).
2. Confirm the reply editor does **not** show a timestamp popover.
3. Confirm the date filter has no visible effect on the reply editor.
- [ ] **Step 8: Smoke — edit mode unaffected**
1. Edit an existing memo (pencil icon).
2. Confirm the existing timestamp popover still works exactly as before, regardless of any active calendar filter.
- [ ] **Step 9: Smoke — empty-date click on Explore page**
1. Navigate to the Explore page (which also renders the calendar).
2. Click an empty date.
3. Confirm the URL filter applies and the empty-state shows. (No editor on Explore — that's correct.)
- [ ] **Step 10: Record results**
Note any unexpected behavior (especially: selection-ring contrast on the lowest-intensity background, mentioned as a flag in the spec). If the ring is too subtle, file a follow-up — *not* part of this plan.
---
## Self-review
**Spec coverage check:**
| Spec requirement | Task |
|---|---|
| Empty calendar dates clickable | Task 2 |
| Editor shows TimestampPopover in create mode when filter active | Task 5 (popover condition) |
| `createTime` = selected date + current local hh:mm:ss | Task 1 (helper) + Task 4 (seed) |
| `updateTime` mirrored to same value | Task 4 (seed) + Task 5 (live sync) |
| Live-derived: filter change re-syncs timestamps | Task 5 (live-sync `useEffect`) |
| Filter cleared → undefined → server-stamped "now" | Task 5 (passes `undefined` through) + Task 6 (helper returns undefined) |
| Future dates allowed (no clamp) | Task 1 (no clamp in helper); confirmed in Task 7 step 4 |
| Comment editor unaffected | Task 6 wires only `PagedMemoList`; confirmed in Task 7 step 7 |
| Edit mode unaffected | Task 4 + 5 explicitly guard on `memo`; confirmed in Task 7 step 8 |
| Empty-date click on Explore/Archived/Profile | Task 2 (calendar-side change); confirmed in Task 7 step 9 |
| DST/timezone uses local time | Task 1 (`new Date(y, m-1, d, h, mi, s)`) |
| Helper unit tests | Task 1 |
| `CalendarCell` empty-cell test | Task 2 |
| Manual smoke | Task 7 |
All spec requirements have a task. No gaps.
**Placeholder scan:** No "TBD"/"TODO" steps. Every code step shows the actual code.
**Type/name consistency check:**
- `MemoFilter` and `useMemoFilterContext` exist at `@/contexts/MemoFilterContext` (verified during exploration).
- `editorActions.setTimestamps` exists in `state/actions.ts:75` and accepts `Partial<EditorState["timestamps"]>` (verified). Calls in Tasks 4 and 5 match.
- `state.timestamps.createTime` is `Date | undefined` (verified `state/types.ts:27-29`). The popover render condition uses it as a truthy guard — `Date` instances are truthy, `undefined` is falsy.
- `useMemoFilterContext` (alias used in PagedMemoList) is exported from `MemoFilterContext.tsx:151` (verified).
- `deriveDefaultCreateTimeFromFilters` signature is identical between Task 1 (definition) and Task 6 (consumer).
- `defaultCreateTime: Date | undefined` flows consistently through `MemoEditorProps` (Task 3) → `MemoEditorImpl` destructuring (Task 5) → `useMemoInit` options (Task 4) → reducer payloads.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,774 @@
# First Screen Lazy Heavy Dependencies Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Keep Mermaid, Leaflet, React Leaflet, marker cluster, and feature CSS out of the auth/signup initial screen while preserving diagram, math, and map behavior when those features are used.
**Architecture:** Move runtime Leaflet objects behind plain coordinate data at parent boundaries, then lazy-load map implementations from small wrapper components. Replace Mermaid's static import with effect-time dynamic import, and move KaTeX/Leaflet CSS from the app entry into feature modules.
**Tech Stack:** React 19, TypeScript 6, Vite 8/Rolldown, React Router 7, React Query 5, Mermaid, KaTeX, Leaflet, React Leaflet, pnpm.
---
## File Structure
- Create `web/src/components/map/types.ts`
- Defines a lightweight `MapPoint` interface used by parents that must not import Leaflet.
- Create `web/src/components/map/LazyLocationPicker.tsx`
- Provides a `React.lazy` boundary and map-sized fallback for `LocationPicker`.
- Modify `web/src/main.tsx`
- Removes global Leaflet and KaTeX CSS imports.
- Modify `web/src/router/index.tsx`
- Lazy-loads the Home route so memo/editor/markdown modules are not part of the auth/signup entry graph.
- Modify `web/vite.config.mts`
- Keeps optional heavy dependency split groups from becoming entry preloads.
- Modify `web/src/components/map/LocationPicker.tsx`
- Imports Leaflet CSS inside the lazy implementation path.
- Accepts and emits plain `MapPoint` values at the public component boundary.
- Keeps `LatLng` runtime use internal to the map implementation.
- Modify `web/src/components/map/index.ts`
- Stops exporting `LocationPicker` and map utility helpers from the barrel so non-map imports do not pull Leaflet into parent chunks.
- Keeps exporting `useReverseGeocoding` because it has no Leaflet dependency.
- Modify `web/src/components/MemoEditor/types/insert-menu.ts`
- Replaces `LatLng` in editor state with `MapPoint`.
- Modify `web/src/components/MemoEditor/hooks/useLocation.ts`
- Removes runtime Leaflet import and stores plain coordinates.
- Modify `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`
- Removes runtime Leaflet import.
- Imports `useReverseGeocoding` directly from its file.
- Constructs plain coordinates from geolocation.
- Modify `web/src/components/MemoMetadata/Location/LocationDialog.tsx`
- Uses `LazyLocationPicker` and `MapPoint`.
- Only mounts the lazy picker while the dialog is open.
- Modify `web/src/components/MemoMetadata/Location/LocationDisplayView.tsx`
- Removes runtime Leaflet import and uses `LazyLocationPicker` inside the popover.
- Modify `web/src/pages/UserProfile.tsx`
- Lazy-loads `UserMemoMap` only when the map tab is active.
- Modify `web/src/components/MemoContent/MemoMarkdownRenderer.tsx`
- Imports KaTeX CSS from the markdown-rendering path.
- Modify `web/src/components/MemoContent/MermaidBlock.tsx`
- Dynamically imports Mermaid only when rendering a Mermaid block.
- Modify `web/src/components/UserMemoMap/UserMemoMap.tsx`
- Keeps marker cluster CSS in the lazy map implementation path.
- Verify with `pnpm lint`, `pnpm build`, and a production preview/browser network check.
## Task 1: Remove Leaflet From Editor Location State
**Files:**
- Create: `web/src/components/map/types.ts`
- Modify: `web/src/components/MemoEditor/types/insert-menu.ts`
- Modify: `web/src/components/MemoEditor/hooks/useLocation.ts`
- Modify: `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`
- [x] **Step 1: Add a Leaflet-free map coordinate type**
Create `web/src/components/map/types.ts`:
```ts
export interface MapPoint {
lat: number;
lng: number;
}
```
- [x] **Step 2: Replace editor location state type**
In `web/src/components/MemoEditor/types/insert-menu.ts`, replace the entire file with:
```ts
import type { MapPoint } from "@/components/map/types";
export interface LocationState {
placeholder: string;
position?: MapPoint;
latInput: string;
lngInput: string;
}
```
- [x] **Step 3: Remove Leaflet runtime usage from `useLocation`**
In `web/src/components/MemoEditor/hooks/useLocation.ts`, remove `import { LatLng } from "leaflet";`, add a type import, and use plain coordinate objects:
```ts
import { create } from "@bufbuild/protobuf";
import { useCallback, useMemo, useRef, useState } from "react";
import type { MapPoint } from "@/components/map/types";
import { Location, LocationSchema } from "@/types/proto/api/v1/memo_service_pb";
import { LocationState } from "../types/insert-menu";
export const useLocation = (initialLocation?: Location) => {
const [locationInitialized, setLocationInitialized] = useState(false);
const locationInitializedRef = useRef(locationInitialized);
locationInitializedRef.current = locationInitialized;
const [state, setState] = useState<LocationState>({
placeholder: initialLocation?.placeholder || "",
position: initialLocation ? { lat: initialLocation.latitude, lng: initialLocation.longitude } : undefined,
latInput: initialLocation ? String(initialLocation.latitude) : "",
lngInput: initialLocation ? String(initialLocation.longitude) : "",
});
const stateRef = useRef(state);
stateRef.current = state;
const updatePosition = useCallback((position?: MapPoint) => {
setState((prev) => ({
...prev,
position,
latInput: position ? String(position.lat) : "",
lngInput: position ? String(position.lng) : "",
}));
}, []);
const handlePositionChange = useCallback(
(position: MapPoint) => {
if (!locationInitializedRef.current) setLocationInitialized(true);
updatePosition(position);
},
[updatePosition],
);
const updateCoordinate = useCallback((type: "lat" | "lng", value: string) => {
const num = parseFloat(value);
const isValid = type === "lat" ? !isNaN(num) && num >= -90 && num <= 90 : !isNaN(num) && num >= -180 && num <= 180;
setState((prev) => {
const next = { ...prev, [type === "lat" ? "latInput" : "lngInput"]: value };
if (isValid && prev.position) {
const newPos = type === "lat" ? { lat: num, lng: prev.position.lng } : { lat: prev.position.lat, lng: num };
return { ...next, position: newPos, latInput: String(newPos.lat), lngInput: String(newPos.lng) };
}
return next;
});
}, []);
const setPlaceholder = useCallback((placeholder: string) => {
setState((prev) => ({ ...prev, placeholder }));
}, []);
const reset = useCallback(() => {
setState({
placeholder: "",
position: undefined,
latInput: "",
lngInput: "",
});
setLocationInitialized(false);
}, []);
const getLocation = useCallback((): Location | undefined => {
const { position, placeholder } = stateRef.current;
if (!position || !placeholder.trim()) {
return undefined;
}
return create(LocationSchema, {
latitude: position.lat,
longitude: position.lng,
placeholder,
});
}, []);
return useMemo(
() => ({ state, locationInitialized, handlePositionChange, updateCoordinate, setPlaceholder, reset, getLocation }),
[state, locationInitialized, handlePositionChange, updateCoordinate, setPlaceholder, reset, getLocation],
);
};
```
- [x] **Step 4: Remove Leaflet import from `InsertMenu`**
In `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`:
Remove:
```ts
import { LatLng } from "leaflet";
```
Replace:
```ts
import { useReverseGeocoding } from "@/components/map";
```
with:
```ts
import { useReverseGeocoding } from "@/components/map/useReverseGeocoding";
```
Replace the geolocation success handler body:
```ts
handleLocationPositionChange(new LatLng(position.coords.latitude, position.coords.longitude));
```
with:
```ts
handleLocationPositionChange({ lat: position.coords.latitude, lng: position.coords.longitude });
```
- [x] **Step 5: Run focused type check**
Run:
```bash
cd web && pnpm lint
```
Expected: TypeScript may still fail because map component props have not been converted yet. If the only failures are `LatLng`/`MapPoint` mismatches in map/location components, continue to Task 2. If unrelated failures appear, stop and inspect before editing further.
## Task 2: Lazy-Load Location Picker And Keep Leaflet Inside Map Modules
**Files:**
- Create: `web/src/components/map/LazyLocationPicker.tsx`
- Modify: `web/src/components/map/LocationPicker.tsx`
- Modify: `web/src/components/map/index.ts`
- Modify: `web/src/components/MemoMetadata/Location/LocationDialog.tsx`
- Modify: `web/src/components/MemoMetadata/Location/LocationDisplayView.tsx`
- [x] **Step 1: Create lazy location picker wrapper**
Create `web/src/components/map/LazyLocationPicker.tsx`:
```tsx
import { lazy, Suspense } from "react";
import { cn } from "@/lib/utils";
import type { MapPoint } from "./types";
interface LazyLocationPickerProps {
readonly?: boolean;
latlng?: MapPoint;
onChange?: (position: MapPoint) => void;
className?: string;
}
const LocationPicker = lazy(() => import("./LocationPicker"));
export const LazyLocationPicker = ({ className, ...props }: LazyLocationPickerProps) => {
return (
<Suspense
fallback={
<div
className={cn(
"memo-location-map relative isolate h-72 w-full overflow-hidden rounded-xl border border-border bg-muted/30 shadow-sm",
className,
)}
/>
}
>
<LocationPicker className={className} {...props} />
</Suspense>
);
};
```
- [x] **Step 2: Convert `LocationPicker` public props to `MapPoint`**
In `web/src/components/map/LocationPicker.tsx`:
Add CSS and type imports near the top:
```ts
import "leaflet/dist/leaflet.css";
import type { MapPoint } from "./types";
```
Keep Leaflet runtime import:
```ts
import L, { LatLng } from "leaflet";
```
Add helper functions after imports:
```ts
const toLatLng = (point: MapPoint): LatLng => new LatLng(point.lat, point.lng);
const fromLatLng = (latlng: LatLng): MapPoint => ({ lat: latlng.lat, lng: latlng.lng });
```
Update public-facing props:
```ts
interface LocationMarkerProps {
position: LatLng | undefined;
onChange: (position: MapPoint) => void;
readonly?: boolean;
}
```
In `LocationMarker`, replace:
```ts
onChange(e.latlng);
```
with:
```ts
onChange(fromLatLng(e.latlng));
```
Update `MapControlsProps`:
```ts
interface MapControlsProps {
position: MapPoint | undefined;
}
```
Update `LocationPickerProps`:
```ts
interface LocationPickerProps {
readonly?: boolean;
latlng?: MapPoint;
onChange?: (position: MapPoint) => void;
className?: string;
}
```
Replace:
```ts
const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945);
```
with:
```ts
const DEFAULT_CENTER: MapPoint = { lat: 48.8584, lng: 2.2945 };
```
Inside `LocationPicker`, replace:
```ts
const position = latlng || DEFAULT_CENTER_LAT_LNG;
```
with:
```ts
const position = latlng || DEFAULT_CENTER;
const mapCenter = toLatLng(position);
const markerPosition = latlng ? toLatLng(latlng) : mapCenter;
```
Replace the `MapContainer` props and marker call:
```tsx
<MapContainer
className="h-full w-full !bg-muted"
center={mapCenter}
zoom={13}
scrollWheelZoom={false}
zoomControl={false}
attributionControl={false}
>
<ThemedTileLayer />
<LocationMarker position={markerPosition} readonly={readOnly} onChange={onChange} />
<MapControls position={latlng} />
<MapCleanup />
</MapContainer>
```
- [x] **Step 3: Keep the map barrel Leaflet-free**
Replace `web/src/components/map/index.ts` with:
```ts
export { useReverseGeocoding } from "./useReverseGeocoding";
```
Do not export `LocationPicker`, `LazyLocationPicker`, `map-utils`, or Leaflet helpers from this barrel. Import map UI directly from `@/components/map/LazyLocationPicker` or the implementation file.
- [x] **Step 4: Use lazy picker in `LocationDialog`**
In `web/src/components/MemoMetadata/Location/LocationDialog.tsx`:
Remove:
```ts
import type { LatLng } from "leaflet";
import { LocationPicker } from "@/components/map";
```
Add:
```ts
import { LazyLocationPicker } from "@/components/map/LazyLocationPicker";
import type { MapPoint } from "@/components/map/types";
```
Change the prop type:
```ts
onPositionChange: (position: MapPoint) => void;
```
Replace:
```tsx
<LocationPicker className="h-full" latlng={position} onChange={onPositionChange} />
```
with:
```tsx
{open && <LazyLocationPicker className="h-full" latlng={position} onChange={onPositionChange} />}
```
- [x] **Step 5: Use lazy picker in `LocationDisplayView`**
In `web/src/components/MemoMetadata/Location/LocationDisplayView.tsx`:
Remove:
```ts
import { LatLng } from "leaflet";
import { LocationPicker } from "@/components/map";
```
Add:
```ts
import { LazyLocationPicker } from "@/components/map/LazyLocationPicker";
```
Replace:
```tsx
<LocationPicker latlng={new LatLng(location.latitude, location.longitude)} readonly={true} />
```
with:
```tsx
{popoverOpen && <LazyLocationPicker latlng={{ lat: location.latitude, lng: location.longitude }} readonly={true} />}
```
- [x] **Step 6: Run lint**
Run:
```bash
cd web && pnpm lint
```
Expected: PASS for the files changed so far, or only failures unrelated to this work. Fix any `MapPoint`/`LatLng` type errors before continuing.
## Task 3: Lazy-Load Profile Map
**Files:**
- Modify: `web/src/pages/UserProfile.tsx`
- Modify: `web/src/components/UserMemoMap/UserMemoMap.tsx`
- [x] **Step 1: Move `UserMemoMap` behind `React.lazy`**
In `web/src/pages/UserProfile.tsx`, replace the React import section:
```ts
import copy from "copy-to-clipboard";
import { ExternalLinkIcon, LayoutListIcon, type LucideIcon, MapIcon } from "lucide-react";
import { toast } from "react-hot-toast";
```
with:
```ts
import copy from "copy-to-clipboard";
import { lazy, Suspense } from "react";
import { ExternalLinkIcon, LayoutListIcon, type LucideIcon, MapIcon } from "lucide-react";
import { toast } from "react-hot-toast";
```
Remove:
```ts
import UserMemoMap from "@/components/UserMemoMap";
```
Add after the `type TabView = "memos" | "map";` line:
```ts
const UserMemoMap = lazy(() => import("@/components/UserMemoMap"));
```
Replace:
```tsx
<div className="">
<UserMemoMap creator={user.name} className="h-[60dvh] sm:h-[500px] rounded-xl" />
</div>
```
with:
```tsx
<div className="">
<Suspense fallback={<div className="h-[60dvh] sm:h-[500px] rounded-xl border border-border bg-muted/30" />}>
<UserMemoMap creator={user.name} className="h-[60dvh] sm:h-[500px] rounded-xl" />
</Suspense>
</div>
```
- [x] **Step 2: Keep Leaflet CSS in the lazy profile map implementation**
In `web/src/components/UserMemoMap/UserMemoMap.tsx`, add the Leaflet CSS import above marker cluster CSS:
```ts
import "leaflet/dist/leaflet.css";
import "leaflet.markercluster/dist/MarkerCluster.css";
```
The file should continue to import Leaflet, React Leaflet, marker clustering, and `map-utils` directly because this component is now loaded only from a lazy boundary.
- [x] **Step 3: Run lint**
Run:
```bash
cd web && pnpm lint
```
Expected: PASS, or only unrelated pre-existing failures.
## Task 4: Move KaTeX CSS And Dynamically Import Mermaid
**Files:**
- Modify: `web/src/main.tsx`
- Modify: `web/src/components/MemoContent/MemoMarkdownRenderer.tsx`
- Modify: `web/src/components/MemoContent/MermaidBlock.tsx`
- [x] **Step 1: Remove feature CSS from app entry**
In `web/src/main.tsx`, remove:
```ts
import "leaflet/dist/leaflet.css";
import "katex/dist/katex.min.css";
```
- [x] **Step 2: Load KaTeX CSS from markdown renderer**
In `web/src/components/MemoContent/MemoMarkdownRenderer.tsx`, add this import with the existing dependency imports:
```ts
import "katex/dist/katex.min.css";
```
- [x] **Step 3: Remove static Mermaid import**
In `web/src/components/MemoContent/MermaidBlock.tsx`, remove:
```ts
import mermaid from "mermaid";
```
- [x] **Step 4: Replace Mermaid initialization/render effects with dynamic import**
In `web/src/components/MemoContent/MermaidBlock.tsx`, remove the existing Mermaid initialization effect:
```ts
useEffect(() => {
mermaid.initialize({
startOnLoad: false,
theme: toMermaidTheme(currentTheme),
securityLevel: "strict",
fontFamily: "inherit",
suppressErrorRendering: true,
});
}, [currentTheme]);
```
Replace the existing render effect with:
```ts
useEffect(() => {
if (!codeContent) return;
let cancelled = false;
const renderDiagram = async () => {
try {
const { default: mermaid } = await import("mermaid");
if (cancelled) return;
mermaid.initialize({
startOnLoad: false,
theme: toMermaidTheme(currentTheme),
securityLevel: "strict",
fontFamily: "inherit",
suppressErrorRendering: true,
});
const id = `mermaid-${Math.random().toString(36).substring(7)}`;
const { svg: renderedSvg } = await mermaid.render(id, codeContent);
if (cancelled) return;
setSvg(renderedSvg);
setError("");
} catch (err) {
if (cancelled) return;
console.error("Failed to render mermaid diagram:", err);
setSvg("");
setError(formatErrorMessage(err));
}
};
renderDiagram();
return () => {
cancelled = true;
};
}, [codeContent, currentTheme]);
```
- [x] **Step 5: Run lint**
Run:
```bash
cd web && pnpm lint
```
Expected: PASS, or only unrelated pre-existing failures.
## Task 4.5: Remove Remaining Entry Preloads Found During Verification
**Files:**
- Modify: `web/src/router/index.tsx`
- Modify: `web/vite.config.mts`
- [x] **Step 1: Lazy-load the Home route**
`web/src/router/index.tsx` now uses `lazyWithReload(() => import("@/pages/Home"))` instead of a static Home import. This prevents Home, `PagedMemoList`, `MemoEditor`, `MemoContent`, KaTeX, and Mermaid code from entering the auth/signup app entry graph.
- [x] **Step 2: Tighten optional vendor split groups**
`web/vite.config.mts` no longer defines a manual `mermaid-vendor` group, because Rolldown emitted the preload helper from that group and forced an entry preload. The Leaflet group now matches only the `leaflet` package, not `react-leaflet`, so React does not get bundled into a Leaflet-named entry preload.
- [x] **Step 3: Re-run lint and build**
Run:
```bash
cd web && pnpm lint && pnpm build
```
Expected: PASS.
## Task 5: Build And Verify Initial Network Behavior
**Files:**
- No source edits expected unless verification finds a regression.
- [x] **Step 1: Build production frontend**
Run:
```bash
cd web && pnpm build
```
Expected: PASS. Build output should still contain separate Mermaid and Leaflet chunks, but they should not be required by the auth/signup initial route.
- [x] **Step 2: Inspect build output for heavy chunks**
Run:
```bash
cd web && find dist/assets -maxdepth 1 -type f \( -name '*mermaid*' -o -name '*leaflet*' -o -name '*katex*' \) -print | sort
```
Expected: Mermaid and Leaflet assets may exist as lazy chunks. Their existence is fine; the goal is that auth/signup does not request them initially.
- [x] **Step 3: Start production preview**
Run:
```bash
cd web && pnpm exec vite preview --host 127.0.0.1 --port 4173
```
Expected: Preview server starts on `http://127.0.0.1:4173/`. Keep this session running until browser verification is complete.
- [x] **Step 4: Verify `/auth/signup` network with browser tooling**
Open:
```text
http://127.0.0.1:4173/auth/signup
```
Expected initial document and asset requests do not include filenames containing:
```text
mermaid
leaflet
```
If KaTeX CSS still appears on `/auth/signup`, inspect the chunk initiator and remove any remaining static import path that reaches `MemoMarkdownRenderer` from auth/signup.
- [ ] **Step 5: Smoke test feature lazy loading**
Use the running app or a local backend/dev setup to verify:
```text
1. A memo containing a Mermaid code block renders the diagram and requests the Mermaid chunk only when the memo content appears.
2. A memo containing inline or block math displays KaTeX styling when memo content appears.
3. Opening the location picker loads Leaflet assets and the picker remains interactive.
4. Opening a memo location popover loads Leaflet assets and shows the pinned map.
5. Opening `/u/:username?view=map` loads the profile map and marker cluster behavior still works.
```
Expected: Features behave as before after their lazy chunks load.
Result in this session: live feature smoke was not completed because the production preview has no authenticated backend session or seeded memo data. Static verification confirmed the relevant feature chunks still exist and load paths are behind lazy imports.
- [x] **Step 6: Stop preview server**
Stop the preview command from Step 3 with `Ctrl-C`.
## Task 6: Commit Implementation
**Files:**
- All source files modified by Tasks 1-4.
- [x] **Step 1: Review final diff**
Run:
```bash
git diff -- web/src/main.tsx web/src/components/map web/src/components/MemoEditor web/src/components/MemoMetadata/Location web/src/pages/UserProfile.tsx web/src/components/MemoContent web/src/components/UserMemoMap/UserMemoMap.tsx
```
Expected: Diff is limited to lazy-loading heavy optional dependencies and plain coordinate type changes.
- [x] **Step 2: Run final verification**
Run:
```bash
cd web && pnpm lint && pnpm build
```
Expected: PASS.
- [x] **Step 3: Commit**
Run:
```bash
git add web/src/main.tsx web/src/components/map web/src/components/MemoEditor web/src/components/MemoMetadata/Location web/src/pages/UserProfile.tsx web/src/components/MemoContent web/src/components/UserMemoMap/UserMemoMap.tsx
git commit -m "perf: lazy load heavy first-screen dependencies"
```
Expected: Commit succeeds with only the intended source changes.
## Self-Review
- Spec coverage: The plan removes global Leaflet/KaTeX CSS, dynamically imports Mermaid, lazy-loads map UI, keeps Leaflet types out of parent boundaries, and verifies auth/signup network behavior.
- Placeholder scan: No placeholder markers, unresolved decisions, or vague generic handling steps remain.
- Type consistency: `MapPoint` is the shared parent-facing coordinate type; `LatLng` remains internal to `LocationPicker` and map implementation files only.
@@ -0,0 +1,776 @@
# Placeholder Component Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace `Empty.tsx` with a reusable `<Placeholder>` component covering empty / loading / noResults / notFound states, each rendering a hand-curated ASCII bird from a pool-shaped data file with subtle CSS-only animation.
**Architecture:** Single component (`Placeholder/index.tsx`) reads from a co-located `ascii-pool.ts` data file via a `pickPiece(variant)` picker. Motion is CSS keyframes in `Placeholder.css`, gated by `prefers-reduced-motion`. Default messages live in a `messages.ts` seam ready for future i18n. Integration is narrow: only `Inboxes.tsx` is rewired in this PR; `Empty.tsx` is deleted.
**Tech Stack:** React 19 · TypeScript · Tailwind v4 (via `@tailwindcss/vite`) · Vitest + `@testing-library/react` + jsdom · Biome for lint/format · `cn` helper from `@/lib/utils` for class composition.
---
## File Structure
**Create:**
- `web/src/components/Placeholder/index.tsx` — public component (default export)
- `web/src/components/Placeholder/Placeholder.css` — keyframes + motion classes
- `web/src/components/Placeholder/ascii-pool.ts` — types, `ASCII_POOL` array, `pickPiece()`
- `web/src/components/Placeholder/messages.ts``DEFAULT_MESSAGES` map
- `web/src/components/Placeholder/CREDITS.md` — Joan Stark attribution
- `web/tests/placeholder-pool.test.ts` — picker + pool integrity tests
- `web/tests/placeholder-component.test.tsx` — component render tests
**Modify:**
- `web/src/pages/Inboxes.tsx` — replace `<Empty />` with `<Placeholder variant="empty" message={…} />`
**Delete:**
- `web/src/components/Empty.tsx`
---
## Task 0: Commit this plan
**Files:**
- Add: `docs/superpowers/plans/2026-05-12-placeholder-component.md`
- [ ] **Step 1: Commit the plan document on its own**
```bash
git add docs/superpowers/plans/2026-05-12-placeholder-component.md
git commit -m "docs: add Placeholder component implementation plan"
```
This keeps the planning artifact separate from feature commits.
---
## Task 1: Scaffold pool types and picker (TDD)
**Files:**
- Create: `web/src/components/Placeholder/ascii-pool.ts`
- Test: `web/tests/placeholder-pool.test.ts`
- [ ] **Step 1: Write the failing tests for `pickPiece` and pool shape**
Create `web/tests/placeholder-pool.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { ASCII_POOL, pickPiece, type PlaceholderVariant } from "@/components/Placeholder/ascii-pool";
const VARIANTS: PlaceholderVariant[] = ["empty", "loading", "noResults", "notFound"];
describe("ASCII_POOL integrity", () => {
it("contains at least one piece per variant", () => {
for (const variant of VARIANTS) {
const matches = ASCII_POOL.filter((p) => p.variant === variant);
expect(matches.length, `variant=${variant}`).toBeGreaterThanOrEqual(1);
}
});
it("uses unique ids", () => {
const ids = ASCII_POOL.map((p) => p.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("preserves the jgs credit on every piece", () => {
for (const piece of ASCII_POOL) {
expect(piece.credit, `piece=${piece.id}`).toMatch(/jgs/);
}
});
it("uses a known motion style on every piece", () => {
for (const piece of ASCII_POOL) {
expect(["bob", "flutter", "none"]).toContain(piece.motion);
}
});
});
describe("pickPiece", () => {
it("returns a piece matching the requested variant", () => {
for (const variant of VARIANTS) {
const piece = pickPiece(variant);
expect(piece.variant).toBe(variant);
}
});
it("returns a non-empty ascii string", () => {
const piece = pickPiece("empty");
expect(piece.ascii.length).toBeGreaterThan(0);
});
});
```
- [ ] **Step 2: Run the tests and verify they fail**
```bash
cd web && pnpm test placeholder-pool
```
Expected: FAIL — module `@/components/Placeholder/ascii-pool` not found.
- [ ] **Step 3: Implement `ascii-pool.ts` with types, an empty pool, and the picker**
Create `web/src/components/Placeholder/ascii-pool.ts`:
```ts
export type PlaceholderVariant = "empty" | "loading" | "noResults" | "notFound";
export type MotionStyle = "bob" | "flutter" | "none";
export interface AsciiPiece {
/** Stable identifier — used as React key and for debugging. */
id: string;
/** Which placeholder state this piece is shown for. */
variant: PlaceholderVariant;
/** ASCII art preserved verbatim — must keep every space. */
ascii: string;
/** Attribution shown beneath the bird, e.g. "jgs · 4/97". */
credit: string;
/** Motion hint applied to the <pre>. */
motion: MotionStyle;
}
export const ASCII_POOL: AsciiPiece[] = [];
export function pickPiece(variant: PlaceholderVariant): AsciiPiece {
const matches = ASCII_POOL.filter((p) => p.variant === variant);
if (matches.length === 0) {
throw new Error(`No ASCII piece registered for variant "${variant}"`);
}
return matches[Math.floor(Math.random() * matches.length)];
}
```
- [ ] **Step 4: Run the tests and verify they still fail (pool is empty)**
```bash
cd web && pnpm test placeholder-pool
```
Expected: FAIL — "contains at least one piece per variant" expectations not met (because `ASCII_POOL` is `[]`).
This is the expected red — pool gets seeded in the next task.
- [ ] **Step 5: Commit**
```bash
git add web/src/components/Placeholder/ascii-pool.ts web/tests/placeholder-pool.test.ts
git commit -m "feat(placeholder): scaffold ASCII pool types and picker"
```
---
## Task 2: Seed the four ASCII pieces
**Files:**
- Modify: `web/src/components/Placeholder/ascii-pool.ts`
- [ ] **Step 1: Replace the empty `ASCII_POOL` array with the four seed entries**
In `web/src/components/Placeholder/ascii-pool.ts`, replace `export const ASCII_POOL: AsciiPiece[] = [];` with the four entries below. Preserve every space and newline in the `ascii` strings exactly — they are template literals with escaped backslashes/backticks per JS rules.
```ts
export const ASCII_POOL: AsciiPiece[] = [
{
id: "jgs-crested-parrot",
variant: "empty",
credit: "jgs · 4/97",
motion: "bob",
ascii: ` .---.
/ 6_6
\\_ (__\\
// \\\\
(( ))
=====""===""=====
|||
|`,
},
{
id: "jgs-hummingbird-sm",
variant: "loading",
credit: "jgs · 7/98",
motion: "flutter",
ascii: ` , _
{ \\/\`o;====-
.----'-/\`-/
\`'-..-| /
/\\/\\
\`--\``,
},
{
id: "jgs-wide-eyed-owl",
variant: "noResults",
credit: "jgs · 2/01",
motion: "bob",
ascii: ` __ __
\\ \`-'"'-\` /
/ \\_ _/ \\
| d\\_/b |
.'\\ V /'.
/ '-...-' \\
| / \\ |
\\/\\ /\\/
==(||)---(||)==`,
},
{
id: "jgs-bird-flown-away",
variant: "notFound",
credit: "jgs · 7/96",
motion: "flutter",
ascii: ` ___
_,-' ______
.' .-' ____7
/ / ___7
_| / ___7
>(')\\ | ___7
\\\\/ \\_______
' _======>
\`'----\\\\\``,
},
];
```
- [ ] **Step 2: Run the pool tests and verify they pass**
```bash
cd web && pnpm test placeholder-pool
```
Expected: PASS for all six assertions.
- [ ] **Step 3: Commit**
```bash
git add web/src/components/Placeholder/ascii-pool.ts
git commit -m "feat(placeholder): seed pool with four jgs ASCII bird pieces"
```
---
## Task 3: Default messages
**Files:**
- Create: `web/src/components/Placeholder/messages.ts`
- [ ] **Step 1: Add a tiny test for the messages map**
Append to `web/tests/placeholder-pool.test.ts`:
```ts
import { DEFAULT_MESSAGES } from "@/components/Placeholder/messages";
describe("DEFAULT_MESSAGES", () => {
it("provides a non-empty message for every variant", () => {
for (const variant of VARIANTS) {
expect(DEFAULT_MESSAGES[variant], `variant=${variant}`).toBeTruthy();
expect(DEFAULT_MESSAGES[variant].trim().length).toBeGreaterThan(0);
}
});
});
```
- [ ] **Step 2: Run the test and verify it fails**
```bash
cd web && pnpm test placeholder-pool
```
Expected: FAIL — module `@/components/Placeholder/messages` not found.
- [ ] **Step 3: Create `messages.ts`**
```ts
import type { PlaceholderVariant } from "./ascii-pool";
/**
* Default copy shown beneath the ASCII art when no `message` prop is supplied.
*
* Future i18n: swap these strings for `t("placeholder.<variant>")` lookups via
* `react-i18next` without touching the component.
*/
export const DEFAULT_MESSAGES: Record<PlaceholderVariant, string> = {
empty: "No memos yet",
loading: "Loading…",
noResults: "Nothing matches that search",
notFound: "This page flew the coop",
};
```
- [ ] **Step 4: Run the test and verify it passes**
```bash
cd web && pnpm test placeholder-pool
```
Expected: PASS — all variants have a non-empty default message.
- [ ] **Step 5: Commit**
```bash
git add web/src/components/Placeholder/messages.ts web/tests/placeholder-pool.test.ts
git commit -m "feat(placeholder): add DEFAULT_MESSAGES map"
```
---
## Task 4: Animation keyframes
**Files:**
- Create: `web/src/components/Placeholder/Placeholder.css`
- [ ] **Step 1: Create the stylesheet**
```css
/*
* Animations for <Placeholder>.
*
* All keyframes are wrapped in a prefers-reduced-motion guard so users who
* opt out of motion see a static bird and an instantly-visible message.
*/
@media (prefers-reduced-motion: no-preference) {
.placeholder-motion-bob {
animation: placeholder-bob 3.4s ease-in-out infinite;
}
.placeholder-motion-flutter {
animation: placeholder-flutter 0.7s ease-in-out infinite;
}
.placeholder-fade-in {
animation: placeholder-fade 1s ease-out 0.3s both;
opacity: 0;
}
}
@keyframes placeholder-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes placeholder-flutter {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(2px, -1px); }
}
@keyframes placeholder-fade {
to { opacity: 1; }
}
```
- [ ] **Step 2: Commit**
```bash
git add web/src/components/Placeholder/Placeholder.css
git commit -m "feat(placeholder): add motion keyframes with reduced-motion guard"
```
---
## Task 5: Placeholder component (TDD)
**Files:**
- Create: `web/src/components/Placeholder/index.tsx`
- Test: `web/tests/placeholder-component.test.tsx`
- [ ] **Step 1: Write the failing component tests**
Create `web/tests/placeholder-component.test.tsx`:
```tsx
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import Placeholder from "@/components/Placeholder";
import { DEFAULT_MESSAGES } from "@/components/Placeholder/messages";
describe("<Placeholder>", () => {
it("renders the default message for variant=empty", () => {
render(<Placeholder variant="empty" />);
expect(screen.getByText(DEFAULT_MESSAGES.empty)).toBeInTheDocument();
});
it("renders the default message for variant=loading", () => {
render(<Placeholder variant="loading" />);
expect(screen.getByText(DEFAULT_MESSAGES.loading)).toBeInTheDocument();
});
it("renders the default message for variant=noResults", () => {
render(<Placeholder variant="noResults" />);
expect(screen.getByText(DEFAULT_MESSAGES.noResults)).toBeInTheDocument();
});
it("renders the default message for variant=notFound", () => {
render(<Placeholder variant="notFound" />);
expect(screen.getByText(DEFAULT_MESSAGES.notFound)).toBeInTheDocument();
});
it("overrides the default message when `message` prop is passed", () => {
render(<Placeholder variant="empty" message="Custom copy goes here" />);
expect(screen.getByText("Custom copy goes here")).toBeInTheDocument();
expect(screen.queryByText(DEFAULT_MESSAGES.empty)).not.toBeInTheDocument();
});
it("renders the ASCII art inside a <pre> with aria-hidden", () => {
const { container } = render(<Placeholder variant="empty" />);
const pre = container.querySelector("pre");
expect(pre).not.toBeNull();
expect(pre).toHaveAttribute("aria-hidden", "true");
expect(pre!.textContent!.length).toBeGreaterThan(0);
});
it("renders a jgs credit string", () => {
render(<Placeholder variant="empty" />);
expect(screen.getByText(/jgs/)).toBeInTheDocument();
});
it('applies role="status" and aria-live="polite" ONLY when variant=loading', () => {
const { rerender, container } = render(<Placeholder variant="empty" />);
expect(container.querySelector('[role="status"]')).toBeNull();
rerender(<Placeholder variant="loading" />);
const live = container.querySelector('[role="status"]');
expect(live).not.toBeNull();
expect(live).toHaveAttribute("aria-live", "polite");
});
it("renders children below the message when provided", () => {
render(
<Placeholder variant="notFound">
<button type="button">Go home</button>
</Placeholder>,
);
expect(screen.getByRole("button", { name: "Go home" })).toBeInTheDocument();
});
it("merges a custom className onto the outer wrapper", () => {
const { container } = render(<Placeholder variant="empty" className="custom-test-class" />);
expect(container.firstChild).toHaveClass("custom-test-class");
});
});
```
- [ ] **Step 2: Run the tests and verify they fail**
```bash
cd web && pnpm test placeholder-component
```
Expected: FAIL — module `@/components/Placeholder` not found.
- [ ] **Step 3: Implement `index.tsx`**
Create `web/src/components/Placeholder/index.tsx`:
```tsx
import { useMemo, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { pickPiece, type MotionStyle, type PlaceholderVariant } from "./ascii-pool";
import { DEFAULT_MESSAGES } from "./messages";
import "./Placeholder.css";
interface PlaceholderProps {
variant: PlaceholderVariant;
message?: string;
children?: ReactNode;
className?: string;
}
const MOTION_CLASS: Record<MotionStyle, string> = {
bob: "placeholder-motion-bob",
flutter: "placeholder-motion-flutter",
none: "",
};
const Placeholder = ({ variant, message, children, className }: PlaceholderProps) => {
// Stable for the lifetime of this mount; re-rolls only if `variant` changes
// (which is rare in practice — most callers pass a constant).
const piece = useMemo(() => pickPiece(variant), [variant]);
const resolvedMessage = message ?? DEFAULT_MESSAGES[variant];
const isLoading = variant === "loading";
return (
<div
role={isLoading ? "status" : undefined}
aria-live={isLoading ? "polite" : undefined}
className={cn("flex flex-col items-center justify-center max-w-md mx-auto px-4 py-8", className)}
>
<pre
aria-hidden="true"
className={cn(
"font-mono text-xs sm:text-sm leading-tight text-muted-foreground whitespace-pre m-0",
MOTION_CLASS[piece.motion],
)}
>
{piece.ascii}
</pre>
<p className="mt-3 font-mono text-sm text-muted-foreground placeholder-fade-in">
{resolvedMessage}
</p>
<p className="mt-1 font-mono text-[10px] text-muted-foreground/60 placeholder-fade-in">
{piece.credit}
</p>
{children && <div className="mt-4">{children}</div>}
</div>
);
};
export default Placeholder;
```
- [ ] **Step 4: Run the tests and verify they pass**
```bash
cd web && pnpm test placeholder-component
```
Expected: PASS — all ten assertions green.
- [ ] **Step 5: Commit**
```bash
git add web/src/components/Placeholder/index.tsx web/tests/placeholder-component.test.tsx
git commit -m "feat(placeholder): implement <Placeholder> with variant-driven ASCII pool"
```
---
## Task 6: Attribution credits
**Files:**
- Create: `web/src/components/Placeholder/CREDITS.md`
- [ ] **Step 1: Create `CREDITS.md`**
```markdown
# ASCII Art Credits
The ASCII bird illustrations rendered by `<Placeholder>` are from **Joan Stark's**
classic ASCII art collection. Each piece is signed with her `jgs` tag and the
month/year it was published.
- Source archive: https://github.com/oldcompcz/jgs (Joan Stark's ASCII Art Gallery)
- Original site (preserved via WebArchive): https://web.archive.org/web/20091028013825/http://www.geocities.com/SoHo/7373/
- Wikipedia: https://en.wikipedia.org/wiki/Joan_Stark
Joan Stark distributed her art freely on Usenet and the early web. We retain
the `jgs` signature visible beneath each piece in the UI so attribution travels
with the art wherever it is shown.
If you add new ASCII pieces to `ascii-pool.ts`:
- Prefer well-attributed art from established collections.
- Keep the original artist signature in the `credit` field (e.g. `"jgs · 4/97"`).
- If using a different artist, link the source in this file.
```
- [ ] **Step 2: Commit**
```bash
git add web/src/components/Placeholder/CREDITS.md
git commit -m "docs(placeholder): credit Joan Stark for ASCII bird art"
```
---
## Task 7: Wire into `Inboxes.tsx` and delete `Empty.tsx`
**Files:**
- Modify: `web/src/pages/Inboxes.tsx`
- Delete: `web/src/components/Empty.tsx`
- [ ] **Step 1: Read the current empty-state block in `Inboxes.tsx`**
Open `web/src/pages/Inboxes.tsx`. The relevant block is at lines 99105:
```tsx
{notifications.length === 0 ? (
<div className="w-full py-16 flex flex-col justify-center items-center">
<Empty />
<p className="mt-4 text-sm text-muted-foreground">
{filter === "unread" ? t("inbox.no-unread") : filter === "archived" ? t("inbox.no-archived") : t("message.no-data")}
</p>
</div>
) : (
```
The outer `<div className="w-full py-16 flex flex-col justify-center items-center">` and the inner `<p>` both become redundant — `<Placeholder>` handles its own centering and message.
- [ ] **Step 2: Swap the import**
In `web/src/pages/Inboxes.tsx`, replace the import line:
```tsx
import Empty from "@/components/Empty";
```
with:
```tsx
import Placeholder from "@/components/Placeholder";
```
- [ ] **Step 3: Replace the empty-state JSX**
Replace lines 99105 (the existing empty-state block above) with:
```tsx
{notifications.length === 0 ? (
<Placeholder
variant="empty"
message={
filter === "unread"
? t("inbox.no-unread")
: filter === "archived"
? t("inbox.no-archived")
: t("message.no-data")
}
/>
) : (
```
(Only the truthy branch of the ternary changes; leave the `: (` start of the falsy branch and everything below it untouched.)
- [ ] **Step 4: Delete `Empty.tsx`**
```bash
git rm web/src/components/Empty.tsx
```
- [ ] **Step 5: Verify nothing else imports `Empty`**
```bash
cd /Users/steven/Projects/usememos/memos && grep -rn 'from "@/components/Empty"\|from "./Empty"\|from "../Empty"' web/src 2>/dev/null
```
Expected: no output. If anything matches, update that file to use `<Placeholder variant="empty" />` before continuing.
- [ ] **Step 6: Commit**
```bash
git add web/src/pages/Inboxes.tsx web/src/components/Empty.tsx
git commit -m "feat(inboxes): use <Placeholder variant=empty> in place of <Empty>"
```
---
## Task 8: Verification — lint, types, tests, build, dev preview
**Files:** none modified (verification only)
- [ ] **Step 1: Run the lint + typecheck**
```bash
cd web && pnpm lint
```
Expected: exits 0. If Biome flags anything (formatting, sort-order, unused imports), run `pnpm lint:fix` and re-run `pnpm lint`. Commit the fix separately if any changes are required:
```bash
git add -p web/src web/tests
git commit -m "chore(placeholder): biome auto-fixes"
```
- [ ] **Step 2: Run the full test suite**
```bash
cd web && pnpm test
```
Expected: all suites green, including `placeholder-pool` (8 assertions) and `placeholder-component` (10 assertions). Existing tests must still pass.
- [ ] **Step 3: Run the production build**
```bash
cd web && pnpm build
```
Expected: exits 0. Confirms TypeScript still compiles end-to-end and the new CSS import is picked up by Vite.
- [ ] **Step 4: Manual visual check — start the dev server**
```bash
cd web && pnpm dev
```
In a browser, navigate to the running URL (Vite prints it). Sign in with any test account, open the **Inbox** page, and confirm:
1. When inbox is empty, the crested-parrot ASCII bird is visible.
2. It bobs gently every ~3.4 seconds.
3. The message text (one of "no unread", "no archived", or "no data") appears below with a soft fade-in.
4. The small `jgs · 4/97` credit is visible below the message.
5. No console errors or warnings.
Stop the dev server with `Ctrl+C`.
- [ ] **Step 5: (Optional) Reduced-motion check**
Open browser DevTools → command menu → "Emulate CSS prefers-reduced-motion: reduce". Reload the inbox empty state. The bird should be **static** and the message should appear instantly (no fade).
- [ ] **Step 6: No-op commit point**
If steps 14 all passed and no further changes were needed, there is nothing to commit. Proceed to the next task.
---
## Task 9: Document the work in the PR body
**Files:** none modified
- [ ] **Step 1: Draft a PR description**
When opening the PR, use a body like:
```markdown
## Summary
- Adds a new `<Placeholder variant="empty | loading | noResults | notFound">` component that renders a hand-curated ASCII bird from a pool-shaped data file, with subtle CSS-only motion that respects `prefers-reduced-motion`.
- Replaces the single-purpose `Empty.tsx` (used in `Inboxes.tsx`) with `<Placeholder variant="empty">`.
- ASCII art is from Joan Stark's (jgs) classic collection — attribution is preserved on every piece and in a co-located `CREDITS.md`.
## Out of scope (follow-up opportunities)
- Wire `<Placeholder variant="noResults">` into the memo search results page.
- Wire `<Placeholder variant="notFound">` into the router 404 catch-all.
- Wire `<Placeholder variant="loading">` into Suspense fallbacks.
- Seed additional ASCII pieces per variant — the pool architecture supports it; just append entries to `ASCII_POOL`.
## Test plan
- [ ] `pnpm lint` clean
- [ ] `pnpm test` green (incl. new `placeholder-pool` and `placeholder-component` suites)
- [ ] `pnpm build` succeeds
- [ ] Inbox empty state shows the ASCII parrot, bobs, and renders the filter-specific message
- [ ] `prefers-reduced-motion: reduce` produces a static bird and an instantly-visible message
```
- [ ] **Step 2: Open the PR** (skip if user prefers to do this themselves)
This step is left as a manual handoff — do not push or open the PR unless the user has explicitly authorized it.
---
## Self-Review Notes
This plan covers the spec's nine sections as follows:
| Spec section | Implemented by |
|---|---|
| Public Component | Task 5 |
| ASCII Pool | Tasks 1, 2 |
| Default Messages | Task 3 |
| Animation | Task 4 (CSS) + Task 5 (component wires classes) |
| Accessibility | Task 5 (test assertions + impl) |
| File Layout | Tasks 16 (all five files created) |
| Integration | Task 7 (Inboxes rewire + Empty delete) |
| Credits | Task 6 |
| Testing | Tasks 1, 3, 5 (pool tests + component tests) |
No spec section is unimplemented. No "TBD" / "TODO" / vague-handwave language is used in any step. Types, method signatures, and class names referenced across tasks match:
- `PlaceholderVariant`, `MotionStyle`, `AsciiPiece`, `ASCII_POOL`, `pickPiece` — consistent across Tasks 1, 2, 3, 5
- `DEFAULT_MESSAGES` — defined in Task 3, consumed in Task 5
- `placeholder-motion-bob`, `placeholder-motion-flutter`, `placeholder-fade-in` — defined in Task 4 CSS, consumed in Task 5 component
- `cn` from `@/lib/utils` — matches existing codebase convention (verified in pre-plan exploration)
- `Placeholder` is a default export — matches the convention used by `Empty.tsx` and most other `web/src/components/*.tsx` files
@@ -0,0 +1,673 @@
# Remove react-use Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove `react-use` from the frontend while preserving current hook behavior and eliminating the transitive `js-cookie@2.2.1` dependency.
**Architecture:** Replace simple one-off `react-use` helpers with native React hooks inside the consuming components. Add two focused local hooks in `web/src/hooks/` for reused debounce behavior and typed localStorage state. Regenerate the pnpm lockfile from `web/package.json` instead of manually editing lockfile entries.
**Tech Stack:** React 19, TypeScript 6, Vite 8, Vitest 4, Testing Library, pnpm 11.
---
## File Structure
- Create `web/src/hooks/useDebouncedEffect.ts`
- Shared debounce hook for effect-style callbacks.
- Create `web/src/hooks/useLocalStorage.ts`
- Typed localStorage state hook for persisted UI preferences.
- Modify `web/src/hooks/index.ts`
- Export the two new hooks for existing `@/hooks` barrel import style.
- Create `web/tests/hooks.test.tsx`
- Unit tests for the two new local hooks.
- Modify `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`
- Replace `react-use` debounce import with local `useDebouncedEffect`.
- Modify `web/src/components/MemoEditor/hooks/useLinkMemo.ts`
- Replace deep `react-use` debounce import with local `useDebouncedEffect`.
- Modify `web/src/components/MemoExplorer/TagsSection.tsx`
- Replace deep `react-use` localStorage import with local `useLocalStorage`.
- Modify `web/src/components/TagTree.tsx`
- Replace `useToggle` with native `useState`.
- Modify `web/src/components/MobileHeader.tsx`
- Replace `useWindowScroll` with native `useState` and `useEffect`.
- Modify `web/src/layouts/RootLayout.tsx`
- Replace `usePrevious` with native `useRef` and `useEffect`.
- Modify `web/package.json`
- Remove the direct `react-use` dependency.
- Modify `web/pnpm-lock.yaml`
- Regenerate through pnpm after `react-use` is removed.
---
### Task 1: Add Failing Hook Tests
**Files:**
- Create: `web/tests/hooks.test.tsx`
- [ ] **Step 1: Write tests for local hook behavior**
Create `web/tests/hooks.test.tsx`:
```tsx
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDebouncedEffect, useLocalStorage } from "@/hooks";
describe("useLocalStorage", () => {
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
window.localStorage.clear();
});
it("uses the default value when storage is empty", () => {
const { result } = renderHook(() => useLocalStorage("hook-test-empty", false));
expect(result.current[0]).toBe(false);
});
it("reads and writes JSON values", () => {
window.localStorage.setItem("hook-test-existing", JSON.stringify(true));
const { result } = renderHook(() => useLocalStorage("hook-test-existing", false));
expect(result.current[0]).toBe(true);
act(() => {
result.current[1](false);
});
expect(result.current[0]).toBe(false);
expect(window.localStorage.getItem("hook-test-existing")).toBe("false");
});
it("supports updater functions", () => {
const { result } = renderHook(() => useLocalStorage("hook-test-updater", false));
act(() => {
result.current[1]((current) => !current);
});
expect(result.current[0]).toBe(true);
expect(window.localStorage.getItem("hook-test-updater")).toBe("true");
});
it("falls back to the default value for malformed storage", () => {
window.localStorage.setItem("hook-test-malformed", "{bad json");
const { result } = renderHook(() => useLocalStorage("hook-test-malformed", true));
expect(result.current[0]).toBe(true);
});
});
describe("useDebouncedEffect", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it("runs the latest callback after the delay", () => {
const calls: string[] = [];
const { rerender } = renderHook(
({ value }) => {
useDebouncedEffect(
() => {
calls.push(value);
},
100,
[value],
);
},
{ initialProps: { value: "first" } },
);
act(() => {
vi.advanceTimersByTime(99);
});
expect(calls).toEqual([]);
rerender({ value: "second" });
act(() => {
vi.advanceTimersByTime(100);
});
expect(calls).toEqual(["second"]);
});
it("clears the pending timeout on unmount", () => {
const calls: string[] = [];
const { unmount } = renderHook(() => {
useDebouncedEffect(
() => {
calls.push("called");
},
100,
[],
);
});
unmount();
act(() => {
vi.advanceTimersByTime(100);
});
expect(calls).toEqual([]);
});
});
```
- [ ] **Step 2: Run tests and verify they fail because hooks do not exist**
Run:
```bash
cd web
pnpm test tests/hooks.test.tsx
```
Expected: FAIL with missing exports for `useDebouncedEffect` and `useLocalStorage` from `@/hooks`.
- [ ] **Step 3: Commit failing tests**
```bash
git add web/tests/hooks.test.tsx
git commit -m "test: cover local frontend hooks"
```
---
### Task 2: Implement Local Shared Hooks
**Files:**
- Create: `web/src/hooks/useDebouncedEffect.ts`
- Create: `web/src/hooks/useLocalStorage.ts`
- Modify: `web/src/hooks/index.ts`
- Test: `web/tests/hooks.test.tsx`
- [ ] **Step 1: Add `useDebouncedEffect`**
Create `web/src/hooks/useDebouncedEffect.ts`:
```ts
import { type DependencyList, useEffect } from "react";
export const useDebouncedEffect = (effect: () => void | Promise<void>, delay: number, deps: DependencyList): void => {
useEffect(() => {
const timeout = window.setTimeout(() => {
void effect();
}, delay);
return () => {
window.clearTimeout(timeout);
};
}, [delay, ...deps]);
};
```
- [ ] **Step 2: Add `useLocalStorage`**
Create `web/src/hooks/useLocalStorage.ts`:
```ts
import { useCallback, useEffect, useState } from "react";
type SetLocalStorageValue<T> = T | ((currentValue: T) => T);
const readLocalStorageValue = <T>(key: string, defaultValue: T): T => {
if (typeof window === "undefined") {
return defaultValue;
}
try {
const storedValue = window.localStorage.getItem(key);
return storedValue === null ? defaultValue : (JSON.parse(storedValue) as T);
} catch {
return defaultValue;
}
};
export const useLocalStorage = <T>(key: string, defaultValue: T): [T, (value: SetLocalStorageValue<T>) => void] => {
const [storedValue, setStoredValue] = useState<T>(() => readLocalStorageValue(key, defaultValue));
useEffect(() => {
setStoredValue(readLocalStorageValue(key, defaultValue));
}, [key, defaultValue]);
const setValue = useCallback(
(value: SetLocalStorageValue<T>) => {
setStoredValue((currentValue) => {
const nextValue = typeof value === "function" ? (value as (currentValue: T) => T)(currentValue) : value;
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(key, JSON.stringify(nextValue));
} catch {
// Keep React state updated even if persistence is unavailable.
}
}
return nextValue;
});
},
[key],
);
return [storedValue, setValue];
};
```
- [ ] **Step 3: Export hooks from the barrel**
Modify `web/src/hooks/index.ts` to include:
```ts
export * from "./useAsyncEffect";
export * from "./useCurrentUser";
export * from "./useDateFilterNavigation";
export * from "./useDebouncedEffect";
export * from "./useFilteredMemoStats";
export * from "./useLoading";
export * from "./useLocalStorage";
export * from "./useMediaQuery";
export * from "./useMemoFilters";
export * from "./useMemoSorting";
export * from "./useNavigateTo";
export * from "./useUserLocale";
export * from "./useUserTheme";
```
- [ ] **Step 4: Run hook tests and verify they pass**
Run:
```bash
cd web
pnpm test tests/hooks.test.tsx
```
Expected: PASS for all `useLocalStorage` and `useDebouncedEffect` tests.
- [ ] **Step 5: Commit shared hooks**
```bash
git add web/src/hooks/useDebouncedEffect.ts web/src/hooks/useLocalStorage.ts web/src/hooks/index.ts web/tests/hooks.test.tsx
git commit -m "feat: add local frontend hooks"
```
---
### Task 3: Replace Simple react-use Helpers With React Hooks
**Files:**
- Modify: `web/src/components/TagTree.tsx`
- Modify: `web/src/components/MobileHeader.tsx`
- Modify: `web/src/layouts/RootLayout.tsx`
- [ ] **Step 1: Replace `useToggle` in `TagTree`**
In `web/src/components/TagTree.tsx`, change the imports:
```ts
import { ChevronRightIcon, HashIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
```
Replace the toggle state in `TagItemContainer` with:
```ts
const [showSubTags, setShowSubTags] = useState(false);
useEffect(() => {
setShowSubTags(expandSubTags);
}, [expandSubTags]);
```
Replace `handleToggleBtnClick` with:
```ts
const handleToggleBtnClick = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
setShowSubTags((current) => !current);
}, []);
```
- [ ] **Step 2: Replace `useWindowScroll` in `MobileHeader`**
In `web/src/components/MobileHeader.tsx`, replace the first import with React hooks:
```ts
import { useEffect, useState } from "react";
import useMediaQuery from "@/hooks/useMediaQuery";
import { cn } from "@/lib/utils";
import NavigationDrawer from "./NavigationDrawer";
```
Inside `MobileHeader`, replace `const { y: offsetTop } = useWindowScroll();` with:
```ts
const [offsetTop, setOffsetTop] = useState(() => {
if (typeof window === "undefined") return 0;
return window.scrollY;
});
useEffect(() => {
const handleScroll = () => {
setOffsetTop(window.scrollY);
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
```
- [ ] **Step 3: Replace `usePrevious` in `RootLayout`**
In `web/src/layouts/RootLayout.tsx`, change the React import and remove the `react-use` import:
```ts
import { useEffect, useRef } from "react";
import { Outlet, useLocation, useSearchParams } from "react-router-dom";
```
Replace:
```ts
const prevPathname = usePrevious(pathname);
```
with:
```ts
const prevPathnameRef = useRef<string | undefined>(undefined);
```
Replace the route filter clearing effect with:
```ts
useEffect(() => {
const prevPathname = prevPathnameRef.current;
// When the route changes and there is no filter in the search params, remove all filters.
if (prevPathname !== undefined && prevPathname !== pathname && !searchParams.has("filter")) {
removeFilter(() => true);
}
prevPathnameRef.current = pathname;
}, [pathname, searchParams, removeFilter]);
```
- [ ] **Step 4: Run TypeScript/lint check**
Run:
```bash
cd web
pnpm lint
```
Expected: PASS.
- [ ] **Step 5: Commit simple helper replacements**
```bash
git add web/src/components/TagTree.tsx web/src/components/MobileHeader.tsx web/src/layouts/RootLayout.tsx
git commit -m "refactor: replace simple react-use helpers"
```
---
### Task 4: Replace Debounce And LocalStorage Call Sites
**Files:**
- Modify: `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`
- Modify: `web/src/components/MemoEditor/hooks/useLinkMemo.ts`
- Modify: `web/src/components/MemoExplorer/TagsSection.tsx`
- Test: `web/tests/hooks.test.tsx`
- [ ] **Step 1: Update `InsertMenu` debounce import and call**
In `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`, remove:
```ts
import { useDebounce } from "react-use";
```
Add:
```ts
import { useDebouncedEffect } from "@/hooks";
```
Replace:
```ts
useDebounce(
() => {
setDebouncedPosition(locationState.position);
},
1000,
[locationState.position],
);
```
with:
```ts
useDebouncedEffect(
() => {
setDebouncedPosition(locationState.position);
},
1000,
[locationState.position],
);
```
- [ ] **Step 2: Update `useLinkMemo` debounce import and call**
In `web/src/components/MemoEditor/hooks/useLinkMemo.ts`, remove:
```ts
import useDebounce from "react-use/lib/useDebounce";
```
Add:
```ts
import { useDebouncedEffect } from "@/hooks";
```
Replace:
```ts
useDebounce(
```
with:
```ts
useDebouncedEffect(
```
Keep the existing async callback body, `300` delay, and `[isOpen, searchText]` dependency list unchanged.
- [ ] **Step 3: Update `TagsSection` localStorage import**
In `web/src/components/MemoExplorer/TagsSection.tsx`, replace:
```ts
import useLocalStorage from "react-use/lib/useLocalStorage";
```
with:
```ts
import { useLocalStorage } from "@/hooks";
```
Keep both call sites unchanged:
```ts
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", false);
```
- [ ] **Step 4: Verify no source imports from `react-use` remain**
Run:
```bash
rg -n 'react-use' web/src web/package.json
```
Expected: only `web/package.json` still reports `react-use` before the dependency removal task.
- [ ] **Step 5: Run targeted hook tests and lint**
Run:
```bash
cd web
pnpm test tests/hooks.test.tsx
pnpm lint
```
Expected: both commands PASS.
- [ ] **Step 6: Commit call-site replacements**
```bash
git add web/src/components/MemoEditor/Toolbar/InsertMenu.tsx web/src/components/MemoEditor/hooks/useLinkMemo.ts web/src/components/MemoExplorer/TagsSection.tsx web/tests/hooks.test.tsx
git commit -m "refactor: use local debounce and storage hooks"
```
---
### Task 5: Remove react-use Dependency And Regenerate Lockfile
**Files:**
- Modify: `web/package.json`
- Modify: `web/pnpm-lock.yaml`
- [ ] **Step 1: Remove `react-use` from `web/package.json`**
In `web/package.json`, remove this dependency line:
```json
"react-use": "^17.6.0",
```
Do not add `js-cookie` as a direct dependency.
- [ ] **Step 2: Regenerate the lockfile**
Run:
```bash
cd web
pnpm install --lockfile-only
```
Expected: command succeeds and updates `web/pnpm-lock.yaml`.
- [ ] **Step 3: Verify dependency graph removal**
Run:
```bash
cd web
pnpm why react-use js-cookie
```
Expected: output does not list installed versions for `react-use` or `js-cookie`.
- [ ] **Step 4: Verify repository text references**
Run:
```bash
rg -n '"react-use"|react-use/lib|react-use@17\.6\.0|^\s+react-use:|js-cookie' web/package.json web/pnpm-lock.yaml web/src
```
Expected: no matches.
- [ ] **Step 5: Commit dependency removal**
```bash
git add web/package.json web/pnpm-lock.yaml
git commit -m "chore: remove react-use dependency"
```
---
### Task 6: Final Verification
**Files:**
- Verify: all files changed by Tasks 1-5
- [ ] **Step 1: Run frontend tests for the new hook coverage**
Run:
```bash
cd web
pnpm test tests/hooks.test.tsx
```
Expected: PASS.
- [ ] **Step 2: Run frontend lint**
Run:
```bash
cd web
pnpm lint
```
Expected: PASS.
- [ ] **Step 3: Run frontend build**
Run:
```bash
cd web
pnpm build
```
Expected: PASS.
- [ ] **Step 4: Confirm no removed dependency remains**
Run:
```bash
cd web
pnpm why react-use js-cookie
rg -n '"react-use"|react-use/lib|react-use@17\.6\.0|^\s+react-use:|js-cookie' src package.json pnpm-lock.yaml
```
Expected: no `react-use` or `js-cookie` dependency remains. The `rg` command returns no matches.
- [ ] **Step 5: Inspect final diff**
Run:
```bash
git diff --stat HEAD~5..HEAD
git status --short
```
Expected: diff contains only hook tests, local hooks, six call-site rewrites, and dependency files. Working tree is clean after all task commits.