Dedupe the dashboard+login UI and split the framework CSS into layered thematic files - #283
Merged
Merged
Conversation
…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>
…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 (&, ') 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
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
marked this pull request as ready for review
August 12, 2026 19:05
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 conflictingmin-height; the stacked-field re-declarations forced three specificity-repair rules; the legacy.modalblocks were doubled — all merged. Nine verified-unused rules, two unreachable JS modules (one with German mock strings) and deadthemeToggleids deleted.escapeHtmlexisted six times (three incomplete); now one export inmodules/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-erroruse the shell head partials; one nav data source renders rail + tabbar (render-diff: byte-identical rail, whitespace-only tabbar);.login-step/.setup-stepmerged 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 inbaseso components override them without selector doubling; state utilities win over everything.zr-faintbeating the OCR search error colour) is fixed in markup.Guardrails (commits 6–8)
bodyrule 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)
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).invert()was handed a colour token); restored toinvert(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
.modal-overlayscrim tone unified; danger toasts announcerole="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.rate-limiting, which fails identically on an untouchedmaincheckout against a live instance on:3000(environmental, pre-existing).zr.cssdirectly; they now load the split files throughtests/framework-css.jsin link order.package.jsonchanges triggerdocker-check.yml(build-only) as expected. OpenAPI: only the new dev-only/styleguidepath (regen is idempotent).settings-scripts.ejs(separate task).🤖 Generated with Claude Code