Structural scaffolding for a panel-based document viewing desktop app: every shell area is present as a labelled placeholder with its real behaviour (resize, collapse, overflow, scroll) already implemented. No document engine, no business logic — the frame the components will later be dropped into.
Plain HTML/CSS/JS, no build step, no dependencies.
python3 -m http.server 8777 # then open http://localhost:8777/index.html
Everything was verified over http://. Opening index.html straight off disk is
built for as well — classic scripts rather than ES modules, an inline SVG sprite,
and BroadcastChannel/localStorage behind try/catch so the postMessage
transport carries the cross-window sync on its own — but that path is untested.
Serve over HTTP if you want the tested configuration.
| File | Purpose |
|---|---|
index.html |
the viewer shell |
control.html |
area on/off switches, theme switcher, live geometry readout |
Open the control window from the viewer's header (the right-hand button, next to the branding) or from the application menu → Layout controls…. It can also be opened on its own; it will find the viewer either way.
The two windows share one state object over three transports at once —
BroadcastChannel, postMessage between opener/child, and localStorage — so
they stay in sync over http:// and file:// alike, and the layout is
remembered between sessions.
Taken from the Figma Scaffolding Layout frame, including its deliberate asymmetries: the header spans the full width, the left rail and left sidebar are full-height columns (so the main toolbar starts to their right), and the status bar spans only the thumbnail + canvas columns.
┌──────────────────────────────────────────────────────────────┐
│ header full width 32 │
├────┬─────────────┬───────────────────────────────────────────┤
│ │ infobox │ main toolbar 48 │
│rail│ ─────────── ├──────────┬────────────────┬──────┬───────┤
│ L │ tools │ thumbs │ canvas │ side │ rail │
│ 48 │ ─────────── │ 180 │ (flex) │ bar │ R │
│ │ sidebar L │ │ │ R │ 48 │
│ │ 240 │ │ │ 240 │ │
│ │ ├──────────┴────────────────┤ │ │
│ │ │ status bar 24 │ │ │
└────┴─────────────┴───────────────────────────┴──────┴───────┘
All dimensions are multiples of 4px.
| Area | Size | Scrolling | Overflow |
|---|---|---|---|
| Header | 32px, full width | never | ⋯ menu |
| Main toolbar | 48px | never | ⋯ menu |
| Rails (left/right) | 48px | never | ⋯ menu |
| Left / right sidebar | 240px, drag 160–480 | vertical only | ⋯ menu in the tool strip |
| Thumbnail view | 180px, drag 120–400 | vertical only | — |
| Viewer canvas | fills, min 320px | both axes | — |
| Status bar | 24px | never | — |
Scrollbars are custom overlay bars: they float over the content, fade out ~1s after the last scroll, and take no layout width — a panel is exactly as wide whether or not its content overflows.
Splitters are 4px wide with a 12px pointer target.
- Drag to resize.
- Drag past the minimum to collapse. The splitter stays behind as an 8px handle with a chevron.
- Drag the handle outwards to re-open.
- Double-click to toggle.
- Rail tabs switch the sidebar's content; clicking the active tab collapses the sidebar (the Acrobat / VS Code convention).
- The main toolbar has toggles for the thumbnail view and the right sidebar.
Keyboard, with focus on a splitter (role="separator", full aria-value*):
| Key | Action |
|---|---|
| ← / → | resize by 8px |
| Shift + arrows | resize by 32px |
| Home | collapse |
| End | expand as far as fits |
| Enter / Space | toggle collapse |
One solver in js/resize.js owns every horizontal dimension. When the window
gets too narrow it first shrinks panels to their minimum, then collapses
them, both in the specified priority order:
right sidebar → left sidebar → thumbnail view
The canvas always keeps its 320px minimum. Because the solver derives everything from scratch on each pass — only the user's desired widths and manual collapses are stored — panels come back on their own when the window grows again, with no undo bookkeeping.
Switching an area off in the control window is the same code path: the area gets
hidden, its splitter with it, and the flex rows close the gap, so nothing empty
is ever left behind. Special cases are handled too — turning the canvas off lets
the thumbnail column take over the freed width rather than leaving a hole.
Three themes, switched from the control window or the application menu.
| Theme | Chrome | Panels | Canvas | Accent |
|---|---|---|---|---|
| Dark (default) | #30363F |
#434952 |
#595F67 |
#3170EB |
| Light | #E7E9EB |
#F7F9FB |
#808080 |
#264D97 |
| Test | one distinct hue per area, plus name badges on every region |
The palette is derived from the supplied sources rather than invented:
css/tokens.csscarries the--nfwc-*primitives verbatim from the MoCla2 / Fusion OO colour-token Figma file.- The dark mapping matches the JWT-NG predecessor pixel for pixel (sampled from
the Figma render: chrome
#30363F, panels#434952, selection#3170EB). - The light mapping matches the live jadice web viewer, which paints its header
in exactly
--nfwc-primary-background#E7E9EB— confirming the token file is the same one the app uses. - Type is Source Sans 3 / Source Sans Pro, the face the live viewer uses, with a system fallback if the webfont is unavailable.
A semantic layer (--bg-*, --text-*, --border-*) sits on top of the
primitives; components only ever consume the semantic names, so re-theming means
editing one block.
Targeting WCAG 2.1 level AA. What was actually verified in the browser, in all three themes:
- Text contrast — every rendered text node audited against its composited background: 154 nodes (166 in the test theme), all ≥ 4.5:1, zero failures.
- Non-text contrast (1.4.11) — 23 checks per theme covering the focus ring, scrollbar thumbs, control borders and the active-tab indicator against every surface they can land on (chrome, panels, canvas, popovers, the accent-filled selected state, the white document sheet). All pass.
Two findings from that audit shaped the design:
- A single-colour focus ring cannot pass everywhere — brand blue on a
brand-blue selected control measured 1.00:1. The ring is therefore
two-tone (a light band against a dark band, 21:1 with each other), drawn
inside the control so the chrome's
overflow: hiddencannot clip it. - A translucent scrollbar thumb cannot pass over both a white document page and an accent-filled row, so the thumb carries a hairline of the opposite luminance. Whichever side loses contrast, the other keeps it.
Also implemented:
- Landmarks (
banner,navigation,complementary,main,status), a skip link, andaria-labelledbyon every scrollable region. - Rails are real
tablists; toolbars are realtoolbars, both with roving tabindex and Arrow/Home/End navigation (ARIA APG). - Splitters are
role="separator"with livearia-valuenow/aria-valuetext("Left sidebar collapsed" / "…248 pixels wide"). - Menus are
role="menu"with arrow-key navigation,Escapeto close, and focus returned to the trigger. - Collapse/expand and panel switches are announced through a polite live region.
- Overlay scrollbars are pointer sugar only — the scroll containers keep native keyboard scrolling and are focusable.
prefers-reduced-motionandforced-colors(Windows High Contrast) honoured.
Not covered: no screen-reader pass with an actual AT, and no automated axe/Lighthouse run — the contrast and ARIA claims above come from the in-page audit and from reading the markup, not from a third-party tool.
index.html the shell
control.html the second window
css/tokens.css primitives, semantic tokens, metrics, the three themes
css/base.css reset, focus ring, overlay-scrollbar chrome, a11y utils
css/layout.css shell geometry, splitters, collapse/disable rules
css/components.css toolbars, rails, panels, slots, menus, toasts, canvas
css/control.css the control window
js/config.js area registry + panel limits — the single source of truth
js/bus.js cross-window state (BroadcastChannel + postMessage + storage)
js/resize.js the layout solver, drag/keyboard resize, responsive cascade
js/overflow.js popup menus and the ⋯ overflow controllers
js/overlay-scroll.js auto-hiding overlay scrollbars
js/app.js wiring for the shell
js/control.js wiring for the control window
- Add an entry to
AREASinjs/config.js(id, group, label, behaviour note). - Put
data-area="<id>"on the matching element inindex.html.
The control window's checkbox, the show/hide plumbing and the parent/child blocking all follow from that one entry.