feat(web): TYPO3-style backend shell — module rail | page tree | module content #34

Merged
dominik.polakovics merged 2 commits from afk/33 into main 2026-07-14 20:54:56 +02:00

Replaces the always-visible tree | edit | preview three-column layout with a TYPO3-style module shell: module rail | page tree | module content.

Closes #33

What changed

Module registry. Page and View are the first two entries in a real registry (web/src/modules.tsx: id, label, icon, whether it wants the page tree, component). The shell never names a module — it looks one up by the id in the URL, asks whether it wants a tree, and renders it. A third module costs one entry.

Routing (@solidjs/router). /:module/:lang/*path, e.g. /page/en/blog/post.md. Module, language and selected page are deep-linkable and reload-safe; the URL replaces the deckle:selection sessionStorage mirror as the source of truth (two stores for one fact can only drift). Element selection stays ephemeral. / and unknown modules redirect to /page/<defaultLang> once the tree reports its language; a URL naming a page the tree does not have falls back to the empty state, never an error page. Switching modules preserves the page and switching pages preserves the module — both fall out of the URL scheme.

One catch-all route, parsed in a pure routing.ts, rather than route patterns: a page path carries slashes and dots, and whether a module id is a module is the registry's call, not the route table's.

Module rail. Icon-only, 48px, tooltips, icons from lucide-solid. The page-tree toggle lives in the rail — a control that hides the column it sits in cannot bring it back.

Page tree column. Drag- and keyboard-resizable (role="separator"), width clamped 180–560px and persisted (deckle:tree-width), collapsible (deckle:tree-collapsed). Architecturally per-module optional; both current modules use it.

Unsaved-changes guard (new feature). Module switch, tree page switch, and beforeunload prompt discard / save & continue / stay. Because module and page switches are both router navigations now, a single useBeforeLeave covers both. FormPane and PageSettings register as dirty sources (they now track field dirtiness, not just the rich-text body, and expose a programmatic save). Navigations that follow a confirmed server mutation — rename, move, delete, tree prune, bundle conversion — run unguarded: offering to save a page that no longer exists would be offering nonsense.

Preview bridge → cross-module navigation. Clicking an element in the View preview jumps to the Page module with that element selected. In-iframe link browsing still does not sync tree selection or the URL.

Notes for the reviewer

  • Outbound highlight is now inert. With the modules strictly exclusive, the element list that emits highlights and the iframe that receives them are never mounted together. The issue permits this ("hover-highlight may stay"), so the deckle:highlight protocol — the SPA's contract with the theme's ce-bridge.js — is kept and covered at the PreviewPane component level rather than pretending it works end-to-end. A future split-view module can revive it.
  • One subtle fix worth naming: solid-router applies navigation in a transition, so during the bundle-conversion follow the route still names the old page while the new element selection names the new one. selectedElement is therefore derived (an element selection is valid iff it names the selected page) rather than cleared from an effect — an effect would race the transition and throw away the very selection the conversion was preserving.
  • Layout-only, as scoped: tree create/rename/move/delete stay where they live today. No mobile/responsive work.

Verification

  • npm test — 389 passing across 23 suites (new: routing, guard unit, Guard end-to-end through the shell, ShellRouting, PreviewPane bridge protocol).
  • npx tsc --noEmit clean; npm run build green; go test ./... green.
  • Smoke-tested against the real binary: GET /page/en/posts/first.md returns the SPA (200, text/html) via the existing serveStatic index.html fallback, with real assets still resolving.

🤖 Generated with Claude Code

Replaces the always-visible `tree | edit | preview` three-column layout with a TYPO3-style module shell: **module rail | page tree | module content**. Closes #33 ## What changed **Module registry.** Page and View are the first two entries in a real registry (`web/src/modules.tsx`: id, label, icon, whether it wants the page tree, component). The shell never names a module — it looks one up by the id in the URL, asks whether it wants a tree, and renders it. A third module costs one entry. **Routing (`@solidjs/router`).** `/:module/:lang/*path`, e.g. `/page/en/blog/post.md`. Module, language and selected page are deep-linkable and reload-safe; the URL replaces the `deckle:selection` sessionStorage mirror as the source of truth (two stores for one fact can only drift). Element selection stays ephemeral. `/` and unknown modules redirect to `/page/<defaultLang>` once the tree reports its language; a URL naming a page the tree does not have falls back to the empty state, never an error page. Switching modules preserves the page and switching pages preserves the module — both fall out of the URL scheme. One catch-all route, parsed in a pure `routing.ts`, rather than route patterns: a page path carries slashes and dots, and whether a module id *is* a module is the registry's call, not the route table's. **Module rail.** Icon-only, 48px, tooltips, icons from `lucide-solid`. The page-tree toggle lives in the rail — a control that hides the column it sits in cannot bring it back. **Page tree column.** Drag- and keyboard-resizable (`role="separator"`), width clamped 180–560px and persisted (`deckle:tree-width`), collapsible (`deckle:tree-collapsed`). Architecturally per-module optional; both current modules use it. **Unsaved-changes guard (new feature).** Module switch, tree page switch, and `beforeunload` prompt **discard / save & continue / stay**. Because module and page switches are both router navigations now, a single `useBeforeLeave` covers both. `FormPane` and `PageSettings` register as dirty sources (they now track field dirtiness, not just the rich-text body, and expose a programmatic save). Navigations that *follow* a confirmed server mutation — rename, move, delete, tree prune, bundle conversion — run unguarded: offering to save a page that no longer exists would be offering nonsense. **Preview bridge → cross-module navigation.** Clicking an element in the View preview jumps to the Page module with that element selected. In-iframe link browsing still does not sync tree selection or the URL. ## Notes for the reviewer - **Outbound highlight is now inert.** With the modules strictly exclusive, the element list that emits highlights and the iframe that receives them are never mounted together. The issue permits this ("hover-highlight may stay"), so the `deckle:highlight` protocol — the SPA's contract with the theme's `ce-bridge.js` — is kept and covered at the `PreviewPane` component level rather than pretending it works end-to-end. A future split-view module can revive it. - **One subtle fix worth naming:** solid-router applies navigation in a transition, so during the bundle-conversion follow the route still names the old page while the new element selection names the new one. `selectedElement` is therefore *derived* (an element selection is valid iff it names the selected page) rather than cleared from an effect — an effect would race the transition and throw away the very selection the conversion was preserving. - Layout-only, as scoped: tree create/rename/move/delete stay where they live today. No mobile/responsive work. ## Verification - `npm test` — 389 passing across 23 suites (new: `routing`, `guard` unit, `Guard` end-to-end through the shell, `ShellRouting`, `PreviewPane` bridge protocol). - `npx tsc --noEmit` clean; `npm run build` green; `go test ./...` green. - Smoke-tested against the real binary: `GET /page/en/posts/first.md` returns the SPA (200, `text/html`) via the existing `serveStatic` index.html fallback, with real assets still resolving. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(web): TYPO3-style backend shell — module rail | page tree | module content
Some checks failed
ci / build-and-test (pull_request) Failing after 5m27s
b079daa6b5
Replaces the always-visible `tree | edit | preview` three-column layout with a
module shell. Page and View are the first two entries in a real registry
(web/src/modules.tsx: id, label, icon, whether it wants the tree, component), so
the next module costs one entry and nothing else — the shell never names a
module, it looks one up by the id in the URL.

- Routing (@solidjs/router): `/:module/:lang/*path`. Module, language and the
  selected page are deep-linkable and reload-safe; the URL replaces the
  sessionStorage selection mirror as the source of truth. Element selection stays
  ephemeral. `/` and unknown modules redirect to `/page/<defaultLang>`; a URL
  naming a page the tree does not have falls back to the empty state, never an
  error. One catch-all route, parsed in routing.ts: page paths carry slashes and
  dots, and whether a module id is a module is the registry's call.
- Module rail: icon-only (lucide-solid), tooltips, and the page-tree toggle —
  which lives in the rail because a control that hides the column it sits in
  cannot bring it back.
- Page tree column: drag- and keyboard-resizable, width and collapsed state
  persisted (deckle:tree-width, deckle:tree-collapsed). Per-module optional.
- Unsaved-changes guard (new): module switch, tree page switch and beforeunload
  prompt discard / save & continue / stay. Module and page switches are both
  router navigations now, so one useBeforeLeave covers both. Selections that
  follow a confirmed server mutation (rename, move, delete, tree prune, bundle
  conversion) run unguarded — offering to save a page that no longer exists
  would be offering nonsense.
- Preview bridge is now a cross-module gesture: clicking an element in the View
  preview jumps to the Page module with it selected. Outbound highlight keeps its
  protocol (covered at PreviewPane level) but is inert while the modules are
  exclusive.

Tests: 389 passing. New suites cover routing, the guard (unit + end-to-end
through the shell), shell routing, and the preview bridge protocol.

Closes #33

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Owner

This was generated by AI while landing a PR.

Landing audit — FAIL (do not merge yet)

Verification signal

CI run 23 is red on this head (b079daa). I could not read the job log (needs a forge session), so I reproduced the CI recipe locally on the PR head instead:

  • npm ci, npm run build, npm test — green
  • go vet ./..., go test ./..., go build ./cmd/harness — green, with the pinned Hugo 0.163.3+extended on PATH, so the four Hugo integration tests that skip without it actually ran

The base is red too. main has been failing CI since the v1 rebuild landed: run 20 (PR #32), runs 21 and 22 (pushes to main), now run 23 (this PR). Failure durations are scattered (4m / 5m / 8m / 14m), which does not look like one deterministic broken test. This is a repo-level CI breakage that deserves its own issue — it is not evidence against this PR, but it does mean nothing currently vouches for this branch.

Conventions

  • Title is Conventional Commits (feat(web): …) — OK
  • Body carries a working Closes #33 — OK
  • Branch afk/33 merges cleanly into main (base is an ancestor of the head; no conflict) — OK

Findings

Blocking — spec divergences from #33:

  1. web/src/components/TreePane.tsx:184 — the "URL naming a nonexistent page falls back to the empty state, never an error page" rule is implemented as an effect inside TreePane, which is mounted only when the tree column is visible. With the tree collapsed (deckle:tree-collapsed), loading /page/en/ghost.md renders the page-error pane permanently. The same hole opens for any future module declaring tree: false — which the registry is designed to allow. Route repair belongs in the shell, not in a conditionally-mounted column.
  2. web/src/selection.ts:111 — the prune navigates with a push, not a replace. Loading a bad page URL prunes to /page/en and grows history; Back re-enters the bad URL, which prunes and pushes again — the back button is trapped. Every rename/move/bundle conversion also leaves a spurious empty-state entry in history (TreePane.tsx:296, which refetches before navigating).
  3. web/src/App.tsx:106 — the route-repair effect fires only when the module is unknown; an unknown language is never repaired. /page/de/about.md on an en-only site leaves the editor parked on /page/de with a permanently empty page module.

Non-blocking:

  1. web/src/guard.ts:133saveAndContinue is re-entrant. The dialog button has no busy state and the guard keeps no in-flight flag, so double-clicking "Save & continue" issues two concurrent POST /api/page/save calls for the same file — the read-modify-write race the sequential-save loop is documented to prevent.
  2. web/src/App.tsx:146 — a pending preview-bridge selection is cleared only when its exact page loads. Navigating elsewhere before it resolves leaves it stashed, and it can hijack the element selection when that page is next opened.

Cleared

Listener hygiene (beforeunload, message, mousedown, pointer) all have matching cleanups; parseRoute/buildRoute round-trip paths with slashes, dots, spaces, %, # and reject malformed input without throwing; same-URL navigation does not false-positive the guard; deckle:selection is genuinely gone with the URL as sole source of truth; the Go SPA fallback serves the deep links.

The restructure otherwise delivers #33 as specified: module registry, rail, resizable/collapsible tree column, URL-as-source-of-truth routing, unsaved-changes guard, cross-module preview bridge.

> *This was generated by AI while landing a PR.* ## Landing audit — FAIL (do not merge yet) ### Verification signal CI run 23 is **red** on this head (`b079daa`). I could not read the job log (needs a forge session), so I reproduced the CI recipe locally on the PR head instead: - `npm ci`, `npm run build`, `npm test` — green - `go vet ./...`, `go test ./...`, `go build ./cmd/harness` — green, with the pinned Hugo 0.163.3+extended on PATH, so the four Hugo integration tests that skip without it actually ran **The base is red too.** `main` has been failing CI since the v1 rebuild landed: run 20 (PR #32), runs 21 and 22 (pushes to `main`), now run 23 (this PR). Failure durations are scattered (4m / 5m / 8m / 14m), which does not look like one deterministic broken test. This is a repo-level CI breakage that deserves its own issue — it is not evidence against this PR, but it does mean nothing currently vouches for this branch. ### Conventions - Title is Conventional Commits (`feat(web): …`) — OK - Body carries a working `Closes #33` — OK - Branch `afk/33` merges cleanly into `main` (base is an ancestor of the head; no conflict) — OK ### Findings Blocking — spec divergences from #33: 1. **`web/src/components/TreePane.tsx:184`** — the "URL naming a nonexistent page falls back to the empty state, never an error page" rule is implemented as an effect *inside* `TreePane`, which is mounted only when the tree column is visible. With the tree collapsed (`deckle:tree-collapsed`), loading `/page/en/ghost.md` renders the `page-error` pane permanently. The same hole opens for any future module declaring `tree: false` — which the registry is designed to allow. Route repair belongs in the shell, not in a conditionally-mounted column. 2. **`web/src/selection.ts:111`** — the prune navigates with a *push*, not a replace. Loading a bad page URL prunes to `/page/en` and grows history; Back re-enters the bad URL, which prunes and pushes again — the back button is trapped. Every rename/move/bundle conversion also leaves a spurious empty-state entry in history (`TreePane.tsx:296`, which refetches before navigating). 3. **`web/src/App.tsx:106`** — the route-repair effect fires only when the *module* is unknown; an unknown *language* is never repaired. `/page/de/about.md` on an `en`-only site leaves the editor parked on `/page/de` with a permanently empty page module. Non-blocking: 4. **`web/src/guard.ts:133`** — `saveAndContinue` is re-entrant. The dialog button has no busy state and the guard keeps no in-flight flag, so double-clicking "Save & continue" issues two concurrent `POST /api/page/save` calls for the same file — the read-modify-write race the sequential-save loop is documented to prevent. 5. **`web/src/App.tsx:146`** — a pending preview-bridge selection is cleared only when its exact page loads. Navigating elsewhere before it resolves leaves it stashed, and it can hijack the element selection when that page is next opened. ### Cleared Listener hygiene (`beforeunload`, `message`, `mousedown`, pointer) all have matching cleanups; `parseRoute`/`buildRoute` round-trip paths with slashes, dots, spaces, `%`, `#` and reject malformed input without throwing; same-URL navigation does not false-positive the guard; `deckle:selection` is genuinely gone with the URL as sole source of truth; the Go SPA fallback serves the deep links. The restructure otherwise delivers #33 as specified: module registry, rail, resizable/collapsible tree column, URL-as-source-of-truth routing, unsaved-changes guard, cross-module preview bridge.
fix(pageops): make the bundle-rollback test root-proof
All checks were successful
ci / build-and-test (pull_request) Successful in 5m53s
6a689d91b0
TestEnsureBundleRollbackOnWriteFailure forced the index.md write to fail
by creating the bundle directory read-only (umask 0222 -> mode 0555).
Root ignores permission bits, so under CI's root runner the write
succeeded, no error came back, and the test failed at its first
assertion — red on every branch since the v1 rebuild.

Fail the write at a seam instead: EnsureBundle's write goes through
writeBundlePage, which the test substitutes with a failing writer. The
rollback is now exercised as root and as an ordinary user alike, and the
process-global syscall.Umask — which could leak into any concurrently
running test — is gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Owner

This was generated by AI while landing a PR.

CI is green — root cause was a root-hostile test, not this PR

TestEnsureBundleRollbackOnWriteFailure (internal/pageops/ensurebundle_test.go) forced the index.md write to fail by creating the bundle directory read-only (umask 0222 → mode 0555). Root ignores permission bits, so under CI's root runner the write succeeded, no error came back, and the test failed at its first assertion. Reproduced locally by re-running the test as root inside a user namespace (unshare -r): same failure, same line. That is why main and every branch off it has been red since the v1 rebuild (runs 20–23), while everything passed on developer machines.

Fixed in 6a689d9 on this branch: the write goes through a writeBundlePage seam that the test substitutes with a failing writer, so the rollback is exercised as root and as an ordinary user alike. The process-global syscall.Umask — which could leak into any concurrently running test — is gone. Verified the test still fails when the rollback is mutated away, so it retains its teeth.

CI run 24: success (5m53s).

The three blocking findings from the audit above still stand

Green CI does not clear them — they are frontend routing defects no Go test covers:

  1. TreePane.tsx:184 — nonexistent-page repair only runs while the tree column is mounted (collapsed tree ⇒ permanent error pane, which #33 forbids)
  2. selection.ts:111 — the repair pushes instead of replacing, trapping the back button
  3. App.tsx:106 — an unknown language gets none of the repair an unknown module gets

They share one root cause: route repair lives in a conditionally-mounted component instead of the shell.

> *This was generated by AI while landing a PR.* ## CI is green — root cause was a root-hostile test, not this PR `TestEnsureBundleRollbackOnWriteFailure` (`internal/pageops/ensurebundle_test.go`) forced the `index.md` write to fail by creating the bundle directory read-only (`umask 0222` → mode 0555). **Root ignores permission bits**, so under CI's root runner the write succeeded, no error came back, and the test failed at its first assertion. Reproduced locally by re-running the test as root inside a user namespace (`unshare -r`): same failure, same line. That is why `main` and every branch off it has been red since the v1 rebuild (runs 20–23), while everything passed on developer machines. Fixed in `6a689d9` on this branch: the write goes through a `writeBundlePage` seam that the test substitutes with a failing writer, so the rollback is exercised as root and as an ordinary user alike. The process-global `syscall.Umask` — which could leak into any concurrently running test — is gone. Verified the test still fails when the rollback is mutated away, so it retains its teeth. CI run 24: **success** (5m53s). ## The three blocking findings from the audit above still stand Green CI does not clear them — they are frontend routing defects no Go test covers: 1. `TreePane.tsx:184` — nonexistent-page repair only runs while the tree column is mounted (collapsed tree ⇒ permanent error pane, which #33 forbids) 2. `selection.ts:111` — the repair pushes instead of replacing, trapping the back button 3. `App.tsx:106` — an unknown language gets none of the repair an unknown module gets They share one root cause: route repair lives in a conditionally-mounted component instead of the shell.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle!34
No description provided.