Skip to content

Dedupe the dashboard+login UI and split the framework CSS into layered thematic files - #283

Merged
admonstrator merged 40 commits into
nightlyfrom
claude/dashboard-login-dedup-f2661a
Aug 12, 2026
Merged

Dedupe the dashboard+login UI and split the framework CSS into layered thematic files#283
admonstrator merged 40 commits into
nightlyfrom
claude/dashboard-login-dedup-f2661a

Conversation

@admonstrator

Copy link
Copy Markdown
Owner

What changed

An audit of the dashboard + login surface (6 parallel inventory agents + a consolidation pass, every finding verified against the code) surfaced ~60 duplications across CSS, JS and EJS. This PR consolidates all P1/P2 findings and restructures the framework so the duplication classes cannot silently return. Eleven commits, each independently green and reviewable:

Deduplication (commits 1–3)

  • .zr-setup* existed in full in both stylesheets with a conflicting min-height; the stacked-field re-declarations forced three specificity-repair rules; the legacy .modal blocks were doubled — all merged. Nine verified-unused rules, two unreachable JS modules (one with German mock strings) and dead themeToggle ids deleted.
  • escapeHtml existed six times (three incomplete); now one export in modules/text-utils.js. The dashboard's second legend template interpolated labels raw — both legends now render through one always-escaping helper. Toast, theme, cookie and time-ago logic each exist once; login/setup/setup-error use the shell head partials; one nav data source renders rail + tabbar (render-diff: byte-identical rail, whitespace-only tabbar); .login-step/.setup-step merged into .zr-steppane.

Thematic split under @layer (commits 4–5)

  • zr.css (2590 lines) → 11 thematic files, zr-pages.css (1120 lines) → 9 page files, all inside a declared layer order (tokens, base, shell, components, modules, pages, utilities). Priorities are now explicit instead of source-order accidents: layout primitives sit in base so components override them without selector doubling; state utilities win over everything.
  • Rule preservation is proven, not assumed: multiset diffs of (context, selector, declarations) show zero unexpected differences; computed-style A/B over full DOM trees (real pages + a synthetic kitchen-sink page, both themes, two widths) is hash-identical. The one real cascade casualty of layering (zr-faint beating the OCR search error colour) is fixed in markup.

Guardrails (commits 6–8)

  • Stylelint in CI (changed-files pattern like ESLint/Prettier): duplicate selectors/properties and unquoted font names now fail the build. It caught its first real bug during setup — a doubled body rule from the split.
  • /styleguide (dev builds only, authenticated, not in the nav): every component rendered once with the app's real markup; charts mount live via their declarative entry points.

Re-audit + remediation (commits 9–11)

  • A closing re-audit with the same agent setup confirmed the dashboard+login surface is clean and flagged two pre-existing P2s beyond the original scope, fixed here: the four queue pages each carried a verbatim showToast (drift already begun) — now thin adapters over the kernel toast (−204 lines, plus a latent bug removed where hidden hosts would have swallowed kernel toasts); the GitHub bug-report prefill incl. the secret-redaction regexes existed twice — now one implementation with a byte-identical URL parity proof (and an entity-escaping bug in the sidebar copy gone).
  • The dark-mode image inversion had been an invalid declaration since the UI migration (invert() was handed a colour token); restored to invert(1) with repaired opt-outs (logos, mascots, and newly the MFA QR codes).

Why

Maintainable UI code needs central definitions. The audit showed the old structure invited drift: source-order/specificity workarounds and components being reinvented invisibly. The layer contract, stylelint and the styleguide address the causes, not just the instances.

Notes for review

  • Intended behaviour deltas (each documented in its commit): .modal-overlay scrim tone unified; danger toasts announce role="alert"; queue-page toasts stack, dismiss on click, use the kernel icon/timeout; an expired session during scan start/stop now shows the failure toast instead of a false success; the changelog dialog title inherits the dialog-head weight; setup-error gains the favicon.
  • Test suite: 62 passed / 2 skipped (env-gated) / 1 failed — rate-limiting, which fails identically on an untouched main checkout against a live instance on :3000 (environmental, pre-existing).
  • Four tests read zr.css directly; they now load the split files through tests/framework-css.js in link order.
  • package.json changes trigger docker-check.yml (build-only) as expected. OpenAPI: only the new dev-only /styleguide path (regen is idempotent).
  • Deferred by choice: ~28 P3 cosmetic findings (tracked outside the repo per the no-changelog-files convention) and the pre-existing phantom-class bug in settings-scripts.ejs (separate task).

🤖 Generated with Claude Code

admonstrator and others added 30 commits August 10, 2026 18:56
…ramework

Background

The UI had no shared foundation. Layout, theming and components were solved
three times in parallel: CSS custom properties on [data-theme], Tailwind `dark:`
variants, and hand-written classes. Every page loaded the Tailwind Play CDN
compiler (451 KB), which translated utility classes in the browser on each page
view, plus FontAwesome (74 KB CSS + 158 KB woff2), Chart.js (195 KB), three TTF
font families and — on History — jQuery with DataTables (175 KB). A dashboard
view cost roughly 1.3 MB of framework and vendor code before a single number was
fetched. Layout scaled badly in both directions: a third of a 1600 px viewport
stayed empty because the grid did not grow, while only 1.5 cards fit on a 375 px
phone.

Alpine, highlight.js, marked and jsonview were shipped but never loaded by any
view. Shepherd was likewise never loaded, yet setup-scripts.ejs called
`new Shepherd.Tour(...)` — the guided tour threw a ReferenceError on every run.
public/js/manual.js was dead too: no view referenced it.

Changes

Framework
- public/css/zr.css: the whole system in one stylesheet, no build step. Tokens
  as custom properties drive both themes and the entire spacing/type scale.
  Layers: reset, tokens, shell, grid + modules, components, utilities,
  container queries, viewport breakpoints.
- public/css/zr-pages.css: the page-level pieces (OCR toolbar, history table,
  playground, auth pages, changelog timeline), all on the same tokens. Replaces
  eleven separate stylesheets.
- public/js/zr.js: ~4 KB kernel. Scans the DOM for [data-module] and imports
  only the ES modules the current page declares, so a page pays for its own
  modules and nothing else. Also owns theme, drawer, rail and toasts.
- public/js/modules/*: counter, spark, donut, bar-list, data-table, dashboard,
  scanner-health, section-nav, settings-form, tag-input, wizard, scan-status.
- public/icons.svg: ~70 stroke icons as a sprite, inheriting currentColor.
- public/js/dialogs.js: window.zrDialog / zrToast on top of native <dialog>.

Removed
- Tailwind runtime compiler, FontAwesome, Chart.js, SweetAlert2, jQuery,
  DataTables, and the never-loaded Alpine, highlight.js, marked, jsonview and
  Shepherd bundles.
- Manrope and Sora font families — UI text now uses the system stack, Outfit
  stays for the wordmark and headings.
- Dead views (index, layout, template), dead scripts (dashboard.js, manual.js,
  dashboard-scripts.ejs) and the Shepherd tour code.
- public/ drops from 6.5 MB to 844 KB.

Rebuilt
- Shell: rail with grouped navigation, collapsible to 56 px, sticky top bar,
  off-canvas drawer plus a bottom tab bar below 860 px.
- Dashboard: KPI row and a 12-column module grid. Duplicated figures removed
  ("Processed Today" and "Total Documents" each appeared twice; "Operations
  Snapshot" was a third copy). Charts are now SVG modules instead of Chart.js.
- Settings: section list instead of six horizontal tabs, ENV key on every field,
  sticky save bar that counts unsaved changes and flags restart-required edits.
- Setup: two-column layout with a vertical stepper — all seven steps visible at
  once instead of "Step 1 of 7".
- Login and setup-error rebuilt on the framework. setup-error.css carried a bare
  `body` rule that would have leaked onto every page once merged; it is scoped
  to the page now.
- History: DataTables replaced by public/js/modules/data-table.js, which speaks
  the same server-side protocol the API already served. Below 860 px rows render
  as cards instead of scrolling sideways.

Testing
- node scripts/run-tests.js --all: 54 passed, 6 skipped (need a running server),
  0 failed.
- npx eslint public/js and npx prettier --check on every touched file: clean.
- node scripts/regen-openapi.js: no drift.
- All 11 views rendered and reviewed in a browser at 1440 px and 390 px, both
  themes, against a mock API.
- Two tests were updated to follow moved code, keeping their assertions:
  test-paperless-unreachable-banner now imports the shipped scanner-health
  module instead of scraping a <script> block out of an EJS partial, and
  test-setup-wizard-quickstart stubs zrDialog instead of Swal.

Impact

A dashboard view now loads about 54 KB of framework code instead of ~1.3 MB.
eslint.config.mjs gained a block marking public/js as ES modules. No API,
route or data change; DOM ids were preserved so the page scripts keep working.

Upstream Status

Not applicable — this fork's UI only.
The shell renders `pageTitle` in the top bar, but history, ocr, failed,
ignored, about, manual and playground only passed it to the <head>. Every one
of them showed the fallback "Zettelrobbe" instead of the page name.
Background

The first pass ported the framework, shell, dashboard, settings, setup and
login. History, OCR Queue, Failed, Ignored, About, Manual and Playground had
their icons and the obvious utility classes migrated, but their page headers
still came from the old content-header structure and roughly 1300 Tailwind
utility occurrences were left in place. With Tailwind gone those classes do
nothing, so the headers, buttons and form controls on those pages sat wrong.

Changes

- Page headers rebuilt on .zr-viewhead. Where the top bar already names the
  page, the duplicate <h1> is gone and the description became the subtitle.
- History: filters, bulk actions and the reconciliation note are separated
  instead of crowding one row.
- All remaining Tailwind utilities replaced — buttons and anchors mapped onto
  .zr-btn by intent (red to danger, blue/green to primary), form controls onto
  .zr-input / .zr-select, pills onto .zr-badge, progress bars onto .zr-meter.
  No `px-*`, `bg-*-500` or `text-*-500` class is left in any view.
- The components those pages still needed were recovered from the retired
  dashboard.css — modal, toolbars, document search dropdown, status badges,
  detail lists — and rebased on the framework tokens in zr-pages.css.
- New framework pieces: .zr-link, .zr-panel, .zr-label, .zr-overlay, plus
  padding and header treatment for modules whose content is not wrapped in
  __body, which is what left About and Manual looking unstructured.

Fixed along the way

- views/manual.ejs still loaded the deleted /js/dashboard.js — a 404 plus a
  MIME-type refusal on every visit.
- partials/scripts/login-scripts.ejs drove the theme toggle by rewriting an <i>
  element's className. The icons are SVG now, so it threw
  "Cannot set properties of null" on login and setup. The kernel already owns
  the toggle through data-action="theme", so the partial is gone.
- public/js/login.js read the retired window.__paperlessAiGetTheme; it uses
  window.zrTheme now.

Testing

- node scripts/run-tests.js --all: 54 passed, 6 skipped, 0 failed.
- npx eslint public/js and npx prettier --check on the touched files: clean.
- node scripts/regen-openapi.js: no drift.
- All 11 views rendered and reviewed in a browser against a mock API; every
  referenced script and stylesheet verified to exist.

Impact

public/ is down to 836 KB. No API, route or data change.

Upstream Status

Not applicable — this fork's UI only.
Background

Both earlier passes only touched .ejs files. Everything the page scripts build
at runtime — table rows, toasts, badges, the playground rating dialog — still
carried Tailwind classes and FontAwesome icons, neither of which ships any more.
A review of the branch turned up 153 such class strings and 77 icon references
across seven files. They stayed invisible during the first review because the
queue tables were only ever seen empty; with rows they render unstyled and with
blank icons.

Fixed

- Settings tag chips: settings.js created chips as `bg-blue-100 …` and looked
  them up with `.bg-blue-100`, while the server rendered them as `.zr-chip`.
  Server-rendered tags were therefore invisible to `initializeExistingTags()`
  and to `updateHiddenInput()`, so **saving the form dropped every tag that came
  from the config** and their remove buttons did nothing. Both the created class
  and all three selectors are `.zr-chip` now.
- playground-analyzer.js `showMessage()` dereferenced
  `document.querySelector('.material-card')` without a null check — the class no
  longer exists, so the first message on that page threw a TypeError. It now
  renders a `.zr-alert` and falls back to the view container.
- The model-test rows in settings.js and setup.js created an `<i>` element for
  their status icon. They build a real SVG sprite reference now.
- 77 FontAwesome icons across ocr, settings, failed, setup, history,
  playground-analyzer and ignored replaced with sprite references.
- Retired CSS variables (`--bg-primary`, `--border-color`, `--accent-primary`,
  …) still referenced from inline styles in playground-analyzer.js mapped onto
  the framework tokens.
- `toolbar-btn`, `material-card` and `card-title` remnants in generated markup
  mapped onto the framework classes.

Cleanup

- Dropped CSS rules for libraries that no longer ship: DataTables wrappers,
  Chart.js containers, and the old dashboard activity list — 64 lines.
- Translated the four German comments in playground-analyzer.js; the repo is
  English-only.
- New framework helpers the generated markup needs: .zr-danger-text,
  .zr-ok-text, .zr-warn-text, .zr-detail-label, .zr-modal-center, .zr-modal-card.

Testing

- node scripts/run-tests.js --all: 54 passed, 6 skipped, 0 failed.
- npx eslint public/js clean — it caught one real regression during this pass:
  a block replacement had swallowed `resultMessage.textContent = text`, which
  would have left the MFA result message blank. Restored.
- npx prettier --check clean on all touched files.
- node --check on every JS file.
- OCR Queue, History, Ignored verified in a browser with populated tables
  against mock endpoints — that is the case the earlier review missed.

Impact

Zero Tailwind classes and zero FontAwesome references remain anywhere in the
codebase. public/ is at 828 KB. No API, route or data change.

Upstream Status

Not applicable — this fork's UI only.
Background

The setup wizard still looked half-migrated: field widths jumped between 460,
560 and full width, labels sat at different heights within one row, and thin
rules appeared under some fields but not others. The cause was leftover Tailwind
utilities that earlier passes missed because they matched whole class strings —
any variant with an extra token ("space-y-2 mt-4", "grid grid-cols-1 md:grid-
cols-2 gap-4 mt-4") slipped straight through.

Changes

- Cleanup is token-based now, so class order and extra tokens no longer matter.
  Applied to every view and partial; EJS expressions inside class attributes
  were masked and restored (verified: 11 before, 11 after in settings.ejs).
- Fields in the wizard panel share one width, so they line up on a single edge.
- .zr-formgrid is a fixed two-column grid instead of auto-fit, which silently
  became three columns on a wide panel. Fields that need the whole row use
  .zr-formgrid__wide (the former md:col-span-2).
- .zr-field--stacked drops the inherited divider and padding; a rule under every
  field read as noise. The field spacing rule is scoped to direct children so it
  no longer pushes grid cells out of alignment.
- The restart and model-test overlays were still pure Tailwind. They use a new
  .zr-fullscreen component now.
- Remaining dynamic FontAwesome icons replaced: the changelog category map in
  settings.ejs and the alert built in manual-scripts.ejs, which now also sets
  its message as text rather than interpolating it into markup.
- The <html> tag kept `class="h-full … dark"` on seven views; data-theme is the
  only theme signal, so it is gone.
- Added the rules the remaining page classes needed: .card-grid,
  .tooltip-content, .custom-field-item, .tech-details, .modal-loader/.modal-data.

Testing

- Every class used in every view now has a matching CSS rule — verified by
  cross-checking all class attributes against both stylesheets.
- node scripts/run-tests.js --all: 54 passed, 6 skipped, 0 failed.
- npx eslint public/js and npx prettier --check clean.
- All 11 views render; setup reviewed step by step in a browser.

Impact

No API, route or data change. Zero utility-class or FontAwesome leftovers
remain in views, partials or page scripts.

Upstream Status

Not applicable — this fork's UI only.
Background
Every page so far had only been reviewed against the mock preview renderer.
Running the app against a real Paperless-ngx instance exposed a set of defects
that mock data could not show: modal dialogs anchored to the top-left corner,
buttons stuck in their loading state, and several places where the migration
away from Tailwind left classes behind that no longer resolve to anything.

Changes
- Modal dialogs render centred again. The layer-1 reset zeroes every margin,
  which also killed the `margin: auto` a modal <dialog> needs to centre itself.
- Connection and model tests reset their button state as soon as the request
  returns, instead of after the user dismisses the result popup. Ten call sites
  in setup.js awaited showPopup() inside the try block, so the finally clause
  that cleared the spinner only ran on dismissal.
- Icons no longer claim their own line. The reset makes every svg a block; in a
  flex parent that is irrelevant, but an icon placed directly in a label, link
  or empty state broke onto a separate line. `.zr-icon` is inline-block now,
  which fixes the history detail modal, the empty states and the setup wizard
  in one place.
- Empty table states keep their padding: `.zr-table td` outranks `.zr-empty`,
  so they had fallen back to the tight generic cell padding.
- The history actions column is 280px wide; at 210px the three buttons wrapped
  and doubled every row's height.
- Both tag "Add" buttons in the setup wizard look alike. One was primary with
  an icon, the other plain, which left their input fields at different widths.
- Dead utility classes replaced with framework equivalents: font-mono, flex-1,
  flex/flex-wrap/items-center/gap-2 and text-violet-600 across the queue,
  failed, ignored and settings scripts.
- The playground is laid out again. #documentsGrid lost its class during the
  migration and stacked full-width cards; it uses .card-grid now. The thumbnail
  frame, tag overlay, rating modal backdrop and star row got real definitions
  instead of the Tailwind utilities they used to rely on.
- Restored what the migration dropped elsewhere: the manual document preview
  reserves its height again, the OCR progress heading is styled, the changelog
  modal title got its class back, and the history detail modal spaces its
  sections.
- Translated the remaining German comments in playground-analyzer.js.

Testing
- 57 passed, 2 skipped, 1 failed (node scripts/run-tests.js --all). The failure
  is rate-limiting, which expects limiter headers on /chat; this fork has no
  such route (only /api/chat/documents), so it fails regardless of this work
  and only became visible because a reachable server stops it being skipped.
  No server code was touched: git diff 16dd4fb..HEAD -- server.js routes/
  services/ config/ models/ is empty.
- ESLint clean, Prettier clean on every touched css/js file.
- Verified in the browser against a live instance with real documents:
  dashboard, history (incl. detail modal), failed, OCR queue, playground
  (incl. rating modal), manual and settings. Measured rather than eyeballed —
  dialog centring, column widths, icon baselines and field widths were read
  back from the DOM. The setup wizard was checked in the preview renderer,
  since a configured instance redirects /setup to /dashboard.

Impact
Visual and layout only. No API, schema or behaviour change, so the OpenAPI
spec is unaffected.

Upstream Status
Not applicable — this fixes the fork's own UI framework migration.
Background
The settings form mixed two controls for the same kind of decision: some
features were switched with a real toggle (.zr-switch), others with a tick box
in a bordered tile. Both mean "turn this on or off", so they should look and
behave alike.

Changes
- Added .zr-toggle: a switch rendered from the checkbox itself via
  appearance:none plus an ::after thumb. It matches .zr-switch pixel for pixel
  (34x19 track, 15px thumb, brand colour when on) but needs no track/thumb
  spans, so a plain checkbox becomes a switch by swapping one class.
- Converted the eight tiles in settings.ejs (RESTRICT_TO_EXISTING_* and
  ACTIVATE_*) and "Always scan all documents" in the setup wizard.
- A tile holding a toggle now follows the iOS/Android list pattern: label on
  the leading edge, switch on the trailing one.
- Row-selection checkboxes keep .zr-check. In a table row a tick is the
  expected control, and a switch would misrepresent a selection as a setting.
- Removed updateThemeClasses() and the MutationObserver that drove it. It
  restyled inputs for dark mode by toggling Tailwind classes, which no longer
  resolve to anything; it ran over every input on each theme change and left
  dead classes like "bg-gray-800 text-gray-100" on the elements. The framework
  themes everything through CSS variables bound to data-theme.
- Fields pinned by an environment variable are visibly disabled again. The
  utility classes that used to mark them are gone, so the framework now styles
  :disabled inputs, selects and textareas, and the dead classList.add() call
  was dropped.

Testing
- 57 passed, 2 skipped, 1 failed (node scripts/run-tests.js --all). The failure
  is the pre-existing rate-limiting test, which expects limiter headers on
  /chat — a route this fork does not have. Unrelated to this change.
- ESLint and Prettier clean.
- Verified in the browser against the live instance in both themes: toggles
  read 34x19 like the existing switch, flip on label click, restore, keep their
  form value, and the tile still reflects the checked state. Disabled styling
  was confirmed by measurement (background, text colour and not-allowed cursor
  all differ from the enabled state).

Impact
Visual and markup only; the checkboxes keep their ids, names and form values,
so saved configuration is unaffected. No API change.

Upstream Status
Not applicable — fork-specific UI work.
…etch

Background
Walking the wizard end to end against a live Paperless-ngx instance surfaced
three things the user asked about and several defects behind them. The step rail
could not actually answer "which step is done": a finished step rendered as an
empty green circle, was styled identically to a pending one, and lost its state
entirely when paging back. Loading Paperless metadata was a manual button that,
on a fresh install, fetches an empty tag list — and nagged twice if skipped. And
with "scan all documents" on, the include-tag field stayed on screen with a live
Add button, so a tag could be added, rendered as a chip, and submitted while the
env preview right next to it read TAGS=.

Changes
Step indicator
- Completion is tracked in a Set instead of being derived from the current
  index, so paging back no longer turns finished steps into pending ones.
- The check mark appears at all. `check.hidden = …` silently did nothing: the
  marker is an <svg>, and `hidden` is an HTMLElement property, so the attribute
  stayed put and the element kept `display: none`. Both marker parts now go
  through toggleAttribute.
- A finished row reads as finished: full-strength text, semibold name, green
  hint, and a connector line down to the next step — previously the only
  difference from a pending row was the fill of a 22px circle.
- Finished steps are clickable and pending ones are disabled, matching the
  affordance the rail has always shown (cursor, hover, focus ring).
- The rail is no longer desktop-only; below 861px it collapses to a row of
  markers. Phones previously got no step overview at all.
- Progress counts completed steps, so it reads 100% only once the setup is
  actually saved instead of on arrival at the review step.
- Added aria-current, role="progressbar" with aria-valuenow, and a live region
  on the step label.
- Rail names and step titles agreed on 2 of 7 steps; they now match everywhere.

Paperless metadata
- Loads automatically once the connection test succeeds, silently: no dialogs,
  failures only show in the pill. The button and the "Metadata not loaded"
  confirm are gone — it only ever fed tag suggestions, and a fresh Paperless
  install legitimately has no tags.
- That confirm also fired during finalizeSetup(), so quickstart users who never
  opened the step were asked about metadata while saving.

Include tags vs "scan all documents"
- The whole block is hidden while the switch is on, following the pattern
  settings.ejs already uses for the same decision, instead of disabling the
  input and leaving the label, hint and Add button live.
- Collected tags are kept so toggling back restores them, but they are no longer
  submitted while the switch is on, so the payload matches the env preview.
- populateInitialValues() applies the switch too; re-entering setup with an
  existing config used to show "scan all" with the include field still active.

Status pills and hints
- setPillState wrote `setup-pill`, and the password/MFA hints wrote
  `setup-hint` — none of which exist in either stylesheet. Every pill lost its
  background, padding and radius the moment it updated, so a successful test
  rendered as bare body text. They now rebuild .zr-badge / .zr-field__hint and
  add a state modifier.
- Hint state colours needed to match `.zr-field--stacked .zr-field__hint`,
  which outranked the plain utilities.

Smaller fixes
- Exclude chips were built with `<i class="zr-icon">` — a FontAwesome leftover
  that renders nothing — and `.setup-chip`, which does not exist. Both tag lists
  now share one chip builder that puts the tag in as a text node; the include
  chip previously went through innerHTML.
- The OCR timeout field advertised max="7200" while validation rejected
  anything above 120.
- finalizeSetup marks each gate it clears, so the quickstart path no longer
  leaves the rail frozen mid-wizard.

Testing
- 57 passed, 2 skipped, 1 failed (node scripts/run-tests.js --all). The failure
  is the pre-existing rate-limiting test, which expects limiter headers on
  /chat — a route this fork does not have.
- test-setup-wizard-tag-default asserted the old behaviour, including that
  populateInitialValues deliberately did *not* apply the switch. Updated to the
  corrected semantics and extended to cover section visibility and the Add
  button; the three setup stubs gained the new ids.
- ESLint and Prettier clean; no OpenAPI drift.
- Verified in a browser against a disposable stack (real Paperless-ngx with 3
  documents, mock AI provider) through all seven steps to a completed restart.
  Measured rather than eyeballed: check mark 14x14 instead of 0x0; finished row
  rgb(13,27,38) against pending rgb(90,107,119); status preserved as
  done,done,active,done,done after paging back; progress 85.7% before saving and
  100% after; metadata auto-loaded with counters filled and no dialog; include
  section hidden with the payload empty while the chip stayed in state.

Impact
Setup wizard only. No API, schema or persisted-config change — the same env keys
are written with the same values.

Upstream Status
Not applicable — fork-specific UI work.
Background

A full pass over the dashboard against a live instance — real Paperless-ngx,
populated statistics, both themes, desktop and phone — turned up a layout bug in
the framework itself plus a set of figures that disagreed with each other on the
same screen.

The cards in a grid row did not line up. `.zr-module + .zr-module` adds a top
margin so stacked modules get air, but nothing excluded the grid, where the gap
already does that job. Measured at 1280px: the first row started at y=176 and
y=188, and every row gap was 24px instead of 12px.

"AI processed" showed the all-time total, 10, next to "Documents 3", while the
processing donut two modules down showed 3 for the same metric and the coverage
line claimed 100%. The count survives deletions in Paperless-ngx, so the split
is the normal state between reconciliation runs, and only two of the three
places capped it.

The document-type donut cycled five semantic tones, so the sixth category
repeated the first — Contract and Statement were the same teal, in the chart and
in the legend. Beyond that, painting a plain document type in the red that means
"failed" one module over is misleading on its own.

The health banner carried both role="alert" and aria-live="assertive" and its
text was reassigned on every three-second poll. A live region announces on
mutation, identical string or not, so a screen reader was interrupted with the
same outage every three seconds.

A failed statistics load left the old numbers on screen behind a toast that
faded, and the endpoint was never retried — a hiccup at page load froze the
dashboard until a manual reload. `setModulesState()` was meant to cover this,
but no view has ever carried the `data-slot` markup its CSS keys off, so all it
did was write an attribute nothing reads.

Changes

- zr.css: grid children opt out of the stacked-module margin again
- zr.css: --zr-cat-1..8 for categorical charts, in both themes, kept apart from
  ok/warn/danger
- zr.css: .zr-list__sub, .zr-list__time and .zr-stat__delta move from
  --zr-text-faint (3.1:1 on white, below AA) to --zr-text-muted (5.5:1); these
  three carry a date, a correspondent and a coverage figure
- zr.css: .zr-freshness stays hidden on phones while everything is fine and
  appears when it is not
- dashboard.js: the processed count is capped once, so the tile, the coverage
  line and the donut agree; the raw total is disclosed as "N all-time" when the
  two differ
- dashboard.js: document types use the categorical palette, sorted by size, with
  the tail past seven grouped into "Other (n)" instead of reusing colours
- dashboard.js: "1 doc" instead of "1 docs", in both bar lists and the entity
  dialog
- dashboard.js: the "updated HH:MM:SS" line reports staleness per source and the
  statistics endpoint is retried every ten status polls while it is failing
- dashboard.js: dead setModulesState() removed; the scan button keeps its place
  and says "Starting…" instead of swapping to "Stop" before the server agrees
- spark.js: an empty trend draws a dashed baseline and the module says "No token
  trend yet." rather than leaving a blank 40px gap
- scanner-health.js: title and message are only written when they change
- dashboard.ejs: role="status" replaces role="alert" + aria-live="assertive"

Testing

- node scripts/run-tests.js --all → 57 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing: it expects headers on a /chat route this fork
  does not have, and only runs at all because a server was reachable)
- test-paperless-unreachable-banner grew three cases: no rewrite on an unchanged
  poll, a changed message still written, and the view using role="status".
  Verified they fail against the previous behaviour.
- Live walkthrough on a disposable stack with ten processed documents: row tops
  at 176/519/774 with margin 0, six distinct legend colours, tile and donut both
  reading 3, staleness label appearing and recovering for a broken status poll
  and a broken statistics endpoint, retry firing after ~33s and the dashboard
  refilling on recovery, empty-trend hint, phone layout at 390px
- npx eslint / npx prettier --check clean on every touched file
- No OpenAPI drift

Impact

Dashboard only — no other view uses .zr-grid, .zr-list__sub, .zr-list__time,
.zr-stat__delta or .zr-spark. The new --zr-cat-* tokens and .zr-freshness are
additions.

Upstream Status

Not applicable — the dashboard and the framework are fork-specific.
…UI code

Background

Follow-up to the dashboard review, working through the findings that were
reported but left open.

Every page view called api.github.com from the browser to compare release tags.
For a self-hosted application that hands the user's IP and referrer to a third
party on every view, and it spends GitHub's unauthenticated rate limit — 60
requests per hour per IP, shared by everyone behind the same NAT — on people who
simply keep a tab open.

The "Document types" chart never showed document types. getDocumentTypeStats()
read `substr(title, 1, instr(title || ' ', ' ') - 1)` from processed_documents,
the first word of the title. It happens to look plausible for "Invoice 2026-0041
…" and turns into noise for a title like "Jahresabrechnung 2025". The type the
AI actually assigned has been sitting in history_documents.document_type_name
all along.

--zr-text-faint reached 3.1:1 on white and 4.3:1 on dark, below AA for the 11-12
px it is used at. Lifting it alone would have left it indistinguishable from
--zr-text-muted, so both moved.

The Outfit regular face declared `font-weight: 400 700` — the syntax for a
variable font — on a static file, which made it a candidate for bold as well and
left two faces competing for the same weight. Both were TrueType.

Three pieces of the settings page had been dead since the framework migration.
Token validation toggled border-red-500 and border-green-500, so a rejected MFA
code looked exactly like an untouched field. SettingsHintManager searched
`#setupForm p.text-xs.text-gray-500`, matched nothing, and kept a MutationObserver
running over the whole settings form for it. The OCR overlay replaced the
progress bar's entire className on completion, dropping zr-meter__fill along with
the Tailwind classes it was swapping — the bar vanished at 100%.

Changes

- services/updateCheckService.js: server-side release lookup, cached 24h (1h
  after a failure), concurrent callers share one request, last known release
  survives a failed refresh, UPDATE_CHECK_ENABLED=no skips the call entirely
- GET /api/update-check behind isAuthenticated, upstream error stripped from the
  response; sidebar-badges.js reads it instead of GitHub
- models/document.js: document type stats come from
  history_documents.document_type_name, NULL grouped as "Unclassified"
- zr.css: --zr-text-muted #5a6b77 → #485760 and --zr-text-faint #8496a2 →
  #5f6f79 (dark: #9bacb8 → #a6b6c1, #71838f → #80909c); all three tiers now
  clear 4.5:1 against every surface in their theme
- zr.css: .zr-input--valid/--invalid (plus select and textarea), .zr-meter__fill--ok
  and .zr-prose for dialog help text
- Outfit shipped as WOFF2 at 20 KB per face instead of 55 KB TrueType, each face
  declared at its own weight
- settings.js: the two long field explanations open a dialog; the rest of the
  hint machinery, its observer and the Tippy plumbing are gone (-495 lines), and
  locked-env pills use a native title
- Dropped vendored Tippy and Popper (46 KB) and the orphaned setup-scripts.ejs
  partial, which no view had included since the migration
- ocr.js: the progress bar keeps its class and takes a tone modifier
- THIRD_PARTY_NOTICES.md matches what is actually vendored again — it still
  listed FontAwesome, Chart.js, jQuery, DataTables, SweetAlert2, Alpine,
  highlight.js, marked, jquery-jsonview and Shepherd, all removed in the
  framework migration, and never mentioned the vendored Outfit fonts

Testing

- node scripts/run-tests.js --all → 58 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing and unrelated)
- New test-update-check-service covers version comparison including unequal tag
  lengths, one request per day, concurrent callers sharing it, quiet failure,
  the opt-out, and a sweep asserting no shipped browser code contains
  api.github.com
- Live on a disposable stack: zero external requests from the dashboard, the
  endpoint returned the real latest tag and repeated calls kept the same
  checkedAt, only outfit-700.woff2 downloads, document types render the assigned
  types with seven distinct colours, zero text below 4.5:1 in either theme, the
  help dialogs render with themed code blocks, the MFA field goes red then green
  then neutral, the OCR bar keeps its shape at 100%
- eslint and prettier clean on every touched file; the 27 remaining eslint errors
  are in files this change does not touch
- OpenAPI regenerated

Impact

UPDATE_CHECK_ENABLED is new and defaults to yes, so behaviour is unchanged apart
from where the request comes from. The colour tokens are framework-wide: muted
and faint both get darker in light mode and lighter in dark mode. Anything that
relied on Tippy being loaded on the settings page would break, but nothing does.

Upstream Status

Not applicable — the UI framework and this dashboard are fork-specific.
Background

Self-review of the two preceding commits.

UPDATE_CHECK_ENABLED was the only parseEnvBoolean key stored as a real boolean.
Every other one keeps the 'yes'/'no' string and the consumer compares against
'yes' (see isApiDocsEnabled in server.js), so the odd one out was this change.

Two smaller points from the same pass: renderFreshness() dereferenced
lastUpdatedAt unguarded on the no-failure path — unreachable today because both
markStale() callers pass a non-empty reason, but a latent crash the moment one
does not. And .tooltip-content in zr-pages.css belonged to
views/partials/scripts/setup-scripts.ejs, which the previous commit deleted.

The new opt-out was also undiscoverable: docker-compose.yml documents optional
environment variables as commented lines, and this one was missing.

Changes

- config.js keeps the 'yes'/'no' string; the service compares against 'yes'
- renderFreshness() tolerates a missing timestamp instead of throwing
- docker-compose.yml documents UPDATE_CHECK_ENABLED alongside the other opt-ins
- Removed the orphaned .tooltip-content rules

Testing

- node scripts/run-tests.js --all → 58 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing)
- test-update-check-service updated for the string form and re-run
- Live after a container restart: /api/update-check still reports enabled,
  no outbound request from the browser, freshness label intact
- eslint, prettier and the OpenAPI check clean

Impact

None visible — the opt-out behaves the same, it is just written the way the rest
of the configuration is.

Upstream Status

Not applicable.
Background

Walkthrough of every page with rows in the tables rather than only their empty
state, at 1281px and at 390px.

The mobile breakpoint hides .zr-table-wrap so a card list can take over. Only
public/js/modules/data-table.js builds that card list, and the OCR, failed and
ignored pages render their rows by hand in their own page scripts. The rule was
unconditional, so on any screen below 861px those three pages showed the
toolbar, "Showing 1-3 of 3" and the pager — with nothing between them. Every
row was hidden and nothing took its place. Verified on /failed with three
records: the table area was empty.

Separately, --zr-warn is the only tone that carries text on its own soft
background (the OCR queue count in the sidebar). At #9a6414 on #fdf0d9 that came
to 4.4:1, just under AA, and it was the one contrast miss left on every page.

Changes

- The hide rule is scoped to .zr-table-host:has(> .zr-table-cards), so the table
  only steps aside where a card list actually exists. The three hand-rolled
  pages keep their table and scroll it inside its own wrapper, which already had
  overflow-x: auto.
- --zr-warn #9a6414 → #8f5d12: 5.0:1 on its soft background, 5.6:1 on white.
- New test-mobile-table-fallback guards the rule, the scrolling wrapper, and the
  list of views allowed to rely on it.

Testing

- node scripts/run-tests.js --all → 59 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing)
- The new test fails against the unscoped rule; verified by reverting it
- At 390px: /ocr shows 5 rows, /failed 3, /ignored 3, all scrolling inside the
  wrapper with no sideways scroll on the page itself; /history still swaps to
  its 10 cards and hides the table, so the scoping works in both directions
- At 1281px nothing changed — the wrapper stays visible on all four
- Swept /dashboard, /manual, /playground, /history, /ocr, /failed, /ignored,
  /settings and /about with rows present: no clipped text, no horizontal page
  scroll, no leftover utility classes, every icon reference resolves and paints,
  and after the amber fix no text below 4.5:1 anywhere
- Both switch components verified operable by label click; the settings section
  nav resolves all six anchors and tracks them with aria-current

Impact

Phones can use the three queue pages again. The amber tone is slightly darker
wherever it appears — badge, alert and dot.

Upstream Status

Not applicable — both are fork-specific UI.
Background

Audit of every button, form control and badge on all nine pages, comparing
computed height, padding, radius, font size and border rather than reading the
markup.

zr-pages.css still carried two generations of the same rules stacked on top of
each other. The migrated blocks sit further down the file and win by source
order, so most of the first generation was invisible — but not all of it:

- The OCR page had its own badge system. .status-badge with .status-pending /
  -processing / -done / -failed, and .reason-badge on the OCR, failed and
  ignored pages, were built from hardcoded Tailwind hexes (#fef3c7, #92400e,
  #dbeafe …) with a second set of hardcoded values for the dark theme. They
  ignored the tokens, so they missed the contrast work and needed their own
  theme handling.
- The settings changelog badges did the same: twelve hexes plus six dark-mode
  overrides for six categories.
- The OCR status filter carried .app-toolbar-filter .toolbar-input
  .ocr-toolbar-filter and came out 32px tall at 12.8px, next to framework
  selects at 30px/13px.
- The playground prompt box used .toolbar-input — a text-input rule — instead of
  .zr-textarea.
- #progressLog referenced --input-bg, --border-color and --text-color, none of
  which this framework defines, so it rendered with the greys that came along as
  fallbacks.
- A stale .toolbar-input:focus painted a #3b82f6 focus ring on a teal-branded
  application.

Separately, the reset gives form controls `font: inherit`, so a .zr-select
inside a .zr-sm row rendered a size smaller than the same control elsewhere on
the page — visible on the history table between the page-size select and the
two filter selects.

Changes

- .status-badge, .status-*, .reason-badge and their dark overrides are gone;
  the markup and the three page scripts use .zr-badge with the ok/warn/danger/
  info tones. ocr.js maps queue state to tone in one place.
- Changelog category badges use the tokens, so the dark-mode block is gone too.
- #statusFilter is a .zr-select, the playground prompt a .zr-textarea.
- #progressLog and the .log-* lines use tokens; the six log colours collapse to
  info / brand / ok / danger.
- .zr-input, .zr-select and .zr-textarea pin font-size to --zr-fs-base; the
  family still comes from the page.
- Dropped the superseded first-generation rules (.search-mode-btn,
  .toolbar-input and its blue focus ring, .ocr-toolbar-filter) and the unused
  .ocr-toolbar-toggle.
- Two remaining literal colours are now var(--zr-on-brand) and var(--zr-danger);
  the last one left, the MFA QR background, is deliberately white and says so.

Testing

- node scripts/run-tests.js --all → 59 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing)
- Measured every visible button and control per page: /ocr and /failed report no
  control off the framework, and settings reports one signature across all 39
  controls where the page-size select used to differ
- All 16 badges on /ocr now share one shape (18px / 11px) against three sizes
  before, and carry token colours including the corrected amber
- /ocr checked in the dark theme: pending, completed and failed read correctly
  without the removed overrides
- eslint and prettier clean on every touched file

Impact

Purely visual consistency; no behaviour changes. The OCR status pills and the
changelog badges shift slightly in colour because they now use the same tokens
as everything else.

Upstream Status

Not applicable — fork-specific UI.
Background

The action column on the queue pages was laid out three different ways, and on
the OCR queue it wrapped.

Measured on /ocr at 1282px: the column came out 119px wide while its content
needs 95 + 8 + 40 = 143px, so `zr-row--wrap` pushed the delete button onto a
second line. Rows with a Process button were 85px tall, the one row without it
52px — the table stepped up and down as the eye moved through it. The delete
button also carried zr-btn--danger without zr-btn--icon, so this icon-only
button was 40px wide next to a 30px icon-only button in the same cell.

failed.js put `zr-row zr-row--wrap` on the <td> itself, which makes the cell a
flex container and takes it out of the table layout; its rows measured 48, 47
and 46px for identical content.

history.js had already hit the same wrapping problem and worked around it with
`width: '280px'` and a comment explaining that a narrower column pushed "Chat"
onto a second line — a magic number in one place while the queue pages kept the
bug.

Changes

- New .zr-table__actions: the column sizes to its buttons (width: 1%), never
  wraps them, and right-aligns them so the last button sits in the same place
  whatever buttons a row's state offers
- ocr.js: the remove button gets zr-btn--icon; the cell uses the new class
- failed.js and ignored.js: the buttons move into a wrapper so the <td> stays a
  table cell
- data-table.js gained a `cellClass` column option, and history.js uses it
  instead of the hardcoded width
- The three views tag their Actions header with the same class

Testing

- node scripts/run-tests.js --all → 59 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing)
- /ocr rows go from 85/85/85/52/85 to a uniform 52px, every button on one line,
  and both icon-only buttons now measure 30px
- The last button in each row ends 12px from the cell edge on every row,
  including the one without a Process button
- /failed: the cell reports display: table-cell again and all rows are 47px
  (was 48/47/46); /ignored 47px; /history 53px across all ten rows with its
  three buttons on one line
- Action columns are now content-width: 145px on /ocr, 171px on /failed, 112px
  on /ignored, 238px on /history — previously 119px and 282px for comparable
  content
- At 390px the OCR queue still shows all five rows, scrolling inside its wrapper
- eslint and prettier clean

Impact

Queue and history tables only. The action column is narrower than before on the
pages that were over-wide, which gives the title column the space back.

Upstream Status

Not applicable — fork-specific UI.
Background

Equalising the row heights was not enough: the action block still changed shape
from row to row because each state offers a different set of buttons, and the
toolbar above it was the larger offender.

Measured on /ocr before this change, the toolbar was three top-aligned columns
of 99, 57 and 30px inside a 123px block. Its controls sat at three different
tops (12/39/86px) in two sizes (23/30px), and the search-scope pills wrapped
onto a second line inside their own grey box, so the whole area ended ragged on
every side.

Changes

- New .zr-menu plus js/modules/row-menu.js: the overflow behind a "…" button.
  It is a popover, so it renders in the top layer and the horizontally
  scrolling table wrapper cannot clip it, and Escape and click-outside are the
  browser's own light-dismiss. Only the placement is ours, because popover
  anchoring is not portable yet; the menu right-aligns under its button, flips
  above when there is no room below, and stays inside the viewport.
- /ocr keeps one labelled primary per row — Process or Analyze, never both,
  since they apply to different states — and moves "Show OCR output" and
  "Remove from queue" into the menu.
- /failed keeps Reset and moves "Ignore permanently" into the menu. /ignored has
  a single action and needs no menu.
- The OCR toolbar is one line: the search scope becomes a select in front of the
  field instead of a wrapping pill row, the status filter follows, and the
  switch and "Process All Pending" sit at the other end.
- Removed the markup that has no counterpart any more: .search-mode-btn,
  .search-mode-toggles, .app-toolbar--triple, -main, -controls, -actions,
  -filter, -filters, .filter-item, .toolbar-btn, .toolbar-select and
  .toolbar-actions — 20 rule blocks across the file and its media queries.

Testing

- node scripts/run-tests.js --all → 59 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing)
- Toolbar: 42px tall instead of 123px, and all five controls share one vertical
  centre; the switch keeps its 19px track but is centred on the same line
- Every row on /ocr and /failed now ends with the "…" button 12px from the cell
  edge, including rows without a primary action; heights are uniform
- Menu verified open, right-aligned with its button (0px offset), 4px below,
  inside the viewport, first item focused, closing on item click while the
  action still fires (/api/ocr/queue/204/text)
- Dark theme: menu item 14.3:1 on the menu surface
- At 390px the toolbar wraps to five rows as it should, the menu opens inside
  the viewport and the page does not scroll sideways
- /manual still uses the same search wrapper in its column layout, results load
- eslint and prettier clean

Note: Escape-to-dismiss is native popover behaviour and the element resolves to
popover="auto", but injected key events do not trigger it in this automation —
a plain unstyled popover on the same page behaves identically — so that path is
verified by the attribute, not by an end-to-end key press.

Impact

Queue pages only. Two actions moved one click away: "Show OCR output" and
"Ignore permanently".

Upstream Status

Not applicable — fork-specific UI.
Background

The settings section nav already carried `position: sticky` with a top offset,
but it scrolled away after 190px and stayed gone for the remaining seven
thousand.

A sticky element can only travel inside its own containing block, and
`.zr-settings` sets `align-items: start`, which shrinks the aside to the height
of the nav itself. Measured: the grid was 7299px tall, the aside 190px. At
scrollY 1200 the nav sat at top: -1136 — off screen with no way back except
scrolling to the top.

Changes

- `.zr-settings > aside` gets `align-self: stretch`, so the column spans the
  whole row and the sticky nav inside it can follow the page. The content
  column keeps `align-items: start`.
- The nav caps its height at the viewport and scrolls internally, so a longer
  list on a short window cannot reach past the bottom edge where it would be
  unclickable. The mobile rule resets that cap, since the nav is a single
  horizontal row there.

Testing

- node scripts/run-tests.js --all → 59 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing)
- Desktop: the aside now measures 7299px instead of 190px, and the nav holds
  top: 64px and stays fully visible at scrollY 0, 1200, 3000, 5000 and 6595 on
  a 7394px page
- The scroll-spy still tracks along the way: System → AI → Maintenance →
  Changelog
- At 390px the nav stays static, horizontal, 36px tall with no height cap, the
  desktop aside stays hidden and the page does not scroll sideways
- prettier clean

Impact

Settings page only.

Upstream Status

Not applicable — fork-specific UI.
Background

Every screen checked at 390x844 with rows in the tables: dashboard, history,
OCR queue, failed, ignored, manual, playground, settings, about and login.

Five faults, one of them mine from two commits ago:

- History cards clipped their third action. Moving the actions column to
  `flex-wrap: nowrap` was right for the table but the card layout renders the
  same markup on a phone, where three buttons do not fit — "Chat" ended 26px
  past the card edge.
- The description on the OCR, failed, ignored and about pages was squeezed into
  a narrow column. `.zr-viewhead__sub` already carries `flex-basis: 100%`, but a
  wrapper div around it became the flex item instead, so on /ocr the sentence
  broke across six lines beside the status badges.
- Empty `<span class="zr-grow">` spacers strand their neighbours once a row
  wraps: on /history "Rescan selected" and "Reset selected" sat on the same line
  with the whole remaining width between them.
- The manual page reserved 600px for the document preview — 71% of the screen,
  empty until a document is picked, with the analysis and tag cards below it.
- On the playground "Clear All" sat jammed against the "Saved Prompts" heading.
  The heading stacked `zr-row zr-row--between` onto `.zr-module__title`, which
  is display:block, so the space-between never applied and 160px of slack sat
  unused beside the button.

Changes

- history.js renders the action cell as `zr-row zr-row--wrap` again; the
  `.zr-table__actions > .zr-row` rule still overrides it to one line inside the
  table, so desktop is unchanged and the cards wrap
- Dropped the wrapper div around `.zr-viewhead__sub` in four views
- Empty flex spacers in a wrapping row are hidden below the mobile breakpoint
- `.manual-preview` drops to 300px there
- The saved-prompts heading became a real `.zr-module__head`

Testing

- node scripts/run-tests.js --all → 59 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing)
- History: nothing overflows the card, "Chat" wraps to a second line; at 1280px
  all three buttons stay on one line and rows are still a uniform 53px
- /ocr: the description now runs the full width in two lines with the badges
  below it
- Playground: the head reports display:flex and the button ends 12px from the
  edge instead of 160px short of it
- No page scrolls sideways at 390px, and nothing sits behind the tab bar; the
  settings save bar clears both the tab bar and the last card
- eslint and prettier clean

Impact

Phone layouts only; desktop measurements are unchanged on every page checked.

Upstream Status

Not applicable — fork-specific UI.
Background

Reprocessing an already-analysed document with OCR meant going to the OCR queue
page and typing its ID into the manual-add field, because the queue only knows
about documents the scan loop itself flagged. The history row is where you
notice a poor result, so that is where the action belongs.

The history actions moved to the same shape the queue pages use — one labelled
primary plus a "…" menu — which gives the new entry somewhere to live and keeps
three buttons from wrapping in the phone card layout.

Wiring it up surfaced a bug in showToast(): all four page scripts assign
`className` on the toast's <svg> icon, which is a read-only SVGAnimatedString,
so the call threw before the toast was ever shown. Three of them were still
assigning FontAwesome class names. No toast has appeared on the OCR, failed,
ignored or history pages since the framework migration; the failures were
silent because every caller sits in a try/catch that reports them as if the
request had failed.

Changes

- history.js: Details stays as the primary action, "Open in Paperless-ngx" and
  "Chat about this document" move into a row menu, and "Send to OCR queue"
  joins them below a separator
- The new action posts to the existing /api/ocr/queue/add and reports what came
  back; the request is separated from the toast so a rendering fault cannot be
  reported as a queueing failure
- showToast() in history.js, ocr.js, failed.js and ignored.js swaps the sprite
  reference instead of writing className, and history.js gained the
  clearTimeout its three siblings already had
- test-history-xss-hardening matches the dedicated classes anywhere in the
  attribute rather than only at its start, and now also covers the OCR button

Testing

- node scripts/run-tests.js --all → 59 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing)
- End to end against a document that really exists in Paperless-ngx: the menu
  closes, the toast shows "Document 1 added to OCR queue" with the ok tone and
  the check icon, and /api/ocr/queue?status=pending returns it
- A history row whose document was deleted from Paperless-ngx reports "Document
  110 was not found in Paperless-ngx" in the danger tone with the alert icon
- Re-queueing a pending document succeeds and moves it back to the front, which
  is what the upsert in addToOcrQueue does
- Rows stay a uniform 53px with both controls on one line at 1300px; the phone
  card shows Details plus the "…" with nothing overflowing
- No OpenAPI drift — the endpoint already existed
- eslint and prettier clean

Impact

History page. Two actions are one click further away; the OCR queue gains a
second entry point.

Upstream Status

Not applicable — fork-specific UI.
Background

Checking the overlapping menus in the option mockup against the shipped code.

The mockup is not the implementation: its "…" buttons are
`onclick="this.parentNode.classList.toggle('open')"` with an absolutely
positioned panel, so nothing closes anything and all three menus stack. The
shipped menu is a native popover="auto" — verified that opening a second one
closes the first, so only one is ever open.

The check did surface a real edge, though. The queue tables scroll sideways on a
narrow screen, and place() clamped the menu into the viewport whatever the
button was doing. Measured at 390px with the table unscrolled: the button sits
at x=1051, 699px outside the viewport, and the menu opened at x=192 — 707px away
from its anchor, floating over unrelated rows. Reaching it needs the popover
opened while its button is out of view, which a pointer cannot do but a keyboard
or a programmatic call can.

Changes

- row-menu.js closes the menu instead of placing it when no part of the button
  is on screen.

Testing

- node scripts/run-tests.js --all → 59 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing)
- At 390px with the actions column scrolled out of view the menu no longer
  opens; scrolling the column into view and opening it puts the menu 0px
  right-aligned with the button, 4px below it and inside the viewport
- Opening a second and third row menu still leaves exactly one open
- eslint and prettier clean

Impact

Queue and history tables.

Upstream Status

Not applicable — fork-specific UI.
Background

Counting the checkboxes across the app: all 21 on /settings are switches
already (11 in the template, 10 through the settings-switch partial), as are
the OCR toolbar and both in the setup wizard. Two were left, both on
/history — "Select all on this page" and one per row. They carried nothing
but accent-color, so the browser painted them. They were the last controls
in the app the operating system drew, which is why they looked foreign
beside the switches.

Rows are picked, not switched on, so they stay checkboxes rather than
becoming .zr-toggle; a switch would claim the row is a setting.

Changes

- .zr-check is drawn here now: rounded box, brand fill when checked, tick and
  indeterminate dash off one pseudo-element, focus ring, disabled state.
- Its resting border uses --zr-text-faint. An empty box is nothing but its
  outline, so the border carries the whole control and needs the 3:1 of WCAG
  1.4.11 on its own; --zr-line-strong, which borders the text inputs, measures
  1.5:1 against the surface.
- 4px corners rather than --zr-r-sm: 6px on a 16px box rounds it into a circle,
  and a round box reads as a radio button.
- The tick and both switch thumbs use --zr-on-brand instead of a hard-coded
  white. The dark theme's brand is a bright teal that white reaches only 2.3:1
  against; --zr-on-brand is #ffffff in light, so only dark mode changes.
- New test-form-control-styling fails on any checkbox in a view or page script
  that carries neither .zr-toggle nor .zr-check nor a .zr-switch label.

Testing

- node scripts/run-tests.js --all → 60 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing)
- Measured in the browser on /history and /settings, light and dark:
  unchecked border 5.20:1 / 5.09:1, checked fill 5.41:1 / 7.41:1, tick on the
  fill 5.41:1 / 7.34:1, switch thumb on the on-track 5.41:1 / 7.34:1
- All three states rendered at 8x — empty, checked, indeterminate, disabled
- Shift+Tab from the search field puts the 2px --zr-focus ring on the box
- The new test verified against a planted bare checkbox
- eslint and prettier clean

Impact

The History selection column, and the switch thumb in dark mode everywhere.

Upstream Status

Not applicable — fork-specific UI.
Background

The tick is the right and bottom border of a 4x8 box turned 45 degrees, so its
ink does not fill that box: after the rotation it sits 1px left of and 1.29px
above the box it was drawn from. Positioning the box centrally therefore left
the tick itself high and to the left. The indeterminate dash had the same kind
of error — height: 0 puts the whole bar in the bottom border, below `top`, so
it drew a pixel above centre.

Changes

- The tick offsets compensate for the rotation: left 4px -> 5px, top 1px -> 2.3px
- The dash sits one pixel lower, top 5px -> 6px

Testing

Rebuilt both shapes from real elements so the browser reports where the ink
lands rather than trusting the arithmetic. In the 14x14 padding box the tick
now measures a centre of (7, 7) with gaps of 2.76px left and right and
3.47/3.46px top and bottom; the dash measures (7, 7) with 2/2 and 6/6. Both
also checked against a crosshair at 18x.

node scripts/run-tests.js --area observability -> 7 of 7 passed. Prettier clean.

Impact

The History selection column.

Upstream Status

Not applicable — fork-specific UI.
Background

The Details button on /history looked as if its label sat right of centre. It
was not a centring problem: the icon in front of it was 0px wide and painting
nothing, while the 6px gap between icon and label stayed. Measured: the button
is 75.81px wide in the table and 89.81px once the icon has its size, and the
ink sits 6.58px right of centre — almost exactly the phantom gap.

The cause is the reset, which gives every img and svg `max-width: 100%` so a
picture cannot burst out of its column. An icon has a fixed size and needs no
such cap, and inside a shrink-to-fit cell — .zr-table__actions is `width: 1%` —
that percentage resolves against a width that is not settled yet, so the used
width comes out 0. svg clips its overflow, so the icon vanishes entirely.

Counted across the app: 22 icons were affected, all of them in table rows —
10 Details buttons on /history, 5 Process buttons and 1 status icon on /ocr,
3 Reset on /failed, 3 Unignore on /ignored. Dashboard, settings, playground and
manual were clean, which is why this survived every earlier review: the icon is
missing only where a row-action button sits in a shrink-to-fit column.

Changes

- .zr-icon sets `max-width: none`, opting out of the reset's cap. Targeted at
  the icon rather than removing the cap, so any future non-icon svg keeps it.
- New test-icon-sizing guards the opt-out, and only while the reset still caps
  svg with a percentage.
- ruleBody() in both CSS tests now anchors the selector to the start of a line.
  ".zr-icon {" was answered by ".zr-navitem .zr-icon {" 300 lines earlier, so
  the new test read the wrong rule body and failed against correct CSS.

Testing

- node scripts/run-tests.js --all -> 61 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing)
- Re-measured all four table pages: 0 collapsed icons, and no button whose
  content box is more than 1px off centre. Same at 390px.
- Checked every .zr-btn on history, ocr, failed, ignored, settings (45),
  dashboard, playground and manual for content that is not centred; the
  remaining offsets are at most 1.93px of ink asymmetry between an icon's
  transparent margin and the text's side bearings.
- The Details button now renders its icon at 1440px, 880px and 390px
- eslint and prettier clean

Impact

Every row-action button in the queue and history tables.

Upstream Status

Not applicable — fork-specific UI.
Background

At 390px the eight controls above the history table wrapped into five ragged
rows. Measured in a 366px container: widths of 200, 220, 91, 106, 103, 154, 124
and 82px, lines ending at 212, 331, 229, 298 and 94px — no two edges the same,
and the filters capped by inline max-widths the phone rule could not override.

Changes

- The two filter selects trade their inline max-width for a .history-filter
  class, capped only above the breakpoint, so on a phone they take the width
  every other control takes.
- The filter group stacks below the breakpoint. Scoped with :has() to a group
  holding a control: the OCR head uses the same box for three status badges,
  which belong side by side.
- New .zr-btnbar lays a bar of page-level buttons out as a grid on a phone,
  auto-fit with a 160px floor: two columns at 390px, three at 600px, four at
  860px, and never narrower than the longest label it carries.
- Inside that bar the empty .zr-grow spacer keeps its meaning. On a wide screen
  it pushes the destructive buttons to the far side; here it becomes the break
  onto their own row. It has to be declared after the rule that hides spacers on
  a phone, which matches it at the same specificity.

Testing

- node scripts/run-tests.js --all -> 62 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing)
- At 390px every control now starts at one of two x positions and ends at one
  of two, the grid columns: 12/199 and 191/378. Previously five of each.
- Checked at 390, 600 and 860px: no label is clipped in its column
  (scrollWidth == clientWidth on all five)
- 1440px is unchanged, filters right-aligned and the destructive pair pushed
  to the far end
- /ocr keeps its three status badges on one line at 390px
- New test guards the grid and the source order the spacer depends on
- eslint and prettier clean

Impact

The history toolbar below 861px.

Upstream Status

Not applicable — fork-specific UI.
Background

Five bulk-action buttons take a third of a 390px viewport before the first
document is visible. Lining them up in a grid made the block tidy but no
smaller. Rows already answer this with a menu; the toolbar now does too.

Two of those buttons never worked. "Select all" and "Deselect all" appear in
history.ejs and nowhere else in the codebase — no listener, no delegation.
Clicking them did nothing, which a menu would only have made harder to notice.

Changes

- New toolbar-menu module folds a .zr-btnbar into one "Actions" trigger below
  the breakpoint and unfolds it above. The buttons are moved, not copied: their
  ids and listeners belong to the page script, so a second set would need either
  duplicate ids or a second round of wiring. Moving keeps a disabled state and a
  label swapped mid-request intact as well.
- The empty .zr-grow spacer becomes the separator between the neutral and the
  destructive entries, the same break it draws on a wide screen.
- history.js wires the two dead buttons to a shared setAllSelected(), which the
  header checkbox now uses too, so the header state follows either route.
- row-menu places a menu against a trigger on the left half of the screen by its
  left edge. Right-aligning the toolbar trigger put the menu 4px beside itself,
  because the clamp caught it before the alignment did.
- The grid stays as what shows until the module mounts and what remains if its
  import fails.

Testing

- node scripts/run-tests.js --all -> 62 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing)
- At 390px the bar is one 96x38 trigger at x=12. The menu opens flush with it
  (0px offset, 4px below), inside the viewport, first item focused.
- "Select all" from the menu now checks all 10 rows and leaves the header
  checkbox checked rather than indeterminate; "Deselect all" clears both.
- Resized 390 -> 1440 -> 390: the bar returns to six children in template order
  with their original classes, then folds again.
- Row menus still right-align exactly (0px) at 1440px — their anchor is in the
  right half, so the new branch does not apply to them.
- The test that guards the wiring was checked against a renamed handler
- eslint and prettier clean

Impact

The history toolbar below 861px, and menu placement for any trigger on the
left half of the screen.

Upstream Status

Not applicable — fork-specific UI.
Background

The three queue pages kept their table on a phone and scrolled it sideways.
Measured at 390px in a 364px wrapper: /ocr laid out 1120px of table, 756px of it
off screen; /failed and /ignored overflowed by 264 and 262px. In every case the
actions column — the only reason to open these pages — was past the right edge.

/history does not have this problem because data-table.js builds a second copy
of the rows as cards. The three queue pages render their rows by hand, so an
earlier pass scoped the card swap with :has() and left them scrolling.

Changes

- .zr-table--stack lays a table out as the same cards without a second copy in
  the DOM: the header row is hidden and each cell draws its column name from a
  data-label. The three page scripts add that attribute per cell; the actions
  cell carries an empty one so its buttons line up with the values above.
- A cell that truncates in a column wraps in a card instead. The ellipsis would
  not have appeared anyway — the text is an anonymous item of the flex row and
  text-overflow does not reach it.
- The phone rule that hides an empty .zr-grow spacer now only matches a span or
  a div. A void element matches :empty forever, so it also hid
  <input class="zr-input zr-grow"> — the reason field on the ignore list was
  invisible on every phone.
- A field sharing a wrapping row with other controls now takes the row to
  itself. Three side by side at 390px left the free-text one 75px wide, showing
  "Reason (" and nothing more. Its neighbour's inline max-width moved to a class
  capped only above the breakpoint, the same treatment the history filters got.

Testing

- node scripts/run-tests.js --all -> 62 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing)
- At 390px all three pages report zero horizontal overflow, on the wrapper and
  on the document. Every cell is a full-width row of the card.
- The empty and loading states keep the full width: their cell has no
  data-label, so it stays a block with no label column.
- At 1440px the tables are still tables — display table, thead visible, cells
  table-cell with no ::before — and the ignore form is one row again with the
  id field at 160px.
- The stacking test was checked against a table with the class removed
- eslint and prettier clean

Impact

/ocr, /failed and /ignored below 861px, and any wrapping row of form fields.

Upstream Status

Not applicable — fork-specific UI.
Background

"AI analysis after OCR" showed a bare grey pill with nothing in it. Not a
checkbox, not a switch — no way to tell on from off except by colour.

The framework carried two switches. .zr-toggle draws the track on the checkbox
itself and the knob as its ::after. .zr-switch built the same look from a label,
a visually hidden input and two nested spans — and its knob was one of those
spans, an inline box, so the width: 15px and height: 15px never applied. Measured
on every .zr-switch in the app: track 34x19, knob 0x0. The knob has never
rendered; the pill was always empty.

This survived my own review earlier today because I read the knob's colour
without ever reading its box. getComputedStyle answers for an element that
paints nothing just as readily.

Changes

- The five .zr-switch sites (the settings-switch partial, three in settings, one
  each in the OCR toolbar and the setup wizard) now use .zr-toggle on the input.
- .zr-switch, __track, __thumb and __text are gone. .zr-switchrow stays and now
  aligns .zr-toggle instead.
- test-form-control-styling drops the .zr-switch branch and gains a check that
  no view or script builds the removed markup again.

Testing

- node scripts/run-tests.js --all -> 62 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing)
- /ocr and /settings: every toggle measures 34x19 with a 15x15 knob. The three
  that measure 0x0 sit inside .hidden containers, as they did before.
- Rendered both states at 6x: grey track with the knob left, brand track with
  the knob right.
- The settings partial changed shape, so the hidden yes/no input was retested:
  clicking the switch moves it no -> yes -> no and the track follows.
- eslint and prettier clean

Impact

Every on/off switch in the app.

Upstream Status

Not applicable — fork-specific UI.
Background
----------
A full design review of every page at desktop and phone widths surfaced
three groups of defects. Shell: the railState=collapsed cookie written by
the desktop rail collapse also stripped every label out of the phone
drawer (the drawer is the same .zr-rail element), the bottom tab bar
painted above the drawer scrim at z-index 45 > 40 so tabs stayed clickable
through it, and the topbar hamburger duplicated the More tab, which opens
the same drawer. History: the two filter selects stacked vertically in the
viewhead, three controls existed for select-all (two never-used buttons
plus the header checkbox), ten primary Details buttons dominated the
table, and the stacked phone cards had no selection checkbox at all
because the column was marked mobile: false — Rescan/Reset selected were
unusable on phones. Queues: the OCR table overflowed a 1042px viewport
(nowrap title cells size an auto-layout table; text-overflow never fires
without a bounded width), stacked cards indented their action buttons by
the empty 96px label column, the permanent "Type to search documents..."
hint repeated the input placeholder, and row actions mixed primary and
quiet buttons across pages.

Changes
-------
- zr.css: scope the collapsed-rail rules to min-width 861px; drop the tab
  bar to z-index 30 (below the scrim); give truncating table cells
  width:100% + max-width:0 so the ellipsis works and the table stops
  scrolling sideways (undone inside .zr-table--stack, where max-width:0
  would collapse the stacked row); hide the empty ::before label of
  actions rows in both card systems (.zr-table--stack td[data-label='']
  and .zr-table-card__label:empty).
- body-open.ejs: remove the topbar hamburger; the More tab is the single
  drawer trigger on phones.
- history.ejs/history.js: move the tag and correspondent filters into the
  data-table toolbar next to the search (new optional `toolbar` slot in
  data-table.js that adopts a page-owned element); move Reload into the
  button bar as "Refresh filters" so the phone Actions menu picks it up;
  delete the Select all/Deselect all buttons and their wiring; render
  Details as a quiet button; include the selection checkbox in the card
  layout (mobileLabel "Select"), keep table/card twin checkboxes in sync
  and deduplicate getSelectedDocuments().
- ocr.js/failed.js/ignored.js/history.js: date-only timestamps in list
  columns with the full timestamp in the cell title; row actions unified
  on quiet buttons (Process, Reset).
- document-omnibox.js/ocr.js/ocr.ejs: the search status line is empty
  while idle and only carries live feedback; clearing the query resets it
  so "No matching documents found." cannot outlive its search. The ID
  scope hint moved into the placeholder.
- zr-pages.css: the two history filters split one toolbar row on phones.

Testing
-------
- node scripts/run-tests.js --all: 62 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing and unrelated).
- ESLint and Prettier clean on every touched file.
- Verified in the running container at 1042px and 375x812: drawer shows
  labels with a collapsed-cookie present, scrim covers the tab bar
  (elementFromPoint hits the scrim), OCR table fits exactly (952/952),
  history filters render beside the search, card checkboxes select and
  the twin sync yields unique ids (10 rows -> 10 ids), Actions menu
  carries all four bar buttons.

Impact
------
Phones get a usable drawer and selection model; desktop tables stop
scrolling sideways at ordinary window widths; every list page shares one
action-button hierarchy and loses its redundant controls.

Upstream Status
---------------
Fork-specific UI framework work; not applicable upstream.
Background
----------
The design review hit settings hardest. Two banners opened every visit and
the green one ("The application is already configured") was true on every
visit by definition. The MFA card showed Enable, Disable and Validate &
Activate side by side in every state — and its state machine had never
actually run: the section carried two id attributes, the parser drops the
second, and getElementById('mfaSettingsSection') returned null, so the
manager bailed out in its constructor. Field widths were chaos: the
label-column grid of .zr-field sets align-items: start, .zr-field--stacked
inherits it as a flex column, and every width:auto child (input groups,
nested fields) shrank to its content — secret fields came out 164px wide
with the placeholder cut off. Buttons dropped into .zr-col stretched to
full-width slabs (Clear Tag Cache Now). The prompt and External API
textareas carried class zr-input, whose height: 30px squashed rows=8 to
two lines. The sidebar listed AI/OCR/Security in an order the page does
not have, so the scroll-spy highlight jumped around. On phones the save
bar floated above the tab bar with scrolling content bleeding through the
gap, and the "field name + type + Add" row was cramped.

Changes
-------
- settings.ejs: single id on the MFA section; both section navs match the
  page order (System, Security, AI, OCR, Maintenance, Changelog); the
  Detected Public URL row uses the same inputgroup shape as the secret
  fields; the four misclassified textareas use .zr-textarea; the
  custom-field add row is .customfield-add; the token field carries
  id=mfaTokenField; the two hand-rolled switch rows put the label left and
  the switch right.
- settings-switch.ejs: label left, switch right — one orientation across
  the whole form (feature toggles and restrictions already sat right).
- settings.js: renderState() hides the controls of other states instead of
  merely disabling them (Enable xor Disable; the authenticator-code field
  only during activation or while enabled).
- routes/setup.js: the settings view no longer sends the "already
  configured" success banner; real errors still render.
- zr.css: .zr-field--stacked aligns stretch with a flex-start opt-out for
  lone buttons; .zr-col > .zr-btn keeps its natural size (.zr-btn--block
  still wins via width); the phone action bar docks onto the tab bar
  (fixed, full width, no radius) with view padding to match.
- zr-pages.css: the custom-field wrappers stack full-width on phones and
  the hidden currency wrapper stops claiming a row.

Testing
-------
- node scripts/run-tests.js --all: 62 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing). ESLint and Prettier clean.
- Verified in the container: no green banner; nav order matches; token
  fields measure 522px inside the 560px group beside full-width URLs;
  cache buttons 178/190px; systemPrompt 157px tall; MFA disabled state
  shows Enable only with token field and provisioning hidden; save bar
  gap to the tab bar is 0 on phones; add-row wrappers stack at 325px with
  no empty currency row; all 12 switch rows end with the toggle and the
  hidden yes/no input still flips no->yes->no.

Impact
------
Settings opens on the content instead of two banners, every field lines
up, the MFA card shows one action at a time (and its JS actually runs),
and the phone save bar looks attached rather than broken.

Upstream Status
---------------
Fork-specific UI framework work; not applicable upstream.
Background
----------
Manual opened on a blank grey preview box the height of a phone screen,
with Save Tags and the add-tag controls looking ready although no document
was selected. Playground rendered a danger Clear All button beside "No
saved prompts yet". And the off-state switch track (--zr-line-strong)
held 1.53:1 against the white thumb in the light theme, so whether a
switch was off was close to invisible (WCAG 1.4.11 asks 3:1).

Changes
-------
- manual.ejs / manual-scripts.ejs / zr-pages.css: the preview shows a
  "No document selected" empty state driven by a CSS sibling rule on
  #contentPreview:empty; Save Tags, the add-tag select and the + button
  start disabled and follow the analyze button through
  setDocumentActionsEnabled(); the + button is quiet (Save Tags is the
  card's one primary action) and the select carries .zr-select; the empty
  preview is 360px on desktop and 160px on phones instead of 600px.
- playground-analyzer.js: refreshSavedPrompts() hides Clear All while the
  list is empty; two stray German comments translated.
- zr.css: new --zr-toggle-off token — #7d8c96 in the light theme (3.46:1
  against the thumb, 3.40:1 against the surface), the existing
  --zr-line-strong in the dark theme (9.74:1). .zr-toggle uses it.

Testing
-------
- node scripts/run-tests.js --all: 62 passed, 2 skipped, 1 failed
  (rate-limiting, pre-existing). ESLint and Prettier clean.
- Verified in the container: empty state visible with all four controls
  disabled; selecting document #1 loads 1253 chars, hides the hint and
  enables the controls; clearing restores both. Clear All hidden with 0
  prompts, visible with 1, hidden again at 0. Measured thumb-vs-track
  3.46:1 (light) and 9.74:1 (dark) on a live toggle after the theme
  transition settled.

Impact
------
First-visit Manual explains itself instead of presenting dead controls,
Playground stops offering to delete nothing, and the off position of
every switch is discernible in both themes.

Upstream Status
---------------
Fork-specific UI framework work; not applicable upstream.
Background:
A duplication audit (2026-08-12) of the dashboard + login surface found
~60 verified findings across CSS, JS and EJS markup. This is batch 1 of
the consolidation: cross-file CSS merges, shared JS helpers, and the
auth pages adopting the shell partials. File-disjoint batches keep each
diff reviewable; the audit's verified non-findings (.hidden as a plain
class, media re-declarations, theme token blocks) were left untouched.

Changes:
- .zr-setup* now lives only in zr-pages.css; the gradient background and
  head padding that only zr.css carried moved along, min-height 440px
  kept (A1). Removed the identical .zr-field--stacked re-declarations
  and the three specificity-repair rules they forced (A2). Merged the
  doubled legacy .modal/.modal-container blocks preserving the effective
  cascade (A3). Dropped the dead width in .zr-entitybtn (A4), the
  spelled-out mono font stacks (A6, now --zr-font-mono) and the unused
  .zr-hidden twin (A8).
- Deleted verified-unused zr-pages.css rules: .card-title, .detail-*,
  .highlight-row, .ocr-header-badges, .toolbar-input, .changelog-chevron,
  empty section headers, no-op .hidden re-declarations (F6/A12).
- Grouped the byte-identical mobile table-card bodies shared by
  .zr-table--stack and .zr-table-card (B2).
- New --zr-scrim/--zr-scrim-soft tokens referenced by the dialog
  backdrop, drawer scrim and legacy modal overlay (.modal-overlay shifts
  from pure black 0.5 to the shared tone by design) (B5).
- New classes consumed by upcoming batches: .zr-module__body--chart,
  .zr-link--underline, .zr-dot--cat-1..8/--brand/--info/--text-faint,
  .zr-donut__total/__caption, cursor on .zr-badge--nav, flex layout on
  .zr-dialog__head, dashboard/overlay spacing rules (C1-C9/D6a/C2).
- .zr-wordmark extracted from .zr-rail__wordmark; login/setup use it
  without the rail class, the rail carries both, the auth card sizes it
  via CSS instead of an inline style (F1/C4).
- Deleted the unreachable modules counter.js and scan-status.js — the
  latter still carried German mock strings (D14/D13).
- describeElapsed/formatTimeAgo live once in scanner-health.js with an
  assumeUtc option for the marker-less DB timestamps (D1/D2).
- New modules/text-utils.js exports the complete 5-replacement
  escapeHtml; dashboard, bar-list, donut, spark and data-table import it
  (three replaced copies were incomplete); settings.js keeps a commented
  local copy because classic scripts cannot import ES modules (D3).
- Scan start/stop share one runScanAction path through fetchJson, so
  both now get the request timeout; an expired session shows the failure
  toast instead of a false success (D9).
- login/setup/setup-error use the shared head-start/head-end partials
  instead of hand-written <head>s; new shell/theme-toggle.ejs replaces
  the three toggle copies and their dead themeToggle ids; login's doc
  links use zr-link--underline instead of inline styles (E1/E2/F7/C3).

Testing:
- node scripts/run-tests.js --all: 62 passed, 2 skipped (missing env),
  1 failed: rate-limiting — pre-existing, fails identically on the
  untouched main checkout against the instance on :3000.
- ESLint and Prettier clean on all touched files.
- Visual A/B against the pre-change baseline via the EJS preview
  renderer with mock data: dashboard (light + dark, filled lists and
  charts), login (default, error and MFA branches), setup wizard —
  pixel-identical; wordmark size and link underlines now come from CSS,
  exactly one theme toggle per page, setup gradient preserved.
- No API changes, OpenAPI spec untouched.

Impact:
- Net -250 lines; no rendered output change except the intended
  .modal-overlay tone and the corrected expired-session toast.
- Incomplete HTML escaping in bar-list/donut/spark closed.

Upstream Status:
- Fork-specific refactor of the zr framework; not applicable upstream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
admonstrator and others added 10 commits August 12, 2026 10:05
…h 2)

Background:
Batch 2 of the dashboard + login duplication consolidation (audit
2026-08-12, batch 1 in 0f5471e). Three file-disjoint agents covered the
dashboard surface, the shared shell JS and the shell partials; the
orchestrator review added one cascade fix the batch surfaced.

Changes:
- One renderLegend() in dashboard.js feeds both donut legends: labels
  are always escaped (the processing legend previously interpolated
  entry.label raw — a latent XSS shape) and dots carry zr-dot--<tone>
  classes instead of inline backgrounds (C7/D4)
- donut.js text uses .zr-donut__total/.zr-donut__caption instead of
  inline font/fill styles, including the no-data label (C8); the entity
  dialog error uses .zr-danger-text (C10)
- dashboard.ejs: chart bodies use .zr-module__body--chart, the health
  banner and KPI row drop their inline margins (zr-pages.css rules from
  batch 1), the dialog head drops zr-row (C1/C2/D6a)
- Cascade fix from review: .zr-module__body--chart is doubled to
  .zr-module__body.zr-module__body--chart — at (0,1,0) the later-defined
  .zr-row won and reverted gap/align-items to the row defaults. Found
  and browser-verified by the implementing agent; the upcoming @layer
  split removes the need for such bumps
- Toast exists once: zr.js keeps the DOM implementation and publishes
  it as window.__zrToast; dialogs.js's zrToast is a 4-line adapter
  mapping its tone vocabulary (success/error/warning) onto it at call
  time. Danger toasts announce as role="alert" (kernel behaviour) (D5)
- Theme: zr.js theme.set delegates to window.zrTheme.apply and drops
  its duplicate attribute write and unnormalised fallback branch;
  login.js drops its dead normalize/read chain and asks zrTheme.get()
  (D7). The railState cookie write gains the conditional Secure flag
  and a named max-age, matching the theme cookie (D8)
- sidebar-badges.js drops its three style.cursor assignments —
  .zr-badge--nav carries cursor: pointer since batch 1 (C9)
- New views/partials/nav.ejs declares the nav destinations once and
  renders both surfaces; sidebar-nav.ejs is a one-line shim and
  body-close.ejs includes the tabbar variant. Per-surface overrides
  keep the intentional /ocr difference (rail: OCR Queue + i-scan,
  tabbar: Queues + i-inbox + dot). Render-diff verified: rail output
  byte-identical, tabbar whitespace-only (E6)
- The changelog dialog controller moves from a 57-line inline script in
  the shell partial to public/js/changelog-modal.js, loaded next to
  sidebar-badges.js; the partial keeps markup only. Its dialog head
  drops zr-row and the legacy modal-title class (E9/E5/F2/D6a)
- The restart/model-test overlays drop their inline margins for the
  batch-1 zr-pages.css rules; the data-driven meter width stays (C2)

Testing:
- node scripts/run-tests.js --all: 62 passed, 2 skipped (missing env),
  1 failed: rate-limiting — pre-existing, environmental (live instance
  on :3000 with GLOBAL_RATE_LIMIT_MAX=1000)
- ESLint, node --check and Prettier clean on all touched JS; Prettier
  clean on zr.css
- Browser-verified on the EJS preview renderer: chart bodies compute
  gap 16px / align-items flex-start (the cascade fix), legend dots
  carry class-based backgrounds with no style attribute, donut text
  styles apply via class, banner/stats margins come from CSS, the
  mobile tabbar renders its five tabs with the queue dot live, and the
  changelog dialog opens correctly from the extracted controller
- EJS render-diff for the nav consolidation: rail byte-identical,
  tabbar differs in leading whitespace only

Impact:
- Toast/theme/cookie logic each exist once; future pages including
  dialogs.js without zr.js lose toasts (both current hosts load the
  kernel — documented in dialogs.js)
- No visual change except the intended chart-body restoration and the
  changelog title inheriting the dialog-head weight

Upstream Status:
- Fork-specific refactor of the zr framework; not applicable upstream

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…atch 3)

Background:
Batch 3 of the dashboard + login duplication consolidation (audit
2026-08-12; batches 1-2 in 0f5471e, 3a9c86d). Finding A5: .login-step
and .setup-step were byte-identical components defined twice in
zr-pages.css, 700 lines apart.

Changes:
- One .zr-steppane / .zr-steppane.is-active definition in the auth
  section of zr-pages.css, documented as shared by the login form and
  the setup wizard; both old definitions removed
- Renamed at every consumer: login.ejs (2 wrappers), setup.ejs
  (7 sections), setup.js (querySelectorAll), and the mock-DOM stubs in
  three setup wizard tests
- The three setup-only descendant rules are scoped to
  .zr-setup__panel .zr-steppane instead of a bare rename: the login
  credentials pane has the same adjacent-stacked-fields shape, and the
  unscoped rule would have doubled its field spacing on the login card
  (margin-top on top of the pane gap). Comment records the constraint;
  no competing declaration sits in the raised specificity band
- login.js and modules/wizard.js needed no change — they select via
  data-step/data-panel and toggle is-active on element references

Testing:
- EJS render diff (login default/MFA/error+cookie-warning variants and
  setup): byte-identical after normalising the renamed class token;
  is-active and aria-hidden token counts unchanged per variant
- node scripts/run-tests.js --all: 62 passed, 2 skipped, 1 failed —
  identical to the pre-edit baseline (known environmental rate-limiting
  failure against the live :3000 instance)
- ESLint/node --check/Prettier clean on all touched non-EJS files
- Preview server renders /login, /login?mfa=1 and /setup with the
  renamed panes
- Note: the wizard test stubs would not have caught a missed rename
  (verified by negative control) — confidence comes from the
  repo-wide grep with zero residuals and the render diff

Impact:
- One shared step-pane component; net -4 lines; no visual change

Upstream Status:
- Fork-specific refactor of the zr framework; not applicable upstream

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tch 4a)

Background:
The dedup batches (0f5471e, 3a9c86d, 8f763ab) removed the duplication
inside the two framework stylesheets; this batch replaces the zr.css
monolith (2590 lines) with thematic files under CSS cascade layers, as
approved in the consolidation plan. Layers make the priority order
explicit instead of depending on source order and selector specificity
— the class of bug the audit kept finding (A2 repair rules, the
batch-2 .zr-row cascade trap).

Changes:
- New public/css/ files, wrapped in layers declared in tokens.css as
  "@layer tokens, base, shell, components, modules, pages, utilities":
  tokens (203 lines), base (184), shell (279), buttons (131), forms
  (436), badges (142), feedback (175), dialogs (121), modules (701),
  tables (267), utilities (58). pages is reserved for the zr-pages.css
  split. @font-face/@Keyframes stay outside the layer blocks
- Layer semantics: layout/text primitives (zr-row/col, zr-icon,
  zr-link, type scale) sit in base so components may override them;
  state utilities (zr-*-text, zr-faint/muted, .hidden, zr-only-*) sit
  in utilities so they win over components — matching the behaviour
  the old file achieved through source order
- The two permitted declaration changes: the batch-2 specificity bump
  .zr-module__body.zr-module__body--chart is simplified back (modules >
  base makes it unnecessary) and the zr-*-text ordering comment now
  explains the layer mechanism
- head-start.ejs links the eleven files in layer order (readability;
  only tokens-first and zr-pages-last are load-bearing) and documents
  the one intra-layer order dependency: the 860px touch-target rule in
  forms.css sizes .zr-btn, so buttons.css must precede forms.css
- zr.css deleted; zr-pages.css untouched and still last (unlayered CSS
  beats layered, preserving its override role until batch 4b layers it)
- Four tests read zr.css directly; new tests/framework-css.js loads
  the split files in link order, strips the layer wrappers and
  re-indents so the existing selector-anchored assertions keep working.
  test-mobile-table-fallback additionally needed its rule regex
  tightened to [^{}] so a match cannot start at an @media opener (its
  target rule now sits first inside the media block)

Testing:
- Rule-preservation proof: multiset diff of (context, selector,
  declarations) between old zr.css and the concatenated new files
  shows exactly the two permitted differences; 1319 declarations in
  both; at-rule context histograms identical
- Computed-style A/B (pristine HEAD export vs worktree, two preview
  servers): full body tree on /settings at desktop and mobile, plus a
  synthetic page instantiating ~all framework classes with
  ::before/::after — identical on every probed property (hash match)
- All 11 views render 200 on the preview server (history 500s
  identically on both sides — known harness limitation)
- node scripts/run-tests.js --all: 62 passed / 2 skipped / 1 failed
  (known environmental rate-limiting) — matches baseline after the
  test rewiring
- ESLint + Prettier clean on the new CSS, the helper and the four
  rewired tests

Impact:
- 11 thematic stylesheets replace the monolith; page weight unchanged
  (same bytes, 11 requests with long-lived caching instead of 1)
- Known follow-ups for batch 4b: layer zr-pages.css (currently
  unlayered = stronger than before, verified conflict-free today) and
  reword its two stale zr.css comment references

Upstream Status:
- Fork-specific refactor of the zr framework; not applicable upstream

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ages (batch 4b)

Background:
Companion to cb2bd65 (zr.css split). zr-pages.css (~1120 lines) was the
last unlayered stylesheet; splitting it into page-scoped files under
@layer pages completes the thematic structure and makes the page CSS
subject to the same explicit cascade contract as the framework.

Changes:
- New public/css/pages/: auth (111 lines, gains the formerly orphaned
  .login-back-button), setup (178: wizard + setup-error + wizard
  steps), dashboard (16), settings (39: blocking overlays + settings),
  queues (107: ocr + ignored), history (29), playground (108),
  settings-changelog (239), shared (345: legacy modal system and other
  cross-page pieces used by history/playground/settings/manual page
  scripts). Every file wraps its rules in @layer pages; relative
  source order preserved inside each file
- The changelog stylesheet is named settings-changelog.css, not the
  planned about.css: the implementing agent verified the .changelog-*
  classes render only in the settings page's Changelog tab, and a
  page-named file holding another page's CSS would mislead
- zr-pages.css deleted; head-start.ejs links the nine files between
  tables.css and utilities.css in the old section order; tokens.css's
  layer-contract comment updated (pages is no longer "reserved")
- Semantic shift, intended and documented: page rules previously beat
  ALL framework CSS (unlayered); they now sit above every framework
  layer but BELOW the state utilities. A colour utility on an element
  a pages rule colours is now won by the utility — the contract going
  forward
- The one real markup casualty of that shift is fixed here:
  #manualDocSearchStatus in ocr.ejs dropped its inert zr-faint class,
  which would otherwise have overridden the .error state (OCR search
  failures would have rendered faint grey instead of red). Both its
  states now render exactly as before

Testing:
- Rule preservation: multiset diff old vs concatenated new files —
  0 differences, 541 declarations on both sides, no rule in two files
- Intra-layer reorder proof: 0 duplicate selectors across the new
  files; all 75 selector pairs whose relative order changed were
  probed on 12 rendered pages (900 checks) — no element ever matches
  both sides of any pair
- Computed-style A/B (pristine HEAD vs worktree, scripts disabled):
  32 cases (8 pages x 2 widths x 2 themes), 7964 elements — 0 diffs on
  every real page; a synthetic kitchen-sink page showed only the
  deliberately authored cascade probes. Targeted /ocr?ocrEnabled=1 run
  isolated exactly the one status-element colour delta that the
  zr-faint removal above resolves
- All views 200 on the preview server (history 500s identically on
  both sides — known harness limitation)
- node scripts/run-tests.js --all: 62 passed / 2 skipped / 1 failed
  (known environmental rate-limiting) — baseline
- Prettier clean on all new/touched files; no zr-pages or about.css
  references remain repo-wide

Impact:
- 20 thematic stylesheets replace the former two monoliths; the
  cascade is now fully layer-governed. Future rule: put page CSS in
  css/pages/<page>.css, framework components in their thematic file,
  and expect state utilities to win over both

Upstream Status:
- Fork-specific refactor of the zr framework; not applicable upstream

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Background:
Wiring up stylelint (next commit) surfaced its first real find before
it even landed: cb2bd65 carried two body rules from different zr.css
sections (reset: font-smoothing; typography: font/color/background)
into the same @layer base block of base.css.

Changes:
- One body rule with all six declarations; nothing between the two
  originals was order-sensitive relative to body, so the merge is
  rendering-neutral.

Testing:
- npm run lint:css now exits 0 across all 20 stylesheets
- Prettier clean; preview server renders unchanged

Impact:
- None visually; unblocks the stylelint CI step for this branch

Upstream Status:
- Fork-specific; not applicable upstream

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Background:
The duplication audit found exactly the defect classes stylelint
catches mechanically: duplicate selectors (A3/A8), duplicate
properties in one rule (A4), spelled-out font stacks (A6). With the
CSS now split across 20 thematic files, a linter is the cheap way to
keep those classes of duplication from creeping back.

Changes:
- devDependencies stylelint + stylelint-config-standard; npm script
  "lint:css" covering public/css/**/*.css
- .stylelintrc.json extends the standard config with the audit rules
  pinned at error (no-duplicate-selectors,
  declaration-block-no-duplicate-properties allowing the deliberate
  100vh/100dvh consecutive pairs, font-family-name-quotes
  always-unless-keyword) and the stock rules tuned to the existing
  hand-written style: BEM-shaped selector-class-pattern, camelCase ids,
  legacy rgba()/prefix media notation, scoped vendor-prefix and
  deprecated-property allowlists. Blank-line formatting rules and
  no-descending-specificity are off — the latter is structurally at
  odds with the @layer architecture, where lower-specificity component
  rules overriding base primitives is the design
- Token-usage enforcement is deliberately not attempted: tokens.css is
  definitionally a wall of literals and pages/shared.css still carries
  legacy values; a disallowed-list rule would bury the signal
- .stylelintignore mirrors .prettierignore (public/vendor)
- ci.yml lint job gains a "Stylelint (changed files)" step mirroring
  the existing changed-file pattern, with --allow-empty-input so a PR
  whose only CSS change is vendored does not die on
  AllFilesIgnoredError

Testing:
- npm run lint:css exits 0 over all 20 stylesheets (after the
  preceding body-rule fix, which this guardrail found)
- Deliberate scratch breakages prove the three audit rules fire,
  including no-duplicate-selectors inside an @layer block
- Workflow YAML parses; steps resolve ESLint -> Stylelint -> Prettier
- node scripts/run-tests.js --all: 62 passed / 2 skipped / 1 known
  environmental rate-limiting failure (baseline)

Impact:
- PRs touching CSS now fail on reintroduced duplication; package.json
  change triggers docker-check.yml (build-only) as expected
- Known limit: no-duplicate-selectors scopes per at-rule block, so a
  selector duplicated across two separate @layer blocks in one file
  escapes it — every file currently has exactly one layer block

Upstream Status:
- Fork-specific; not applicable upstream

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Background:
The duplication audit traced several findings to components being
reinvented because nobody could see what already existed. This page
renders one sample of every zr framework component with the markup the
app actually uses — the "look here before writing a new class"
reference.

Changes:
- views/styleguide.ejs: standard shell, twelve sections (buttons,
  badges/chips, forms, alerts, meter/spinner, toasts, dialogs,
  modules/grid, charts, tables, auth/steps, action bar) navigated by a
  live section-nav. Charts mount the real donut/spark/bar-list modules
  via their declarative data-module entry points; toolbar-menu and
  row-menu run live; toasts/dialogs have static samples plus buttons
  firing the real window.__zrToast / window.zrDialog implementations
- public/js/styleguide.js: classic script wiring the triggers
  defensively (feature-detects the kernel APIs), sets the
  indeterminate checkbox state, drives the segment and step samples
- server.js: GET /styleguide behind isAuthenticated, registered only
  when NODE_ENV !== 'production', mirroring the page-route shape in
  routes/setup.js; @Swagger block documents the dev-only gating and
  OPENAPI/openapi.json is regenerated (only the new path)
- Not linked in the navigation; reachable by URL only

Testing:
- Renders 200 via the EJS preview server in light and dark; all twelve
  sections present; zero data-state=error module mounts; no horizontal
  overflow at 1280px or 375px; btnbar collapses to its popover on
  mobile; toast/dialog triggers verified in-browser
- node scripts/regen-openapi.js is idempotent (no drift)
- ESLint/node --check/Prettier clean; test suite at baseline
  (62 passed / 2 skipped / 1 known environmental failure)

Impact:
- Dev builds gain /styleguide; production registers no route (404).
  The page is a snapshot, not a test — nothing fails if it goes stale

Upstream Status:
- Fork-specific; not applicable upstream

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Background:
The closing re-audit noticed the dark-mode image inversion rule was an
invalid declaration: filter: invert(var(--zr-text)) passes a colour
where invert() takes a number. History shows the rule worked until the
UI migration (--image-invert: 1 in the old dark theme; the token got
mechanically replaced by the text colour), so document thumbnails have
been glaring white in dark mode since the redesign. The migration also
broke the opt-out system: playground.js guarded the logo via the
removed .sidebar-header selector, so a naive repair would have
inverted the brand art.

Changes:
- pages/shared.css: filter: invert(1) (the original dark-theme value),
  with a comment recording the intent and the failure mode
- Repaired opt-outs the redesign lost: rail logo (body-open), login
  and styleguide mascots, setup logo — and newly the two MFA QR codes
  (settings, setup): an inverted QR code may stop scanning, and they
  had no protection even before the redesign
- Deleted public/js/playground.js and its include: the file consisted
  solely of the dead .sidebar-header logo guard

Testing:
- Browser (dark theme): mascot computes filter: none via .no-invert, a
  bare img computes invert(1); login renders with untouched brand art
- npm run lint:css and Prettier clean; full suite at baseline
  (62 passed / 2 skipped / 1 known environmental failure)

Impact:
- Document thumbnails invert in dark mode again (playground, history
  detail); brand art and QR codes stay true-colour

Upstream Status:
- The invert rule and no-invert convention originate upstream; the
  breakage and this repair are fork-specific

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Background:
The closing re-audit (N1) found four verbatim ~22-line showToast
implementations in failed.js, ignored.js, ocr.js and history.js, each
driving a duplicated static #toastNotification host with an inline
z-index override — with drift already begun (history computed its
error flag differently). The kernel publishes window.__zrToast for
exactly this classic-script case since batch 2.

Changes:
- Each page's showToast becomes the same 6-line guarded adapter onto
  window.__zrToast (call-time lookup, mirroring dialogs.js); function
  names and every call site stay untouched
- A call-site survey proved the mapping uniform: all four pages only
  ever pass 'success'/'error', so history's type !== 'success' and the
  others' type === 'error' are equivalent over real inputs
- The four #toastNotification host blocks are deleted from the views.
  This also removes a latent trap: those hosts carried
  class="zr-toasts hidden", so any kernel/dialogs.js toast fired on
  these pages would have been appended into a permanently hidden host

Testing:
- Live browser check on /ignored: a real action fires the kernel toast
  through the adapter (zr-toast--danger, role=alert); manual
  window.__zrToast on /failed and /history creates the kernel host on
  demand and stacks tones correctly
- grep: zero toastNotification references remain repo-wide
- ESLint/node --check/Prettier clean; suite at baseline

Impact:
- One toast implementation serves every page. Intended deltas: danger
  icon becomes the kernel's i-alert-circle, auto-dismiss 4000->4200ms,
  toasts stack and are click-dismissible, and role=alert/status is now
  announced (an a11y gain); net -204 lines

Upstream Status:
- Fork-specific consolidation; not applicable upstream

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bout (N2)

Background:
The closing re-audit (N2) found the GitHub issue-prefill logic —
including the secret-redaction regexes — duplicated between
sidebar-nav-scripts.ejs (every page) and about-scripts.ejs (/about,
where both copies executed). Redaction rules maintained twice are a
leak hazard: a fix applied to one copy leaves the other leaking.

Changes:
- New public/js/bug-report-link.js (classic script, window.zrBugReport
  following the zr* convention): the single implementation of
  redactSecrets, keyState, detectHostOs and the 17-field issue URL
  builder. Wiring is declarative: it reads
  script[type=application/json][data-bug-report-payload] elements and
  points the anchor named by data-bug-report-target
- sidebar-nav-scripts.ejs shrinks from 92 to 41 lines: a JSON payload
  element only; the script loads from body-close.ejs next to the other
  shell scripts
- about-scripts.ejs delegates its prefill (and the copy-diagnostics
  helpers) to the shared implementation; its non-prefill logic stays
- Degradation: if the script is missing, both anchors keep their
  static bug_report.yml fallback href — no secret can leak through the
  degraded path

Testing:
- Parity proof: EJS-rendered partials executed in a vm with a DOM stub
  produce byte-identical issue URLs before vs after for both entry
  points (sha256 match), preserving URLSearchParams field order. A
  second data set with HTML-special characters shows the one intended
  delta: the sidebar previously leaked EJS-escaped entities
  (&amp;, &#39;) into the issue body — it now matches the correct
  About behaviour
- Payload breakout check: a </script> injection attempt in a value
  neither breaks the JSON element nor parsing
- Browser: sidebar link and About button produce identical hrefs with
  [REDACTED] URL credentials; copy-diagnostics still redacts
- grep: the redaction regexes exist in exactly one file
- ESLint/node --check/Prettier clean; suite at baseline

Impact:
- Secret redaction has a single home; the sidebar entity-escaping bug
  is gone; net -50 lines of duplicated inline script per page load

Upstream Status:
- Fork-specific consolidation; not applicable upstream

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@admonstrator
admonstrator changed the base branch from main to nightly August 12, 2026 11:45
@admonstrator admonstrator self-assigned this Aug 12, 2026
@admonstrator
admonstrator changed the base branch from nightly to main August 12, 2026 18:55
admonstrator added a commit that referenced this pull request Aug 12, 2026
Background:
nightly had drifted behind main by a full release cycle. Everything it
carried — the security hardening (redirect guard, DOM textContent
fixes, ReDoS and format-string fixes), the prompt fixes #262 and #127,
and every dependency bump including js-yaml 4.3.1 — had already
reached main through the v2026.08.01 release merge, only under
different commit ids. That divergence was what made PR #283 conflict.

Meanwhile main had moved on with work nightly never received: the
changelog feature (config/changelog.js, public/css/changelog.css), the
OCR auto-process service, batched document-metadata lookups, the
standalone Paperless-ngx connectivity probe (#277), the OCR search
rewrite, and five test files.

Changes:
- Merge main into nightly, resolving every conflict in favour of main.
  The result is byte-identical to main: git diff against main is empty.
  Verified beforehand that no file and no line of substance existed
  only on nightly — the apparent nightly-only content was older
  wording of the same swagger summaries and test assertions plus
  pre-redesign markup that main has since replaced.

Testing:
- git diff origin/main after the merge: empty (tree equality).
- git diff --diff-filter=A main nightly before the merge: empty (no
  nightly-only files).

Impact:
- nightly now mirrors main and can serve as the test environment
  again; its commit history is preserved (no force push, no rewrite).

Upstream Status:
- Branch maintenance; not applicable upstream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@admonstrator
admonstrator changed the base branch from main to nightly August 12, 2026 19:03
@admonstrator
admonstrator marked this pull request as ready for review August 12, 2026 19:05
@admonstrator
admonstrator merged commit f93113e into nightly Aug 12, 2026
6 checks passed
@admonstrator
admonstrator deleted the claude/dashboard-login-dedup-f2661a branch August 12, 2026 19:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant