# /l-migrate-to-preset-style

Migrate an existing generated project to the **minimal-scaffold** shape (epic
zudolab/zudo-doc#2651): a single `zfb.config.ts` (`zudoDoc({...})`), a
handful of unavoidable root files, and markdown content — everything else
(chrome, islands, layout, default `:root` tokens, even the doc ROUTES) ships
from `@takazudo/zudo-doc` in `node_modules`. This is the v2 destination shape
for what used to be called "preset-style" (the flat-config → `zudoDocPreset()`
migration, Package-First Finale epic #2356) — the skill keeps its original
name (`l-migrate-to-preset-style`) because other tooling references it by
name (the #2664 doc-skill child), but the target shape it migrates TOWARD has
moved further, to the locked ~12-file manifest.

The migration is **human-gated**: auto-delete is permitted only for files
that are (a) byte-identical to the pinned template baseline, or (b) match a
KNOWN deleted-from-template legacy path with no eject-CLI counterpart (see
Step 3). Any edited or ambiguous file pauses for explicit human confirmation
before touching it.

> **Shaped by `l-lessons-zfb-migration-parity`:** a dead island silently
> server-renders and the build still exits 0, so byte-comparison alone is
> insufficient proof that an island migration succeeded. Every
> island-touching step is gated behind a hydration smoke test.

## Preconditions

Verify ALL of the following before proceeding. Stop and tell the user if any fails.

1. The target project was generated by `create-zudo-doc` (a `package.json`
   exists at the project root, with a `@takazudo/zudo-doc` dependency).
2. The working tree is clean (`git status --porcelain` returns empty). If not, commit or
   stash first — the migration writes files and a dirty tree makes audit ambiguous.
3. Identify the pinned `@takazudo/zudo-doc` version from `package.json`. This
   skill always diffs against **today's** `packages/create-zudo-doc/templates/base/`
   and `templates/features/*/files/` in THIS repo (the current minimal
   5-file base template) — not a version-matched historical template. If the
   target project's pinned version predates the minimal-scaffold cutover
   (epic #2651) by a lot, expect a large Step 1 diff; that is the point of
   this migration, not an error condition.
4. **`--dry-run`** — when passed, run Steps 1–3 (diff + classify + report) but apply no
   mutations, perform no hydration checks, and print the plan without executing it.

## Step 0 — Detect the starting shape

Read the target project's `zfb.config.ts` (or, for a pre-#2657 project,
`src/config/settings.ts`) to determine which generation it's on. This
decides which later steps apply:

| Starting shape | Signal | Steps needed |
|---|---|---|
| **Already minimal** (`zudoDoc({...})` in `zfb.config.ts`, no `src/config/settings.ts`) | `zfb.config.ts` imports `zudoDoc` from `@takazudo/zudo-doc/config` | Steps 1–3, 6–8 (config already merged) |
| **Preset-style** (`zudoDocPreset({...})` spread inline in `zfb.config.ts`, `src/config/settings.ts` still the real settings object) | `zfb.config.ts` imports `zudoDocPreset` from `@takazudo/zudo-doc/preset` | All steps, including Step 4 (config-merge) |
| **Legacy flat-config** (hand-rolled `defineConfig({...})` with inline plugin wiring, `src/config/settings.ts`) | No `zudoDocPreset`/`zudoDoc` import at all | All steps — expect the largest Step 1 diff |

## Step 1 — Template-baseline diff

Use **`scripts/check-template-drift.sh`** (from the zudo-doc repo) as the canonical diff
mechanism. The script compares `templates/base/` (today, 5 files:
`pages/index.tsx`, `pages/docs/[[...slug]].tsx`, `src/styles/global.css`,
`tsconfig.json`, `scripts/setup-doc-skill.sh`) and `templates/features/*/files/`
against the target project, respecting `.template-drift-allowlist`. It reports
three categories:

- **`[DIFF]`** — file differs from the template (may have local edits)
- **`[MISSING IN PROD]`** — template file has no counterpart in the target project
- **`[USE-CLIENT DRIFT]`** — `"use client"` directive mismatch between template and host

Run from the **target project root**, pointing `ROOT_DIR` at it:

```bash
ROOT_DIR=<target-project-root> bash <path-to-zudo-doc>/scripts/check-template-drift.sh
```

Capture the full output. This only tells you about files the CURRENT
template still has an opinion on — because the template shrank from ~64
files to ~5, most of an older project's files have NO current template
counterpart at all, so this script won't mention them. **Step 1b below finds
those.**

### Step 1b — Deleted-from-template scan (the bulk of an older project's diff)

Scan the target project for paths matching the epic's known deleted-file
families (mirrors `packages/create-zudo-doc/src/__tests__/scaffold.test.ts`'s
`NEVER_RESURRECTED` table — that test file is the authoritative, CI-enforced
version of this list):

```bash
cd <target-project-root>
find pages/lib src/components src/utils src/types src/config \
  zfb-shim.d.ts .htmlvalidate.json .zfb/doc-history-meta.json \
  scripts/run-b4push.sh src/content.config.ts \
  -type f 2>/dev/null
```

Every match is a **deleted-from-template legacy file** — the behavior it
implemented is now entirely package-owned (chrome, islands, settings types,
z-index codegen, html-validate, etc.). See the epic's Wave 6 completion
comment (#2660) for the full rationale per family.

**One documented exception**: if the target project selected `tagGovernance`,
it legitimately has a narrow `src/config/settings.ts` + `src/config/tag-vocabulary.ts`
pair (a tiny audit-only mirror the package's `tags-audit` bin still imports
by path — see `src/features/tag-governance.ts`'s header comment). Do NOT
flag these two specific files if they match that narrow shape (a small
object literal with `docsDir`/`tagGovernance`/`tagVocabulary`/`locales` only,
or a `TagVocabularyEntry[]` array) — they are current, not legacy. If
`src/config/settings.ts` is instead the OLD full project-wide settings
object (dozens of fields), it IS legacy — route it to Step 4 (config-merge),
not Step 3 (delete).

## Step 2 — Classify each drifted / legacy file

For every file flagged by Step 1 or Step 1b, classify it into one of four categories:

| Category | Criterion | Action |
|---|---|---|
| **A — byte-identical to current template** | `diff -q` against the matching `templates/base/` or `templates/features/*/files/` counterpart returns 0 | Safe to auto-delete (no local edits) |
| **D — deleted-from-template legacy file** | Matches a Step 1b path family AND has no current template counterpart at all | Default action: **DELETE** (not eject — see Step 3) |
| **B — edited** | File exists in both old and new shape but content differs, OR is a deleted-from-template file with clearly customized content | Pause → human confirmation → default depends on eject-mapping (see Step 3) |
| **C — missing in prod** | Current template file has no counterpart in the project | Usually means it should be added; pause → confirm |

**Byte-identical check** (the definitive test for Category A):

```bash
diff -q <template-file> <target-file>
# exit 0 → identical (Category A)
# exit 1 → differs (Category B)
```

Build three lists — `AUTO_DELETE` (A + D), `NEEDS_CONFIRMATION` (B + C), and
`CONFIG_MERGE` (a full legacy `src/config/settings.ts`, routed to Step 4) —
before taking any action.

## Step 3 — Delete Category A + D files

> **This is the flipped default from v1 of this skill.** The v1 (preset-first,
> #2356-era) migration defaulted an edited/ambiguous file to **eject**
> (`zudo-doc eject <component>`) because the old template's host stubs
> (`pages/lib/_header-with-defaults.tsx`, `src/components/*.tsx`, …) were
> genuinely swappable for an ejected package copy at the SAME layer. In the
> minimal-scaffold shape those host stubs don't exist as a concept anymore —
> the package owns chrome/routes/islands outright via `createChrome()` and
> `packageOwnedRoutes`, and there is **no eject target that corresponds to a
> deleted host wiring file** (the `EJECTABLE` map below is for
> PRESENTATIONAL components — header, footer, toc, individual islands — a
> different and still-valid axis of customization). So a Category D file's
> default action is **DELETE**, not eject.

Category A (byte-identical to the current template) and Category D
(deleted-from-template legacy file, unmodified) are both safe to remove
outright:

```bash
for f in "${AUTO_DELETE[@]}"; do
  rm "$f"
  git rm "$f"          # stage the removal
done
```

Report each deleted path so the user has a clear audit trail, grouped by
family (pages/lib/*, src/components/*, src/utils/*, src/types/*,
src/config/*, standalone files) so the report reads like the Wave 6
completion comment's delete-list rather than a flat file dump.

> **Do NOT auto-delete `"use client"` island components** even if they are
> byte-identical to a deleted-from-template stub. Islands that are
> byte-identical to the old template may still be relied upon for hydration
> wiring the package's `createChrome()` doesn't yet auto-default (check
> `routes/_chrome.tsx`'s header comment for which islands ARE
> auto-defaulted — currently `DesignTokenPanelBootstrap`; `DocHistory` is
> NOT, and needs the `chromeBindingsModule` seam). Move every island
> component to `NEEDS_CONFIRMATION` regardless of diff result — the
> hydration smoke gate in Step 6 is mandatory before their removal can be
> declared safe.

> **Do NOT delete `src/config/settings.ts` here even if it looks legacy.**
> Route it to Step 4 (config-merge) instead — deleting it before merging its
> fields into `zfb.config.ts` silently reverts every non-default setting to
> the package default.

## Step 4 — Config-merge: `settings.ts` → `zfb.config.ts`'s `zudoDoc({...})`

Skip this step if Step 0 detected the project is already on the minimal
shape. Otherwise, the target project's real configuration currently lives in
`src/config/settings.ts` (a plain object) and is either spread into an
inline `zudoDocPreset({...})` call or hand-wired field-by-field into
`defineConfig({...})`. Migrate it to the single-entry `zudoDoc()` API
(`@takazudo/zudo-doc/config`).

### 4a. Simple path (recommended default) — keep `settings.ts`, wrap it

This is the SAME pattern this repo's own showcase uses (see the root
`zfb.config.ts` and `src/config/settings.ts`) — the lowest-risk migration,
since it changes zero field values, only the wiring:

```ts
// zfb.config.ts
import { defineConfig } from "zfb/config";
import { zudoDoc } from "@takazudo/zudo-doc/config";
import { settings } from "./src/config/settings";

export default defineConfig(
  zudoDoc({
    ...settings,
    // If the project has custom host bindings (search widget, doc-history,
    // frontmatter renderers, footer tag loader, MDX component overrides —
    // see Step 4c below), thread them here:
    // chromeBindingsModule: "./src/chrome-bindings",
  }),
);
```

Delete any now-redundant escape-hatch imports the old config wired manually
(`buildDocsSchema`, `colorSchemes`, `translations`, `directives`,
`tagVocabularyEntries`) — pass them as `zudoDoc()` fields instead if the
project overrides any of them (see `packages/zudo-doc/src/config.ts`'s
`ZudoDocConfig` for the full field list and each field's `@default`).

### 4b. Deeper cleanup (optional) — inline as diff-from-defaults literals

A fresh `create-zudo-doc` scaffold emits ONLY the fields that differ from
`ZudoDocConfig`'s documented defaults, directly as `zudoDoc({...})` literals
— no `settings.ts` file at all. A project that wants to fully match that
shape can flatten `settings.ts`'s fields into `zfb.config.ts` and delete the
file, but this is optional cosmetic cleanup, not required for the migration
to succeed — do it only if the user asks for it, since it's a larger diff
with no functional benefit over 4a.

### 4c. Custom host bindings (only if the project has any)

If the OLD project had real host-bound behavior (a search widget, doc
history with real git data, custom frontmatter-preview renderers, a footer
tag loader, custom MDX component overrides) wired through its own
`pages/lib/_chrome.ts`-style module, that behavior needs a
`chromeBindingsModule` — a project-root-relative module exporting
`chromeBindings: ChromeHostBindings`. Use this repo's own
`src/chrome-bindings.tsx` as the reference shape (it implements every
`ChromeHostBindings` slot the showcase needs) and
`packages/zudo-doc/docs/adr/route-injection-seam.md`'s "Host-callables
channel — chromeBindingsModule" section for the contract. If the project has
NO custom host bindings, skip this — the package's own stubs (empty
search results, no-op DocHistory, etc.) cover a plain scaffold fine.

## Step 5 — tsconfig swap to the extends form

Replace the target project's hand-rolled `tsconfig.json` with the extends
form (see `packages/zudo-doc/CLAUDE.md`'s "Shipped ambient type shims + tsconfig
base" section for the full contract):

```jsonc
{
  "extends": "@takazudo/zudo-doc/tsconfig.base.json",
  "include": ["src", "pages", "zfb.config.ts"],
  "compilerOptions": {
    // No baseUrl (TypeScript 7 rejects it with TS5102 — #4558): an explicitly
    // relative target resolves against THIS file's directory. See
    // packages/zudo-doc/CLAUDE.md "Owned JSX and project-local aliases".
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}
```

**Two traps, both load-bearing:**

- Do NOT declare a top-level `files` field in the project tsconfig — `files`
  is override-only across `extends`, and the base's `files` (which pulls in
  the two ambient shims, `zfb-config-shim.d.ts` + `virtual-modules.d.ts`) would
  be silently dropped, surfacing later as confusing `TS2307` errors on
  `zfb/config` / `virtual:*` imports.
- Delete the project's own `zfb-shim.d.ts` if it still has one — the ambient
  `zfb/config` types now ship from `@takazudo/zudo-doc/zfb-config-shim.d.ts`,
  pulled in transitively via the base's `files`.

After swapping, run `pnpm check` and fix any newly-surfaced type errors
before continuing — this step commonly surfaces stale imports Step 3 missed.

## Step 6 — Hydration smoke gate (mandatory for every island-touching change)

> **Critical invariant (per `l-lessons-zfb-migration-parity`):** a dead island
> server-renders silently. The zfb build exits 0 and emits valid HTML even when an island
> component loses its `"use client"` directive or its registration is broken. Byte-identical
> deletion is insufficient proof that islands still hydrate. This gate is
> **not optional** — skip it only when `--skip-hydration-check` was explicitly passed.

After completing Steps 3–5, run the hydration smoke check:

```bash
pnpm build
```

Then verify island hydration for each component that was deleted or whose
wiring changed. The minimal check is a grep for the island marker in the built HTML:

```bash
# Each interactive island emits a data-zfb-island="<Name>" marker.
grep -roh 'data-zfb-island="[^"]*"' dist/ | sort -u    # distinct island set
grep -rl  'data-zfb-island' dist/ | wc -l              # total island-bearing pages
```

Compare the distinct-island set before vs after (capture a baseline with
`git stash` + build BEFORE Step 3 if you didn't already) — a missing island
name means a silently dead island. `DocHistory` is the highest-risk case:
its package-default is a deliberate no-op stub (unlike
`DesignTokenPanelBootstrap`, which auto-defaults) — if the project had
`docHistory` enabled and Step 4 didn't wire a `chromeBindingsModule`
threading the real `DocHistory` component, the History button silently stops
hydrating with real data even though the build stays green.

For any island component touched in Steps 3–5, also verify the component is
reachable in the dev server and interactive — a static HTML render is not
the same as a hydrated island. Use `pnpm dev` + a quick manual check, or
invoke `/verify-ui` for a computed-style smoke:

```bash
pnpm dev &
# Then check the component interacts as expected.
```

**If any island fails the hydration check:**

1. Stop immediately — do not proceed to Step 7.
2. Report which component is dead (compare the distinct-island set: `grep -roh 'data-zfb-island="[^"]*"' dist/ | sort -u`).
3. Offer to restore the file from git (`git checkout -- <path>`) or wire a `chromeBindingsModule` (Step 4c).

## Step 7 — Verify build and types

After all mutations, run a full build + typecheck to confirm nothing is broken:

```bash
pnpm build
pnpm check
```

Fix any TypeScript errors before committing. The most common post-migration errors are:

- Import path still references a deleted host file (`pages/lib/_chrome.ts`,
  `src/config/settings-types.ts`, …) — the minimal shape resolves the
  equivalent type/behavior through `@takazudo/zudo-doc` subpaths instead.
- A `"use client"` directive is missing on a component still wired through
  a `chromeBindingsModule` slot.
- `zfb.config.ts` field errors — cross-check against `ZudoDocConfig` in
  `packages/zudo-doc/src/config.ts` (every field has a `@default` JSDoc).

## Step 8 — Emit summary report

After Steps 3–7 complete (or at any early stop), print a structured summary:

```
## Migration Summary

### Starting shape (Step 0)
Legacy flat-config / Preset-style / Already minimal

### Deleted — Category A (byte-identical to current template)
- <path>
- ...

### Deleted — Category D (deleted-from-template legacy file, no eject target)
- pages/lib/_header-with-defaults.tsx
- src/components/theme-toggle.tsx
- ...

### Kept as-is / ejected (user chose to retain ownership of an edited file)
- src/components/custom-header.tsx (kept)
- theme-toggle → src/components/zudo-doc/theme-toggle/ (ejected)
- ...

### Config merge (Step 4)
- src/config/settings.ts → spread into zfb.config.ts's zudoDoc({...}) (4a)
- chromeBindingsModule wired: src/chrome-bindings.tsx (4c) / not needed

### tsconfig swap (Step 5): DONE / SKIPPED (already extends form)

### Skipped (deferred for later)
- src/components/bar.tsx
- ...

### Hydration smoke: PASS / FAIL
<list any failed islands>

### Build: PASS / FAIL
### Typecheck: PASS / FAIL

### Next steps
<list any remaining manual steps>
```

## Human-gated loop for edited / ambiguous files (Category B + C)

For **each** file in `NEEDS_CONFIRMATION`, pause and present the following prompt to the
user:

```
File: <relative-path>
Category: B (edited) / C (missing in prod)
Diff summary:
<show diff --stat or a short unified diff — keep it readable>

Action?
  [D] delete — this file's family has no eject target (Category D-adjacent); safe once confirmed
  [E] eject  — copy local source into project via `zudo-doc eject <component>` (only if it maps to an EJECTABLE entry, see below)
  [K] keep   — keep the file as-is (project retains ownership, no package import)
  [S] skip   — leave file unchanged for now (handle later)

Default: [E] eject if the file maps to an EJECTABLE component, else [K] keep
```

Wait for the user's explicit response before proceeding to the next file. Never silently
delete or overwrite an edited file.

### Eject handoff

When the user chooses **eject** (or accepts the default for an ejectable component), call the C1 eject CLI:

```bash
zudo-doc eject <component>
```

The CLI (ships from `@takazudo/zudo-doc`, reachable in generated projects as `node_modules/.bin/zudo-doc`) performs:

1. Copies the component's TS source from the published `eject/` bundle into
   `src/components/zudo-doc/<component>/` inside the target project.
2. Rewrites parent-relative cross-component imports to `@takazudo/zudo-doc/<dir>` subpath
   specifiers so they keep resolving against the installed package.
3. Records the ejected component in `.zudo-doc.json`
   (`{ "ejected": { "<component>": "src/components/zudo-doc/<component>" } }`)
   — lazily creating the file on first successful eject (it is never seeded
   by the scaffold itself).
4. Is **idempotent** — re-running on an already-ejected component prints
   `"already ejected at <path>"` and exits 0 without clobbering local edits.

**Ejectable components** (18 presentational/layout/island components — the full allowlist, `packages/zudo-doc/src/eject/index.ts`'s `EJECTABLE` map):

| CLI name | Import subpath |
|---|---|
| `header` | `@takazudo/zudo-doc/header` |
| `footer` | `@takazudo/zudo-doc/footer` |
| `breadcrumb` | `@takazudo/zudo-doc/breadcrumb` |
| `toc` | `@takazudo/zudo-doc/toc` |
| `sidebar` | `@takazudo/zudo-doc/sidebar` |
| `theme-toggle` | `@takazudo/zudo-doc/theme-toggle` |
| `page-loading` | `@takazudo/zudo-doc/page-loading` |
| `tab-item` | `@takazudo/zudo-doc/tab-item` |
| `doc-pager` | `@takazudo/zudo-doc/doc-pager` |
| `content-admonition` | `@takazudo/zudo-doc/content-admonition` |
| `code-group` | `@takazudo/zudo-doc/code-group` |
| `details` | `@takazudo/zudo-doc/details` |
| `sidebar-tree-island` | `@takazudo/zudo-doc/sidebar-tree-island` |
| `sidebar-toggle-island` | `@takazudo/zudo-doc/sidebar-toggle-island` |
| `desktop-sidebar-toggle-island` | `@takazudo/zudo-doc/desktop-sidebar-toggle-island` |
| `image-enlarge` | `@takazudo/zudo-doc/image-enlarge` |
| `doc-history` | `@takazudo/zudo-doc/doc-history` |
| `site-tree-nav-island` | `@takazudo/zudo-doc/site-tree-nav-island` |

If the file to be ejected does not map to one of these 18 components, the eject CLI will
reject it. In that case, and the file is a deleted-from-template legacy wiring stub
(Category D), route to **delete**; otherwise route to **keep** and advise the
user to handle it manually.

## Flags

| Flag | Effect |
|---|---|
| `--dry-run` | Run Steps 1–3 classification only; print the plan without mutating any files. |
| `--skip-hydration-check` | Skip Step 6 (only for non-island migrations or known-safe deletions). Must be explicit — not implied by any other flag. |

## Key files and references

| Path | Role |
|---|---|
| `scripts/check-template-drift.sh` | Template-baseline diff mechanism for files the CURRENT template still has an opinion on |
| `.template-drift-allowlist` | Files excluded from the automated drift check (still need manual review) |
| `packages/create-zudo-doc/src/__tests__/scaffold.test.ts` | `NEVER_RESURRECTED` — the authoritative, CI-enforced list of deleted-from-template legacy paths (Step 1b) |
| `packages/create-zudo-doc/templates/base/` | Pristine base template (5 files) |
| `packages/create-zudo-doc/templates/features/*/files/` | Pristine feature-specific templates (only `i18n`, `tagGovernance`, `tauri`, `tauriDev` have any) |
| `packages/zudo-doc/src/config.ts` | `ZudoDocConfig` / `zudoDoc()` — the single-entry config API and field census (Step 4) |
| `packages/zudo-doc/CLAUDE.md` | "Shipped ambient type shims + tsconfig base" section — the tsconfig extends-form contract and its two traps (Step 5) |
| `packages/zudo-doc/docs/adr/route-injection-seam.md` | "Host-callables channel — chromeBindingsModule" ADR (Step 4c) |
| `src/chrome-bindings.tsx` | This repo's own reference `ChromeHostBindings` implementation (Step 4c) |
| `packages/create-zudo-doc/docs/eject-contract.md` | Full eject CLI contract (C0 #2359; Decision 5 revised S4 #2373) |
| `packages/zudo-doc/src/eject/index.ts` | `EJECTABLE` map and `eject()` — `zudo-doc` bin ships from `@takazudo/zudo-doc`, not `create-zudo-doc` |
| `.zudo-doc.json` | Provenance marker in the target project (records ejected components + version), lazily created |
| `.claude/skills/l-lessons-zfb-migration-parity/SKILL.md` | Lessons on why hydration smoke is mandatory |