It described the wrong object. A dashboard is a document that may hang on a
hallway tablet and in a desk browser at the same time, and only one of those
has fingers on it — so the flag moves off `DashboardDef.settings` and onto
`PanelDef` as a plain bool, ticked in the Panels dialog. `useCanvasRoot` takes
it as an argument rather than reading the document, and `/panel/{id}` is the
only surface with a panel to ask.
Dropping the message binding with it is deliberate: nothing drove it, and a
flow deciding whether a screen has fingers on it was never the point. A stored
`settings.touch` is inert rather than migrated, which `_check_settings`
skipping unknown names already guaranteed.
The rail was the other half. It had no touch behaviour at all and its 40px
buttons met neither branch of the 44/32 rule. `[data-touch] .dui-rail{-item}`
in `ui/core/core.css` spends the padding and the gap on the buttons instead,
so they reach the 44px target and the rail comes out taller at exactly the
same width — `RAIL_INSET` never moves, and the arrangement under it does not
either.
Also closes the panels-dialog icon gap: `DashboardSummary` carries the `icon`
now, so the dialog draws each assigned dashboard's rail glyph beside its
checkbox. `initials()` went from three identical copies in the looks to one in
`Dashboard/icons.ts`, so the dialog and the rail fall back the same way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va7ExQDtuwKN7kNpHhWWNQ
`mobile.spec.ts` covers the stacked editor, which draws through
`DashboardView` on both sides. Desktop `?edit=true` swaps in `GridLayout`
directly and never mounts `LockedProvider`, so the branch the stacked fix
was matched to had no guard of its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYM38KSb4V4v2T71eifnZv
Two looks were somebody else's language spoken well, and neither was the
product's. A dashboard nobody has dressed yet should look like the rest
of the app, so there is now a third set that follows the root
DESIGN-GUIDELINES.md to the letter — `--card` surfaces told from the page
by a hairline and a low shadow rather than by colour, every control a
pill, 16px panels, frosted floating chrome, one slate-blue accent spent
on what a person can act on — and it is what `look` means when nothing
says otherwise.
That also turns the exemption the other way round. The dashboard is still
allowed to look unlike the product; it just no longer does so by default.
An existing dashboard, which has never named a look, lands on the design
it had before any of this.
Restraint is the style rather than an omission here: no ripple, no glow,
no lift, and a press answered by the colour changing. The one deliberate
departure is the selector, which holds its choice in `--primary` rather
than the `--accent` the segmented rule asks for — that is a decision
about the widget, not about the look, and a control must not change what
it signals when the drawing changes. All three sets hold it the same way.
Five things a wall panel showed that a wall panel should not.
**A tile's body no longer clips.** It scrolled, and a box that scrolls
also cuts whatever crosses its edge — which took the glow off a lit
button at exactly the width where the button filled its tile, and off a
gauge's arc at exactly the height where the dial filled its own. Only
what is written or listed asks for a scroller now; everything else is a
picture drawn to fit, and what overflows is left to the frame, which
clips at the tile's edge where a shadow has already faded out. The
slider's phantom scrollbar goes with it.
**The selector is a selector.** Named for what it does rather than what
it is, and the choice it is holding is held in the dashboard's own
primary — a pill that slides between the options rather than a grey one
that had to be looked for. The stored type is untouched, so no document
changes meaning.
**The arrangement is held off the panel's edges**, by the same distance
it holds between two widgets. The ground is not held off with it: a
background covers the whole panel, and only what is arranged on it has
a margin. No stored panel loses a row to it.
**The rail is drawn on the panel.** It was chrome bolted to the edge of
the screen beside the canvas — in the app's own design rather than the
dashboard's, and on a scaled canvas not even lined up with it. It now
takes a column out of the canvas the way the margin does, scaled with
it and wearing its look. Which cell each widget sits in is unchanged;
only how big a cell is.
Two of these were the same mistake twice: an unlayered rule stating
`position` for everything wearing a class — `.gl-surface` on a rail
placed by a utility, and a blanket lift over every child of a pressable
on a pill placed by `layoutId`. Both now say it one element at a time.
- A tile no longer lifts under the pointer. A finger does not move away
afterwards the way a cursor does, so whatever hover raised stayed
raised until something else was touched: a tile stuck, not answering.
- The ground's blobs wander a closed path on their own clock instead of
sliding back and forth along one line, which read as things moving
rather than as light in a room.
- A bar is one grid now, its rows borrowing its columns, so names of
different lengths no longer start and end their tracks in different
places — two bars that share no baseline cannot be compared, which is
the one thing a stack of them is for. The names read rightward into
their tracks, with room either side.
- A reading on its way somewhere is written to as many decimals as the
value it is heading for. Without that a slider stepping in halves
passed through 22.37460937 on its way to 24: a number nobody asked
for, a different width every frame.
- The brightness column is the same control as the slider widget's,
stood on its end and thicker, and exactly as tall as the disc beside
it. Getting there meant drawing a slider's rail, fill and handle
rather than styling `::-webkit-slider-*`: those need one set of rules
per orientation, each with its own centring quirk, and the handle
landed off its track when the writing mode turned. The native input
stays, laid transparent over the top, so the keyboard, the pointer and
every `aria-` are still its.
The disc was sized against its parents in percentages, and every box
between it and the tile is sized by what is in it — so the chain never
resolved, the brightness slider grew, and the tile scrolled with half a
disc showing. Container units are the tile's own height whatever sits in
between, so the widget now fits exactly.
Two guards behind it, since both are things a picture can fail silently:
nothing on a panel of pictures may overflow its tile, and Touch has to
actually make a control bigger while leaving what it publishes alone.
A dashboard is a wall panel somebody hangs in their own hallway, so it
now wears what they choose: a look, and a palette of their own colours.
Two complete component sets live under `Dashboard/ui/` — `glass`
(translucent panes over a slowly moving ground) and `material` (Material
3 tonal cards) — behind one prop contract. Every control's state,
keyboard and `aria-` live in `ui/core` and are shared, so the two sets
are the same dashboard drawn twice rather than two products: a set only
decides what a control looks like while doing it.
Four settings join the channel, each drivable by a flow like any other:
`look`, `palette`, `background` and `touch`. A palette is an ordered list
of hex colours — background, surface, primary, accent, text, then more
chart colours — pasted from a coolors.co link or typed, written onto the
canvas as the token variables everything already reads. Trailing roles
are derived, so three colours are a whole dashboard, and derived text is
held to AA rather than trusted (`theme.check.ts` measures it). A palette
also decides light or dark, since its first colour is the ground.
Widgets are measured against their own tile with container queries rather
than against the viewport, animate through `motion`, and can be drawn
without their title. The three reworks:
- a bar draws a row per reading, up to eight, each in the dashboard's own
data colours and each able to carry its own scale — replacing readings
nested in one fill, which could only ever share one colour and stop at
three. Documents written the old way are read as rows.
- a chart's range picker moved to a column down its right-hand edge, which
gives the plot back a whole row of a short tile.
- the colour wheel became a disc: hue is the angle and saturation the
distance from the middle, so a colour is one gesture rather than three,
with brightness on a slider beside it.
`index.css` and `lib/motion.ts` are untouched — the dashboard overrides
token *values* on its canvas, never the blocks the two repos share.
uPlot writes what `cursor.move` returns back into the value it hands in next
time and calls it again on every redraw — and a chart sets its data on every
render. The correction was therefore applied repeatedly: the cursor walked
left while the pointer stood still, and the position taken at mousedown no
longer matched the one held at mouseup, which uPlot reads as a drag and
answers by swallowing the click. That is what stopped a chart tile being
selectable in the dashboard editor.
The refiner now returns its previous answer unchanged when handed it back, so
a redraw is a no-op. Lifted out of the config as `cursorRefiner` so the branch
can be checked without a browser; `cursor.check.ts` replays uPlot's own
sequences, including the press-is-not-a-drag one that regressed.
The Playwright hover test now also asserts the cursor holds its place across a
redraw, which is what the first version missed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZeGnqVsf5VHQqvz4HdUhN
A dashboard canvas is CSS-scaled to fit its panel while uPlot maps the
pointer against its own unscaled plot width, so the cursor drifted further
right the further into a chart it went. A `cursor.move` refiner divides the
visual offset back into layout pixels; unscaled hosts get a no-op.
A querying chart's range picker moves onto the frame's title line through a
new `useHeaderSlot`, giving the plot back the row it spent. The editor's drag
handle is the header, so the picker is exempted from it.
Charts can be drawn as a monotone cubic spline — uPlot's own path builder,
monotone so a smoothed line never invents a reading between two real ones.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZeGnqVsf5VHQqvz4HdUhN
A dashboard went live the moment it was created — an empty document straight to
the panels — while a new flow starts as a draft. It now works the way flows do:
published means `dashboard.json` exists, so every dashboard on every running
installation is already published and nothing needs migrating. Only the ones
created from here on start as drafts.
Mirroring FlowStore turned up a latent 500: discarding the draft of a dashboard
that had never been published unlinked its only file, and the read that followed
raised out of a 200 handler. It answers 400 now, the way a flow does.
Publishing all of them was 2N requests, because a publish has to name the
version it expects and the summaries did not carry one. They do now — and so do
the flow summaries, which had the same defect nobody had written down.
A panel had no way to hear about any of this. A publish, or a change to which
dashboards a panel carries, now puts one event on the bus and the screen
refetches what changed: no reload, so a wall display never blanks or asks for
its credential again. The subtle half is that a socket's message allowlist was
computed once at handshake — a reassigned panel would have fetched its new
document and then shown tiles that never updated.
The panels dialog logged non-superusers out. Every write in it needs a
superuser, not only the checkboxes the report mentioned, so the dialog is
read-only for everyone else. The logout itself was `main.tsx` treating 403 as a
dead session, against the contract deps.py spells out: only a 401 ends a
session, and a 403 now says so rather than silently signing someone out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
A bar drew its nested reading on top of the outer one in --chart-5, which
measures 2.53:1 against --primary and lost the 3:1 guideline for non-text. The
readings now partition the fill end to end, up to three of them, in a token of
their own: --primary-nested, the primary hue a few steps deeper, 3.14:1 light
and 3.12:1 dark. It cannot also clear 3:1 against --muted — in dark those two
are 5.82:1 apart and a colour 3:1 from both would need a 9:1 gap — so a segment
is drawn inside a gutter of outer fill rather than ever bordering the track,
which is what separates neighbours too, and what caps the count at three. A
nested value larger than its outer used to spill onto the track; it is clamped.
`inner` still reads as a single binding, so no dashboard needs migrating.
On a phone, .widget-grid took its width from the widest thing any widget held —
a truncating flex item still offers its whole unwrapped line as a min-content
contribution — and a handful of widgets had no floor of their own: the uPlot
legend is a table, a fieldset carries min-inline-size: min-content from the UA
sheet, and buttons are whitespace-nowrap. Each is capped now. A widget's body
scrolls rather than clipping, so long text stops painting over the title.
Gauges and bars move between readings instead of jumping, and a segmented
control slides one thumb rather than recolouring cells. The gauge arc is drawn
whole and revealed by its dash, because `d` cannot be transitioned.
UplotChart pushed new readings only when the point count changed, so once a
rolling window was full a refetch left the old values on screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
The four widget types added this round had renderers but no way to configure
them. The panel now offers a bar's nested reading, unit and range (no step —
that is a slider's), the forecast's item count on the agenda's field, a chart's
y axis title beside its y range, and the icon's mapping editor: a value, a
glyph and a colour per row, first match wins, with a fallback glyph below.
Adds tests/widgets.spec.ts, which asserts each of them on /view: the nested bar
inside its outer fill, the glyph following the message, five forecast columns
fading outwards, a clock that reads the wall without being flagged unbound,
a segmented control and a latching button reading back what they published,
and an unbound tile that says so instead of taking the page down. The axis
title is drawn into uPlot's canvas, so it is checked by panel round-trip.
mobile.spec.ts grows a bar and a forecast so the width check covers them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA