# Dry Run Log

6 October 2026 · branch `dry-run/rewrite-cut` (throwaway, never pushed) · plan: `REWRITE-DRY-RUN.md`

Snapshot of the rewrite: `origin/main` e9e6c86 plus the unmerged branch `test/a1-a6-detail-view` (24 characterisation tests), merged in a detached worktree.

## Step 0: baselines

| | frontend-v3 (`vue3-redesign`) | rewrite snapshot |
|---|---|---|
| Source files in `src/` | 80 (92 in the folder) | 208 |
| Lines in `src/` | about 16,700 | about 30,700 (tests about 11,300) |
| `npm ci` | 5 s | 5 s |
| Tests | 27 files, 318 tests, all pass | 73 files (1 skipped), 1,116 tests, 3 skipped, all pass |
| Type check | in `npm run build` | clean (tests excluded from the check) |
| Build | `vue-tsc -b && vite build` 13.6 s, `vite build` 5.6 s | 11 s, `vite build` 5.8 s |
| Versions | Pinia 3, vue-i18n 11, Vite 6, TypeScript 5.8 | Pinia 2, vue-i18n 9, Vite 5, TypeScript 5.5 |

Collision list: 19 paths exist on both sides (`App.vue`, `main.ts`, `router/index.ts`, `stores/auth.ts`, `i18n/index.ts`, `index.css`, `links.ts`, `mappers.ts`, `types.ts`, `pollKeyStore.ts`, `utils/sanitize.ts`, `composables/{usePollSync,useTheme}.ts`, `components/WysiwygEditor.vue`, and five spec files). Several more name clashes in different folders (`api.ts` against `api/index.ts`, `http.ts` against `api/http.ts`, v3's adopted copies of Bolt's screens under `views/poll/` and `views/shared/` against the rewrite's `views/`). The v3 copies of the poll page, editor and share view differ from the rewrite's by 2,566, 1,692 and 168 changed lines.

## Step 1: copy

197 rewrite files copied or replaced, 10 collisions stay with v3 (shell), `api.ts` relocated to `api/endpoints.ts` (v3's `endpoints.ts` was an older copy of it). Rule: rewrite wins for `views/`, `components/`, `composables/`, `mappers.ts`, `types.ts`, `pollKeyStore.ts`; v3 wins for the shell (`App.vue`, `main.ts`, `router/`, `stores/auth.ts`, `i18n/index.ts`, `links.ts`, `utils/sanitize.ts`). No import rewriting was needed: the rewrite's 487 relative imports kept working because its folder structure was kept.

## Step 2: tool versions

`package.json`: v3's versions kept, `qrcode.vue` and `xlsx` added. `npm install` 8 s. First type check: 76 errors, of which 44 in tests (the rewrite never type-checked its tests; v3's config does) and 32 in source.

| Cause | Count | Fix |
|---|---|---|
| `verbatimModuleSyntax`: type imports must use `import type` (`mappers.ts`) | 13 | mechanical |
| v3's thin auth store lacks the rewrite's account actions; user type lacks roles, images, plan, logos | 11 | step 4 |
| v3's stale adopted copies of Bolt's views against the rewrite's newer types | 5 | views replaced in step 3 |
| other | 3 | with the above |

vue-i18n 9 to 11, Pinia 2 to 3 and Vite 5 to 6 caused **no** failures. Tests under the new versions before the shell merge: 345 failed of 1,369, all from the missing messages and CSS, none from the versions.

## Step 3: shell

- **i18n:** the rewrite's messages (5 TS files) are deep-merged under v3's JSON, fallback `['en', 'de']`. Shared keys: 105 in German, 1 in English; keys with a different value: **1** (`admin.emailTab`). The merge is clean.
- **Router:** rewrite views wired into v3's route names. v3's `/pr/:id/:key?/:publicKey?` becomes three routes with the rewrite's names (`poll-detail`, `poll-detail-participant`, `poll`). v3's handover set extended. The rewrite's login screens are not routed.
- **CSS / Tailwind:** the rewrite's `index.css` defines every v3 token except `--k-error` and `--k-header-*`. The rewrite's tokens evolved (`bg-surface`, `text-foreground`, type roles, `--k-font-sans`) and it has specs that enforce them (`designTokens`, `colorTokens`, `errorColor`: "no `--k-error` any more"). First attempt was to graft v3's palette on top of the rewrite's: it broke 51 tests. Final: the rewrite's CSS and config as base, v3's header tokens appended, and **83 palette classes in 10 v3 files mapped to the semantic tokens** (16 distinct classes, a mechanical sed).
- **Build:** `vite build` 6.6 s, `public/v3` and `resources/views/v3.blade.php` written.

## Step 4: session login

v3's HTTP layer is the rewrite's with the token removed, so this step is small.
- `api/http.ts` extended with `requestPublic` and `siteRoot` (silent mode: no auth events, the server's message is kept). **Bug found in v3's `send`:** an empty 202 body with a JSON header threw a parse error; the rewrite's `readBody` handles it. Ported.
- Auth store: v3's session store plus the rewrite's `refreshUser`, `updateUser`, `changePassword`. `login`, `register`, `loginWithGoogle` dropped.
- **17 files removed** (login, reset and verify screens, auth components, `useGoogleLogin`, `useAfterLogin`, `useAccountRecovery`, 6 specs).
- Specs: 4 files edit-only (`setToken` calls were only login simulation), 2 files with **4 skipped tests** that assert the token is cleared on account deletion (the session equivalent is a POST to `/logout`; needs rewriting, not a bug).
- Type fixes: `ApiUser.ui_version`, `CurrentUser.email` nullable. **Source type errors: 0.**

## Step 5: run everything

| | Result |
|---|---|
| Tests | 91 files, 1,274 tests: **1,262 passed, 7 skipped, 5 failed** |
| Type check | 18 errors, **all in specs** (12 `pagedPoll.spec`, 2 `designTokens`, 2 `AppHeader` fixtures, 1 `colorTokens`, 1 live spec): the rewrite never type-checked them |
| Build | passes (`vite build`); `npm run build` fails on the 18 spec errors |
| Smoke test (new) | the integrated App with v3's router and session store, the network replaced: dashboard renders for a session user, poll link renders for a visitor, no `Authorization` header on any request. 3 of 3 pass |

The 5 failures, all documented, none a regression:
1. `designTokens`: raw colour and type-size literals in v3's `PollCard` (2 colours, 4 sizes) and `components/poll/OptionCard` (1 size), and a palette colour in `PollView`.
2. `designTokens`: budget entries for removed login files (`AuthShell`, `RegisterForm`).
3. `router.spec`: the three-part classic link `/pr/{id}/{key}/{publicKey}` yields `participantId`, where v3 expects `key`. This is the link-format gap (A4), seen in code.

Also needed to get here: v3's own two components break the rewrite's architecture rule (`AppHeader`, `DashboardView` import `api/http` directly) and the raw-field guard (`DashboardToolbar`, `AnswerGrid`); both are listed in the guards for now.

## Step 6: see it run

Laravel in the worktree (`composer install` 1 min 56 s, `.env` and the built classic bundle copied from the main checkout, `php artisan serve --port=8000`), against the local MySQL copy (`kulibri-latest-backup`). `KULIBRI_UI_V3_ENABLED=true`, `KULIBRI_UI_V3_POLL_TYPES=1` as already set on this machine.

**Which UI each URL gets** (response headers `X-Kulibri-UI`, `X-Kulibri-UI-Reason`):

| URL | UI | Reason |
|---|---|---|
| `/dashboard?ui=v3` | v3 | query |
| `/dashboard` | legacy | default (nothing chosen) |
| `/pr/{real date poll}?ui=v3` (9 of 11 tried) | v3 | query |
| `/pr/{hidden poll}?ui=v3` (2 of 11) | legacy | poll-hidden |
| `/pr/{unknown id}?ui=v3` | legacy | poll-not-found |
| `/pr/{poll}?classic=1&ui=v3` | legacy | classic-once |
| `/signin` | legacy | not-a-v3-path |

The resolver behaves as designed; nothing in it changed.

**Rendered with headless Chrome** (`dry-run-screenshots/` in the worktree): the poll "Sommerfest planen" from the local database, opened as a visitor without a login with `?ui=v3`: v3's header (with "Classic view" and "Sign in"), then the rewrite's poll page: banner, tabs (Raster, Termine, Teilnehmer, Kommentare), the answer grid with 9 participants and 5 dates, the legend. The same poll with `?classic=1` renders the classic UI. The bundle loads from `/v3/assets` (entry 248 kB, HTTP 200), the API answers from the same origin.

**Not verified** (no login available to me, no real phone):
- Logged-in flows in a browser: dashboard, poll as owner, editor, share, membership, profile. The smoke test covers the dashboard and the poll page with the network replaced.
- Phone width: headless Chrome has a minimum window width of about 500 px, so the 420 px screenshot is not reliable. Needs a real device or emulation (parity row 3.17).
- Hidden polls, course polls (stay classic), the "Classic view" switch round trip when logged in.

## Findings after the first logged-in test (6 Oct, evening)

Mitko's browser network panel of the dashboard (logged in) showed the card images loaded from `https://test.kulibri.com`, some of them 1.25 to 1.42 MB. Two findings, both fixed in the dry-run branch, plus one bug introduced by the first fix and caught by a real browser.

| # | Finding | Cause | Fix |
|---|---|---|---|
| 1 | Images come from another host | The rewrite's `resolveAssetUrl` (`mappers.ts`) prefixes every relative path with `https://test.kulibri.com/` (a standalone-prototype shortcut; plan item A3). In production the dashboard would load images from the test server | A relative path stays relative (`/images/…`, same origin). `VITE_ASSET_BASE_URL` stays as an optional override for a standalone build |
| 2 | The dashboard loads full-size images | Cards used the full background image: about 7 MB for the first 12 rows (Universe 1.25 MB, Blue 1.42 MB, Surfing 890 kB …). Small previews exist (`sm_` files; 145 in the local `public/images`; for example 83 kB against 1.25 MB) | `mapPoll` uses `thumbUrl` in list mode (the dashboard); the poll page keeps the full image. The card `<img>` is `loading="lazy"` and falls back once to the full image on `error`, for images without a preview |
| 3 | **Bug caused by fix 1:** `Orange%2520by%2520luke-chesser.jpg` (double encoding) | The poll page resolves the already-resolved value again. With the old absolute host the second pass left the URL alone; with a relative path it encoded it twice. Unit tests did not see it; the DOM of a real page load did | `resolveAssetUrl` is idempotent (decode, then encode; a literal `%` in a file name is encoded). Test added |

**Evidence.** 6 new tests: 3 dashboard image tests in `integrated.spec.ts` (they fail on the old code with exactly the URL from the panel, `https://test.kulibri.com/images/…`), 3 `resolveAssetUrl` tests in `mappers.spec.ts` (relative, idempotent, literal `%`). Suite after the fix: **1,268 passed, 7 skipped, 5 failed** (the same 5 documented failures). Chrome net log of a poll page with an image: only `http://localhost:8000/images/Orange%20by%20luke-chesser.jpg`, nothing to another host.

**Diff to re-apply in the real cut:** 4 files, +88 / −3 lines (`mappers.ts`, `HomeView.vue`, `mappers.spec.ts`, `integrated.spec.ts`). Estimate 0.25 day.

**Not verified in a logged-in browser:** that the dashboard now requests `sm_*.jpg` from `localhost:8000`. The dashboard needs a session, so this was checked in jsdom only. Check: reload `/dashboard?ui=v3` and look at the network panel; expect previews of tens of kB, from `localhost:8000`.

**Local note.** The worktree's `public/images` holds the 237 images copied from the main checkout (git-ignored data folder; in production it is the same folder the app is served from).

**Lesson.** Tests with a mocked network missed both findings, and one fix created a new bug. What found them was a real page load: the network panel and, for the encoding bug, the DOM and Chrome's net log. The logged-in browser verification stays in the cut plan, and its checklist gets one line: no request to another host (and no image above about 200 kB on the dashboard).

## Dashboard felt slow: 14 s (second logged-in test, 6 Oct, evening)

After the image fix Mitko's network panel showed the dashboard at 32 requests, 0.7 MB (was about 10 MB), `load` 1.4 s but "finish" 14.6 s, with tiny files such as a 459-byte chunk taking about 2.4 s each. The old frontend felt faster.

**Cause: the local PHP setup, not the bundle.**
- This PHP loads **Xdebug in `debug` mode** (`xdebug.mode=debug`, `start_with_request=1`). Every request waits for a debugger that is not there ("Time-out connecting to debugging client, waited: 200 ms"). `php artisan serve` is single-threaded, so the waits add up. Measured: a 459-byte static chunk takes **0.63 s with Xdebug and 0.21 s without** (curl, including about 0.2 s of the Windows `localhost` IPv6 fallback that curl pays and Chrome does not).
- The new UI makes more, smaller requests than classic (code-split chunks), so it pays the per-request cost more often.

**Fair comparison, Xdebug off, same poll, fresh Chrome profile (net log):**

| | requests to the server | API calls | first to last event |
|---|---|---|---|
| New UI (`?ui=v3`) | 37 | 6 | **0.9 s** |
| Classic (`?classic=1`) | 15 | 1 | 1.9 s |

So on the poll page the new UI is not slower; with Xdebug off it was faster in this run. The dashboard itself could not be measured without a session; please repeat the comparison there with Xdebug off.

**How to start the server for tests:** `XDEBUG_MODE=off php artisan serve --port=8000` (bash) or `$env:XDEBUG_MODE='off'; php artisan serve --port=8000` (PowerShell). Timings from a server with Xdebug on are not comparable between UIs.

**For production:** static files are served by the web server (concurrently, compressed, HTTP/2), so 37 small requests are not a concern there. If the number of chunks ever matters, Vite's `manualChunks` can merge the tiny ones; not needed now. The API cost (`/api/polls` about 80 kB, 2.3 s on this laptop with Xdebug) is the same endpoint classic uses.

**Confirmed by Mitko (6 Oct):** the slow dashboard was the Xdebug debug mode; with it off the dashboard loads normally (numbers not recorded). A permanent fix is in the PHP configuration, not in the app: `xdebug.mode=off`, or `xdebug.start_with_request=trigger` so the debugger is only used on demand.

**Checklist line for the cut:** measure dashboard load in both UIs with Xdebug off, in the same browser profile, and compare request counts and the slowest API call.

## Two navigations on one page (third logged-in test, 6 Oct, evening)

Mitko's screenshot of an owner's poll page showed v3's header (hummingbird, theme toggle, user) stacked on top of the rewrite's own poll header (kulibri logo, toolbar, user menu): two logos, two user menus.

**Cause: my shell merge (step 3).** v3's `App.vue` puts its header and a content frame around every route, unless the route sets `meta.ownShell`. The rewrite's views were built standalone and bring their own header, and I had not set `ownShell` on their routes. The smoke test checked text only, so it did not see the duplicate.

**Which views bring a header** (checked in the code): `HomeView` (dashboard, with user menu and theme toggle), `PollDetailView` (user menu), `PollEditorView` and `PollShareView` (header, no menu), `MembershipView` through `AccountShell` (user menu), `InvoicePrintView` (printable page with its own logo). Without a header: `NewPollView`, `DeletedPollView`, `UnsubscribeView`; these keep v3's header (logo, theme toggle, "Classic view", sign in or account menu).

**Fix**
- `ownShell: true` on 10 routes (dashboard, poll, poll-detail, poll-detail-participant, editor and share with and without key, membership, invoice print).
- **The classic switch lives in one place:** new composable `useClassicSwitch` (the sticky switch from v3's `AppHeader`, unchanged in behaviour), used by `AppHeader` and by the rewrite's `UserMenu`, which gets a "Klassische Ansicht" entry. This is task 2 of the same-link plan. `AppHeader` no longer imports the API itself, so it leaves the architecture test's list.
- **A second bug found on the way:** the rewrite menu's "Dashboard" entry pushed `/`, which in the integrated app belongs to classic; it now opens the dashboard route.

**Evidence.** 8 new tests: 4 in `classicSwitch.spec.ts` (account and visitor paths, shared busy flag, failure), 4 in `integrated.spec.ts` ("one navigation per page": dashboard, poll page, account page, a page without its own header; and the classic entry in the menu). Without the route change the three one-header tests fail. Suite: **1,277 passed, 7 skipped, 5 failed** (the same 5 documented failures), 0 type errors in source, build 4 s. Screenshot of the poll page: one header.

**Diff to re-apply in the cut:** `router/index.ts`, `composables/useClassicSwitch.ts` (new), `AppHeader.vue`, `UserMenu.vue`, two specs; about 0.25 day (part of the shell merge).

**Consequence still open (task 2 of the same-link plan):** a visitor without an account used to get "Classic view" and "Sign in" from v3's header on the poll page. The rewrite's own header shows them nothing, so a participant has no way to open the page in classic. The one-off "open in classic" (`?classic=1`, `classicUrl` in `links.ts`) has to go into the poll header for visitors. Not done yet.

**Lesson.** A layout defect needs a layout check: the smoke test now asserts which header is on the page. The cut's verification list gets "one header, one user menu per page type".

## Tidy-up on the integration branch (6 Oct, evening)

Done on `cut-prep/integration` while waiting for Bolt's final snapshot; none of it touches files Bolt edits.
- v3's remaining screens use the rewrite's tokens: `PollCard` (two `rgba` badges to `bg-black/50` and `bg-destructive/90`, the 10 px text to `text-caption`), `components/poll/OptionCard` (10 px to `text-caption`), `PollView` (red palette classes to the destructive tokens). The two stale budget entries for the removed login files are gone from `designTokens.spec`.
- 18 type errors in specs fixed (typed fixtures, non-null assertions, two obsolete `@ts-expect-error`, one cast). The rewrite never type-checked its specs.
- The four skipped account-deletion tests are rewritten for the session: the flow logs out through `/sanctum/csrf-cookie` and `/logout`; the tests assert that call. A mutation check (flow without the logout call) fails two of them.
- Result: **1,285 passed, 3 skipped, 1 failed**; type errors 0; `npm run build` passes. The one failure is the classic three-part link in `router.spec` (route map, A4).
- Open consequence: after an account deletion the classic sign-in cannot show the "account deleted" notice (see section 12 of the plan).

## Fourth logged-in test: 500 on the answers request, and the public link (6 Oct, evening)

**1. `preferences?choice_start=0&choice_count=4` returned 500 (Mitko's network panel).** Reproduced locally. The trace shows the failing route parameter was `key = 'preferences'`: the request was matched by the catch-all `GET /poll/{id}/{key?}/{action?}`, because the keyless routes `GET /poll/{id}/preferences` and `/poll/{id}/version` do not exist on `vue3-redesign`. They are on `master` (and the test server, which Bolt developed against). **Cause: the dry run's backend is 45 commits behind `master`** (plan item A5), not the rewrite or the cut. Verified in the throwaway dry-run branch: after `git merge master` the same requests return 200 and the whole backend suite passes (**445 tests, 1,885 assertions**). The merge had **4 conflicts, all in text files** (`.gitignore`, `CLAUDE.md`, `SECURITY-BACKLOG.md`, `TESTING-BACKLOG.md`, resolved as unions); `routes/api.php` and `UserController.php` merged by themselves. **The real A5 (merge `master` into `vue3-redesign`) is a precondition of the cut and has to be done before the browser verification.**

**2. A public share link breaks in the new UI (found while checking 1).** The rewrite forwards the second link segment to the API as the key and lets the server decide. The backend only understands **two** kinds of key, the poll's admin key and an invitation id (`PollAccessResolver`: `key == poll.key`, or `Participant::where('invitation_id', $key)`; the invitation lookup is not scoped to the poll). Measured on a local poll, with the merged backend too:

| Second segment | `GET /api/poll/{id}/{segment}` |
|---|---|
| none | 200 |
| admin key | 200, `key` and `adminKey` in the answer |
| an invitation id (of another poll) | 200, nothing special |
| **public key** (`ownerId[0:4] + ownerId`) | **500** "Fails to convert public ids" |
| **legacy admin key** (`last4(ownerId) + ownerId`) | **500** |
| any other string | **500** |

The classic client never sends a public or legacy key: `Poll.vue` recognises them by pattern first (`isPublicKey`: characters 0 to 3 equal characters 4 to 7; `isLegacyAdminKey`: first four equal last four) and sends only the rest as `key`. It also redirects the three-part link `/pr/{id}/{key}/{publicKey}` to the public link `/pr/{id}/{publicKey}` (query and hash kept), and sends an unknown key (no invitation matches) to `/deleted`. **In the integrated app, `/pr/{id}/{publicKey}?ui=v3` shows "Umfrage konnte nicht geladen werden.: Fails to convert public ids"** (screenshot), while classic opens it. The public link is the most shared link kind, so this blocks the first switch of a poll type. It is the concrete form of the gap behind parity rows 1.8, 1.12 and 9.5 and of the failing `router.spec` test.

**Fix, to do in the cut (needs the rewrite's files, so after the freeze):** a pure helper `utils/linkSegment.ts` with the classic rules (`classifySegment`: none, public, legacyAdmin, key), used at the one place that builds the API calls so that only `key` segments reach the backend (poll, answers, version, sync); the three-part redirect in the router; `/deleted` for an unknown key; tests for each kind with the real examples above. Details and the full route table: `docs/route-map.md`.

## Final snapshot imported (7 Oct 2026)

Bolt pushed 11 more commits between the evening of 6 Oct and 10:29 on 7 Oct (sub-admins and the comment notification toggle, dashboard cards, user menu, poll header on mobile, hidden polls with the same tabs as open ones, a shared scroll lock for side panels, removal of the always-zero answered counts, the English grid handover). The tag `bolt-last` points at the last one (`0cc50e8`). Details of the import and the two conflicts: `tools/rewrite-cut/README.md`.

- **Handover documents:** `GRID-KONZEPT.md` (English, committed by Bolt to `master` of this repository) and `docs/kulibri-grid-uebergabe-dev.md` (German original, only on the rewrite branch `raster-paging-FE`). They describe the grid (virtualised rows and columns, answer matrix, panel width), sorting, inline editing and the chunked loading of answers (`?paginate=1`, then `/preferences?choice_start&choice_count`, 50 dates per chunk, fallback to the full request), and list eight suggestions for splitting `PollDetailView` (about 4,300 lines) that match our cut list. Test gaps named by Bolt: sorting, virtualisation and inline editing have no tests.
- **Dashboard counts:** Bolt removed the "answered" and "Zusagen" counts because `/api/polls` fills `confirmations` and `declines` from `participant.choice`, which only guest lists use, so they are zero for date and text polls. That matches A-23 (classic shows the participant count there). The guest-list card still adds the guests twice (`confirmations + guests`); the fix per A-23 stays on our list.
- **Backend findings for us:** G18 (archive is one flag per poll for everyone, PO priority 1) and G19 (see the plan, section 12).
- **Result of the merge:** 1,326 passed, 3 skipped, 1 failed (three-part classic link); 0 type errors; build passes.

## Effort actually spent

About 6 hours of work for steps 0 to 6 (the plan allowed about 3 working days). One session, no step reached its time box.

## Step 7: report

### What the dry run shows

1. **The cut works.** All of the rewrite's code runs inside `frontend-v3`: 1,262 of 1,274 tests pass (5 failures documented, 7 skipped), 0 type errors in source, the bundle builds in 7 s and Laravel serves it through the existing resolver.
2. **Tool versions are not a problem.** Pinia 3, vue-i18n 11, Vite 6 and TypeScript 5.8 broke nothing. The only mechanical fix was 13 `import type` lines in `mappers.ts`.
3. **The session layer is already there.** v3's HTTP client is the rewrite's with the token removed. Four things to add: `requestPublic`, `siteRoot`, the empty-body fix (a real bug in v3: an empty 202 threw), and the account actions of the store. Token code in the rewrite was confined to the login screens (17 files removed), `http.ts` and the store, plus login simulation in 6 specs. Only 4 tests need real rewriting (account deletion logs out through `/logout`).
4. **i18n merges cleanly.** 539 + 200 keys, 105 shared, 1 differing value.
5. **The one real design question is the design system.** The rewrite's tokens evolved (`bg-surface`, type roles, no `--k-error`) and its specs enforce them. v3's four components and its read view use the older palette: 83 class occurrences in 10 files, mapped mechanically; 3 files still have raw literals (`PollCard`, `components/poll/OptionCard`, `PollView`). Decision for the cut: v3's own screens move to the rewrite's tokens.
6. **v3 had adopted older copies of Bolt's screens at final URLs** (poll page, editor, share; 3,245 / 1,967 / 186 lines against 4,597 / 2,225 / 260). The rewrite's versions replace them; no v3 spec covered them.
7. **Link-format gap seen in code.** v3's route knew `/pr/{id}/{key}/{publicKey}`; the rewrite reads the second segment as `participantId`. v3's `router.spec` fails on it. This is parity rows 1.12 and 9.5 and the route map (A4), and it must be solved before the first poll type is switched.
8. **Guards from the rewrite hit v3's own code:** two files import `api/http` directly (`AppHeader`, `DashboardView`) and two use raw form fields; all are listed in the guards for now.
9. **The rewrite's specs were never type-checked.** 18 type errors in 6 spec files; `npm run build` fails on them until they are fixed or excluded.

### Changes that would be cheaper in the rewrite before the freeze

None needed. Optional and tiny: the 13 `import type` lines in `mappers.ts`, and type-checking the specs.

### Effort of the real cut (updated estimate, was 5 to 10 working days)

| Step | Estimate | Based on |
|---|---|---|
| Clean, reviewable commits for the cut (copy, alignment, shell, session) with `git subtree` history | 0.5 day | the dry run's changes are known and scripted |
| Fix the 5 failing specs, the 18 spec type errors, the 4 logout tests, v3's 3 files with literals | 1 day | listed above |
| Decide the dashboard: the rewrite's `HomeView` is routed now, v3's `DashboardView` and its 3 specs are unrouted | 0.5 day, plus the PO's response-count decision (PO-25) | smoke test passes with `HomeView` |
| Browser verification with a real session: dashboard, poll as owner, editor, share, membership, profile, phone width, `?classic=1` round trip | 1 to 2 days, with Mitko | not covered by the dry run |
| Archive the old repository, update `CLAUDE.md`, the skill and the docs | 0.5 day | |

**About 3 to 5 working days**, most of the uncertainty in the browser verification. Not included, as before: the large-view split, the route map and link formats, the switch controls in the rewrite's menu, gap closing.

### Go or no-go

**Go.** No blocker found. Open before the real cut: PO-25 (response count) for the dashboard, and the browser verification above.

## How to see it

From `C:\MyProjects(-Prod\kulibri-dry` (local MySQL running): `php artisan serve --port=8000`, then open `http://localhost:8000/pr/76bm4kvVGpYE?ui=v3` (a date poll of the local database). For the logged-in part: sign in at `http://localhost:8000/signin` (classic), then open `http://localhost:8000/dashboard?ui=v3`. `?ui=legacy` switches back; `?classic=1` shows one page in classic.
