feat(editor): header slot for host navigation, compact preview status when hosted #141

Merged
dominik.polakovics merged 1 commit from afk/139 into main 2026-10-04 09:13:04 +02:00

Closes #139

What this does

Gives the editor's header a slot for host navigation. A host that reverse-proxies the editor answers GET /api/host on the editor's origin, and the editor renders the host's brand, breadcrumb segments with switcher menus, sections and account menu in its own header. With a host present the Hugo status becomes a compact dot with a short label.

Standalone deckle is unchanged. With no host the header is pixel-for-pixel what it is on main.

The contract

deckle owns it, and the README section Host navigation (after "Serving the preview behind a reverse proxy") is the document a host builds against. It covers the path, the version 1 shape with a field reference, every validation rule, how the header renders the data, link rules, the compact status, safety and versioning.

The proposed shape from the issue is kept. These points were refined while implementing, and all are documented:

  • Links. href must start with http:// or https:// and parse as a URL. An entry whose href is present but not acceptable is dropped. An entry with no href is shown as plain text.
  • Empty menus. A group with no valid item is dropped. A menu with no group and no footer entry counts as no menu, so the segment has no arrows.
  • A bare {"version": 1} counts as a host being present. The header switches to its hosted form and shows whichever parts were sent.
  • Same origin, strictly. The request is sent in same-origin mode, so a redirect to another origin fails and counts as no host.
  • Overlapping requests. The newest answer wins. An older answer is used only while the editor has no host data yet.

How it is built

  • web/src/api/host.ts: the sanitized types, the sanitizer and the fetch. It never throws.
  • web/src/stores/hostNav.ts: holds the data in a Solid store and merges refreshes in place, so an open menu survives a refresh.
  • web/src/publish.ts: an onFinal hook on the feedback store. Page publish and Publish all both trigger the refresh through it.
  • web/src/components/HostNav.tsx: brand, segments, sections, account, marks, and one shared disclosure for the menus.
  • web/src/components/StatusPill.tsx: a compact variant. Without it the DOM is exactly as before.
  • web/src/App.tsx: the wiring, and the guard for host links.
  • web/src/leave.ts: the one place the editor navigates the tab itself, as a seam for tests.
  • internal/server: a comment and TestHostNavigationPathIsReserved. The harness gained no handler, flag or option.

Acceptance criteria

Criterion Evidence
No host: today's header, and a pinned 404 TestHostNavigationPathIsReserved; HostHeader.test.tsx "no host"; header screenshots identical to main in Chromium
Valid data: brand, segments, sections, account in order; chip and Publish all unchanged HostHeader.test.tsx "valid host data"
Arrows only with a menu; no href is not a link HostNav.test.tsx, HostHeader.test.tsx "segments and sections"
Current section and current item marked, also for assistive technology aria-current asserted in both test files
Filter above five items; close on Escape, outside click, choice; keyboard reachable HostNav.test.tsx filter, closing and keyboard blocks; keyboard-only run in Chromium
404, network error, non-JSON, invalid body, unknown version leave today's header, silently HostHeader.test.tsx table of eight failures; host.test.ts
Bad href dropped; bad color falls back; markup shown literally host.test.ts; HostHeader.test.tsx "hostile data"
Host link with unsaved changes opens the existing dialog; Save or Discard completes it HostHeader.test.tsx "unsaved changes"; verified in Chromium, including that no native prompt appears after Discard
Data requested again after a publish finishes HostHeader.test.tsx "refresh"; publish.test.ts onFinal
Compact status in three states, details in the tooltip; pill unchanged without host StatusPill.test.tsx; HostHeader.test.tsx "compact status"
Contract documented next to the reverse-proxy section README.md
Tests cover each criterion 581 new frontend tests and one Go test

Verification

  • npm test: 55 files, 1,556 tests pass. tsc --noEmit is clean. npm run build succeeds.
  • go vet ./... and go test ./... pass.
  • Real-browser run in headless Chromium against the built harness behind a small proxy that plays the host. Compared with a harness built from main: the standalone header has identical HTML and identical pixels in all three Hugo states, with and without a publish chip, at 1280 and 800.
  • An independent review of the diff found no high-severity defect. Its findings and the browser findings were fixed and re-verified.

Chromium is the only browser that was tested.

Decisions a reviewer should know about

  • Second row at 900px. The sections move to a second row at a window width of 900px or less, as the mockup does. It is a fixed breakpoint, not a measurement of whether they fit. Above 900px the strip scrolls sideways when it is squeezed.
  • Standalone header until the answer arrives. A hosted editor shows the standalone header for the moment before /api/host answers, then switches in place. Hiding the header until then would have changed standalone's first paint.
  • A 404 in the console. In standalone the browser logs the 404 for /api/host once per load and once after each publish. That follows from the contract.
  • Refresh timing. The data is requested when deckle's own publish feedback ends. A host whose deployment finishes later shows its state as of that request.
  • Status marks differ in shape. ok is a dot, busy a spinning ring, error a diamond, locked a lock, none a hollow ring, each with a text alternative. The mockup drew all but locked as dots, which differ by color only.
  • Publish chip. PublishChip now wraps its message in a span with the full text as title. Standalone pixels are unchanged. In the hosted bar a long message shortens with an ellipsis so the breadcrumb keeps its room.
  • Narrow windows. The hosted bar is never wider than the window. Names truncate to a stub and then the breadcrumb scrolls sideways. The stub relies on contain: inline-size, and the chip's shortening on :has().
  • scaffold/site/AGENTS.md is untouched. Nothing it documents changed: no command, flag, sidecar schema or scaffold layout.

Out of scope, as the issue says

No knowledge of organizations, sites, roles or any host product in deckle's code. No harness flags. No change to publishing behavior, the module rail, the tree or any module.

Found along the way

#140: in the media module, Escape in a dialog also closes the details panel behind it. It exists on main and is not changed here.

🤖 Generated with Claude Code

Closes #139 ## What this does Gives the editor's header a slot for host navigation. A host that reverse-proxies the editor answers `GET /api/host` on the editor's origin, and the editor renders the host's brand, breadcrumb segments with switcher menus, sections and account menu in its own header. With a host present the Hugo status becomes a compact dot with a short label. Standalone deckle is unchanged. With no host the header is pixel-for-pixel what it is on `main`. ## The contract deckle owns it, and the README section **Host navigation** (after "Serving the preview behind a reverse proxy") is the document a host builds against. It covers the path, the version 1 shape with a field reference, every validation rule, how the header renders the data, link rules, the compact status, safety and versioning. The proposed shape from the issue is kept. These points were refined while implementing, and all are documented: - **Links.** `href` must start with `http://` or `https://` and parse as a URL. An entry whose `href` is present but not acceptable is dropped. An entry with no `href` is shown as plain text. - **Empty menus.** A group with no valid item is dropped. A menu with no group and no footer entry counts as no menu, so the segment has no arrows. - **A bare `{"version": 1}`** counts as a host being present. The header switches to its hosted form and shows whichever parts were sent. - **Same origin, strictly.** The request is sent in same-origin mode, so a redirect to another origin fails and counts as no host. - **Overlapping requests.** The newest answer wins. An older answer is used only while the editor has no host data yet. ## How it is built - `web/src/api/host.ts`: the sanitized types, the sanitizer and the fetch. It never throws. - `web/src/stores/hostNav.ts`: holds the data in a Solid store and merges refreshes in place, so an open menu survives a refresh. - `web/src/publish.ts`: an `onFinal` hook on the feedback store. Page publish and Publish all both trigger the refresh through it. - `web/src/components/HostNav.tsx`: brand, segments, sections, account, marks, and one shared disclosure for the menus. - `web/src/components/StatusPill.tsx`: a `compact` variant. Without it the DOM is exactly as before. - `web/src/App.tsx`: the wiring, and the guard for host links. - `web/src/leave.ts`: the one place the editor navigates the tab itself, as a seam for tests. - `internal/server`: a comment and `TestHostNavigationPathIsReserved`. The harness gained no handler, flag or option. ## Acceptance criteria | Criterion | Evidence | | --- | --- | | No host: today's header, and a pinned 404 | `TestHostNavigationPathIsReserved`; `HostHeader.test.tsx` "no host"; header screenshots identical to `main` in Chromium | | Valid data: brand, segments, sections, account in order; chip and Publish all unchanged | `HostHeader.test.tsx` "valid host data" | | Arrows only with a menu; no `href` is not a link | `HostNav.test.tsx`, `HostHeader.test.tsx` "segments and sections" | | Current section and current item marked, also for assistive technology | `aria-current` asserted in both test files | | Filter above five items; close on Escape, outside click, choice; keyboard reachable | `HostNav.test.tsx` filter, closing and keyboard blocks; keyboard-only run in Chromium | | 404, network error, non-JSON, invalid body, unknown version leave today's header, silently | `HostHeader.test.tsx` table of eight failures; `host.test.ts` | | Bad `href` dropped; bad color falls back; markup shown literally | `host.test.ts`; `HostHeader.test.tsx` "hostile data" | | Host link with unsaved changes opens the existing dialog; Save or Discard completes it | `HostHeader.test.tsx` "unsaved changes"; verified in Chromium, including that no native prompt appears after Discard | | Data requested again after a publish finishes | `HostHeader.test.tsx` "refresh"; `publish.test.ts` `onFinal` | | Compact status in three states, details in the tooltip; pill unchanged without host | `StatusPill.test.tsx`; `HostHeader.test.tsx` "compact status" | | Contract documented next to the reverse-proxy section | `README.md` | | Tests cover each criterion | 581 new frontend tests and one Go test | ## Verification - `npm test`: 55 files, 1,556 tests pass. `tsc --noEmit` is clean. `npm run build` succeeds. - `go vet ./...` and `go test ./...` pass. - Real-browser run in headless Chromium against the built harness behind a small proxy that plays the host. Compared with a harness built from `main`: the standalone header has identical HTML and identical pixels in all three Hugo states, with and without a publish chip, at 1280 and 800. - An independent review of the diff found no high-severity defect. Its findings and the browser findings were fixed and re-verified. Chromium is the only browser that was tested. ## Decisions a reviewer should know about - **Second row at 900px.** The sections move to a second row at a window width of 900px or less, as the mockup does. It is a fixed breakpoint, not a measurement of whether they fit. Above 900px the strip scrolls sideways when it is squeezed. - **Standalone header until the answer arrives.** A hosted editor shows the standalone header for the moment before `/api/host` answers, then switches in place. Hiding the header until then would have changed standalone's first paint. - **A 404 in the console.** In standalone the browser logs the 404 for `/api/host` once per load and once after each publish. That follows from the contract. - **Refresh timing.** The data is requested when deckle's own publish feedback ends. A host whose deployment finishes later shows its state as of that request. - **Status marks differ in shape.** `ok` is a dot, `busy` a spinning ring, `error` a diamond, `locked` a lock, `none` a hollow ring, each with a text alternative. The mockup drew all but `locked` as dots, which differ by color only. - **Publish chip.** `PublishChip` now wraps its message in a span with the full text as `title`. Standalone pixels are unchanged. In the hosted bar a long message shortens with an ellipsis so the breadcrumb keeps its room. - **Narrow windows.** The hosted bar is never wider than the window. Names truncate to a stub and then the breadcrumb scrolls sideways. The stub relies on `contain: inline-size`, and the chip's shortening on `:has()`. - **`scaffold/site/AGENTS.md` is untouched.** Nothing it documents changed: no command, flag, sidecar schema or scaffold layout. ## Out of scope, as the issue says No knowledge of organizations, sites, roles or any host product in deckle's code. No harness flags. No change to publishing behavior, the module rail, the tree or any module. ## Found along the way #140: in the media module, Escape in a dialog also closes the details panel behind it. It exists on `main` and is not changed here. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(editor): header slot for host navigation, compact preview status when hosted
All checks were successful
ci / build-and-test (pull_request) Successful in 7m4s
c90b8683a3
When deckle runs behind a host that reverse-proxies the editor, the host
can now put its navigation into the editor's own header, so the user sees
one bar across both products. Standalone deckle is unchanged: with no
host the header is pixel-for-pixel what it was.

The contract (documented in the README, "Host navigation"):

- The editor requests GET /api/host once on load, same-origin and
  relative, in same-origin mode. A host answers it; the harness never
  implements it and keeps answering 404, which means "no host". A test
  pins that 404.
- Anything but a 2xx with a valid version-1 body leaves the standalone
  header in place, silently: 404, network error, non-JSON, invalid body,
  unknown version.
- The body is untrusted. A sanitizer builds the data the UI renders:
  only http(s):// links, only #rrggbb colors, text rendered as text,
  entries with a bad link dropped.
- After a publish started from the editor reaches a final state the data
  is requested again; a failed refresh keeps the last good data, and a
  successful one updates the header in place.

The hosted header shows, left to right: the brand in place of the
wordmark, the segments (names navigate, arrows open a switcher menu with
groups, current item, footer and a filter above five items), the
sections with the current one marked, the unchanged publish chip and
Publish all, the compact preview status, and the account menu. Menus are
a keyboard-operable disclosure and close on Escape, outside press,
focus leaving and choice. The bar never grows wider than the window:
names truncate, sections scroll and move to a second row at 900px.

Host links are plain same-tab links. With unsaved changes they go
through the existing unsaved-changes dialog; Save or Discard then
follows the link without the browser's own leave prompt on top.

With a host present the Hugo pill becomes a dot with a short label
(Preview / Preview starting / Preview crashed); state, restarts, version
and binary source move into the tooltip and the accessible name.

Closes #139

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Author
Owner

[autoland] verdict: pass

This was generated by AI while landing a PR.

Verdict: PASS — validated against #139, awaiting the maintainer's explicit confirmation before merge.

  • Signal relied on: ci / build-and-test (pull_request) is green (frontend build and vitest, go vet, go test, go build). It was not re-run. CI has no type-check step, so tsc --noEmit was run separately on the head (c90b868): clean.
  • Conventions: Conventional Commits title; Closes #139 present. scaffold/site/AGENTS.md is rightly untouched: no command, flag, schema or scaffold layout changed.
  • Conflicts: none. The head sits directly on main (9fb6f57).
  • Diff review: all non-test source read (web/src/api/host.ts, stores/hostNav.ts, components/HostNav.tsx, StatusPill.tsx, PublishChip.tsx, App.tsx, publish.ts, leave.ts, api/client.ts, styles.css, internal/server, README). The sanitizer accepts only absolute http(s) hrefs, six-digit hex colors and the enumerated statuses, and host text is rendered as text. The standalone path changes only by one extra GET /api/host, a span around the publish chip message, and additive CSS scoped to the hosted header. The diff matches the issue's contract and stays in scope.

Notes, none blocking:

  • Sections breakpoint. The sections move to a second row at a fixed 900px window width, not at the point where they stop fitting as the issue words it. Above 900px the strip scrolls sideways in place. The PR discloses this and it follows the mockup.
  • Browser evidence is the author's. Pixel identity of the standalone header and the real-browser run were verified by the author in Chromium only. CI pins the DOM in jsdom, not pixels. The hosted CSS relies on :has() and contain: inline-size.
  • Console 404. Standalone deckle now logs one 404 for /api/host per load and per finished publish. That follows from the contract in the issue.
[autoland] verdict: pass > *This was generated by AI while landing a PR.* **Verdict: PASS** — validated against #139, awaiting the maintainer's explicit confirmation before merge. - **Signal relied on:** `ci / build-and-test (pull_request)` is green (frontend build and vitest, `go vet`, `go test`, `go build`). It was not re-run. CI has no type-check step, so `tsc --noEmit` was run separately on the head (c90b868): clean. - **Conventions:** Conventional Commits title; `Closes #139` present. `scaffold/site/AGENTS.md` is rightly untouched: no command, flag, schema or scaffold layout changed. - **Conflicts:** none. The head sits directly on `main` (9fb6f57). - **Diff review:** all non-test source read (`web/src/api/host.ts`, `stores/hostNav.ts`, `components/HostNav.tsx`, `StatusPill.tsx`, `PublishChip.tsx`, `App.tsx`, `publish.ts`, `leave.ts`, `api/client.ts`, `styles.css`, `internal/server`, README). The sanitizer accepts only absolute http(s) hrefs, six-digit hex colors and the enumerated statuses, and host text is rendered as text. The standalone path changes only by one extra `GET /api/host`, a span around the publish chip message, and additive CSS scoped to the hosted header. The diff matches the issue's contract and stays in scope. Notes, none blocking: - **Sections breakpoint.** The sections move to a second row at a fixed 900px window width, not at the point where they stop fitting as the issue words it. Above 900px the strip scrolls sideways in place. The PR discloses this and it follows the mockup. - **Browser evidence is the author's.** Pixel identity of the standalone header and the real-browser run were verified by the author in Chromium only. CI pins the DOM in jsdom, not pixels. The hosted CSS relies on `:has()` and `contain: inline-size`. - **Console 404.** Standalone deckle now logs one 404 for `/api/host` per load and per finished publish. That follows from the contract in the issue.
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!141
No description provided.