# Plans Library — Design **Date:** 2026-06-17 **Status:** Approved (brainstorming) → ready for implementation plan **Realizes:** [ADR-0002](../../adr/0002-local-first-no-backend.md) — _"The live store is localStorage (an in-app library of named Macroplans); the `.toml` file is the portable source of truth, moved in and out via Import/Export."_ ## Problem The app stores exactly one Macroplan, autosaved to a single localStorage key (`macroplan:source`). ADR-0002 always described localStorage as **a library of named Macroplans**, but only the single-plan slice was built. The README advertised a "library of plans" the app never had. This adds the library: keep several named Macroplans, switch between them, create and delete them, and download any plan as a portable `.toml` file. The `.toml` download is in scope because ADR-0002 makes export the durability mechanism ("durability rests on Export"); a library of N plans in localStorage with no file export would multiply un-backupable state. ## Scope **In:** - Multiple named Macroplans persisted in localStorage. - A header dropdown to switch the active plan. - Create a new plan (a blank page; the bundled sample only seeds a genuinely empty library). - Delete the active plan (with a confirm). - Download the active plan's source as `.toml`. - Migration from the legacy single-key store. **Out (YAGNI for v1):** - Duplicate / clone a plan (fork via _New_ + paste). - File-open `.toml` _import_ — pasting a `.toml` file's contents into a new plan's editor already covers loading. Re-evaluate if it proves clumsy. - Reordering, folders, search, tags. - Any backend or multi-device sync (explicitly rejected by ADR-0002). ## Vocabulary A new term enters the [ubiquitous language](../../../CONTEXT.md): > **Library** — the collection of saved **Macroplans** held in the browser's localStorage. The live store; durability rests on exporting a plan's `.toml` (per ADR-0002). Not a Macroplan itself, and carries no status of its own. A plan's **display name** is its TOML `title` (the existing concept), not a separate field — see Data Model. ## Data Model One new localStorage key **replaces** `macroplan:source`: ``` macroplan:library → { version: 1, activeId: string, plans: [ { id: string, name: string, source: string } ] } ``` | Field | Meaning | | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | Stable identity from `crypto.randomUUID()`. The switcher and `activeId` key off this, **never** the name — so a plan survives title edits and broken TOML. | | `name` | The **cached last-good title** for that plan. Lets the switcher label every plan without parsing all of their sources on every render — only the active plan is parsed live. | | `source` | The TOML string the editor binds to. | **Why a cached name and not a live-derived one:** only the active plan's source is parsed continuously (for the live render). Inactive plans show their last-good title from `name`. The active plan's `name` is refreshed whenever its source parses to a title; a transient parse error leaves the last-good `name` (and the last-good render) in place — same forgiving behavior as today's F3. ### Invariant **The library always holds ≥ 1 plan.** Deleting the last plan creates a fresh sample plan rather than leaving an empty store. This keeps `activeId` always valid and the editor always bound to something. ### Migration On load, `useMacroplan` resolves the initial library: 1. If `macroplan:library` exists and parses → use it. 2. Else if the legacy `macroplan:source` exists → wrap that source as a single plan (`name` = its parsed title, or `"Untitled"` if it doesn't parse), mark it active, write `macroplan:library`, and **remove** the legacy key. 3. Else → seed one sample plan, active. A corrupt/unparseable `macroplan:library` value is treated as case 3 (seed fresh) rather than throwing — autosave is best-effort, consistent with the existing try/catch around `localStorage.setItem`. ## Architecture ### `useMacroplan` (refactor) — the library owner Holds `plans` and `activeId`; persists the whole library to `macroplan:library` on any change (best-effort, in a try/catch as today). Exposes: | Member | Type | Notes | | ---------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `source` | writable `Ref` | Reads/writes the **active** plan's `source`. The editor's `v-model` binds here unchanged. Writing it refreshes the active plan's cached `name` when the new source parses to a title. | | `plan` | `ComputedRef` | Active plan's derived model, falling back to its `lastGood` (unchanged behavior). | | `error` | `ComputedRef` | Active plan's current parse error. | | `plans` | `ComputedRef<{ id, name }[]>` | For the switcher. | | `activeId` | `Ref` | Current selection. | | `newPlan()` | → `void` | Append a **blank** plan and switch to it. The sample is reserved for a genuinely empty library (first run / delete-the-last), which the ≥1-plan invariant means `newPlan` never sees. | | `deletePlan(id)` | → `void` | Remove; if it was active, re-point `activeId` to the **preceding** plan in the list (or the new first plan if it was at index 0); if it was the last remaining plan, seed a fresh sample (the invariant). | | `selectPlan(id)` | → `void` | Switch the active plan and **reset `lastGood`** so a broken target never shows the previous plan's render. | `resetToSample` is **removed** — `newPlan()` (a blank page) replaces it. ### `PlanSwitcher.vue` (new) — presentational - **Props:** `plans: { id: string; name: string }[]`, `activeId: string`. - **Emits:** `select(id)`, `new`, `delete(id)`, `download(id)`. - Renders a DaisyUI dropdown: the active plan's name as the trigger; a list of plans (active one checked); then `+ New plan` and `⤓ Download .toml` entries. - No storage or parsing logic — it only reflects props and emits intent. ### `downloadSource` + `slugify` (new util) — `.toml` export - Refactor `exportFilename` (in `usePngExport.ts`) to build on an extracted `slugify(title): string` helper, so PNG and `.toml` names stay consistent: - `exportFilename(title)` → `macroplan-.png` (unchanged output; existing test still passes). - `sourceFilename(title)` → `macroplan-.toml`. - `downloadSource(source: string, filename: string)`: `Blob(['…'], { type: 'text/plain' })` → object URL → anchor click → revoke. Mirrors the existing `downloadPng` mechanics. Lives in a small util (e.g. `src/composables/useSourceExport.ts`) — kept separate from `usePngExport` since it's source export, not render export. ### App shell (`App.vue`) - The static `

Macroplan

` + subtitle block in the header is **replaced** by `` (logo kept). - A **trash** button is added to the top bar; it deletes the **active** plan behind a confirm modal (`Delete ""?` · Cancel / Delete). DaisyUI modal or ``. - `Copy PNG` and `Download` (PNG) buttons are **unchanged** — `.toml` download lives in the switcher dropdown precisely so there is no competing "Download" label in the top bar. - Wire `PlanSwitcher` events: `select → selectPlan`, `new → newPlan`, `delete → open confirm → deletePlan`, `download → downloadSource(source, sourceFilename(plan.title))`. ## Data Flow ``` edit TOML ─► source (active plan) ─► autosave whole library ─► localStorage │ └─► parse ─► plan/error ─► refresh active plan's cached name (on valid title) switch ─► selectPlan(id) ─► activeId changes ─► source rebinds, lastGood reset ─► re-parse ─► render new ─► newPlan() ─► append blank plan ─► selectPlan(newId) delete ─► confirm ─► deletePlan(activeId) ─► re-point active to preceding plan (or re-seed if last) ─► render toml ─► downloadSource(source, sourceFilename(title)) ─► browser download ``` ## Error Handling - **localStorage write fails** (full/blocked): swallowed in try/catch; the in-memory library stays usable for the session — same posture as today's autosave. - **Corrupt `macroplan:library` on read:** fall back to seeding a fresh sample (don't throw). - **Active plan source unparseable:** the grid keeps the last-good render and shows the error in the editor — existing behavior, now per active plan. - **`crypto.randomUUID` unavailable:** target is modern browsers (the app already uses Clipboard API, `color-mix`, dynamic import); no fallback needed. Note in the plan if a wider support floor is required. ## Testing (Vitest, matching the existing suite) **`useMacroplan` / library model:** - No storage → library seeds one sample plan, active. - Legacy `macroplan:source` present → migrates to a one-plan library (name = parsed title), legacy key removed. - Corrupt `macroplan:library` → seeds fresh sample (no throw). - `newPlan()` appends a **blank** plan and makes it active (the sample only seeds an empty library). - `deletePlan(active)` removes it and re-points `activeId` to a remaining plan. - `deletePlan` of the **last** plan re-seeds a fresh sample (invariant holds). - Editing `source` updates the active entry and refreshes its cached `name` on a valid title; a broken edit keeps the last-good `name` and render. - `selectPlan` swaps `source`/`plan`/`error` and resets `lastGood` (broken target shows its own error, not the prior render). **Filename util:** - `sourceFilename(title)` slug mirrors the existing `exportFilename` test (e.g. `"Q3 Delivery"` → `macroplan-q3-delivery.toml`); empty/garbage title → `macroplan-plan.toml`. **Component (`@vue/test-utils`):** - `PlanSwitcher` renders the plan names, marks the active one, and emits `select` / `new` / `delete` / `download` on the right interactions. ## Docs to update (in the implementation plan) - **`CONTEXT.md`** — add the **Library** glossary entry above. - **`README.md`** — flip the _"Not yet built: a library …"_ note now that it exists; the _How it works_ localStorage bullet can mention named plans + `.toml` export. ## Open questions None blocking. Possible future follow-ups (out of scope): `.toml` file-open import, duplicate-plan, and reordering.