Draw the panel as a panel

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.
This commit is contained in:
2026-08-24 10:44:53 +02:00
parent fea57064f9
commit 6238728dce
18 changed files with 389 additions and 173 deletions
@@ -67,6 +67,19 @@ export const fractionOf = (
export const showTitle = (widget: WidgetDef): boolean =>
config(widget).show_title !== false
/**
* Whether this widget's body may scroll.
*
* Only what is written or listed: a note is as long as it is, and an agenda
* has as many entries as the week does. Everything else is a picture drawn to
* fit the tile, and a picture that scrolls is one nobody can see the rest of —
* as well as a box that clips whatever glow or shadow crosses its edge.
*/
export const scrolls = (widget: WidgetDef): boolean =>
widget.type === "markdown" ||
widget.type === "agenda" ||
widget.type === "notification"
/** Radix hands back a string; the message wants whatever was configured. */
export function asOriginal(
selected: string,
@@ -36,6 +36,8 @@ export type FrameProps = {
issue?: string | null
/** Make this widget draggable in the editor. */
grip?: boolean
/** Let the body scroll. Only what is written or listed asks for it. */
scroll?: boolean
selected?: boolean
onClick?: React.MouseEventHandler<HTMLDivElement>
children: React.ReactNode
@@ -51,7 +51,7 @@ export function Button({
onClick={onClick}
>
<span className="gl-glow" aria-hidden />
<span className="min-w-0 truncate">{children}</span>
<span className="gl-lift min-w-0 truncate">{children}</span>
</motion.button>
)
}
@@ -181,11 +181,14 @@ export function Select({
className="gl-field gl-pressable justify-between"
>
<span className="gl-glow" aria-hidden />
<span className="min-w-0 truncate">
<span className="gl-lift min-w-0 truncate">
<SelectPrimitive.Value placeholder="Choose" />
</span>
<SelectPrimitive.Icon asChild>
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden />
<ChevronDown
className="gl-lift size-4 shrink-0 opacity-60"
aria-hidden
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
@@ -94,6 +94,7 @@ export function Frame({
title,
issue,
grip,
scroll,
selected,
onClick,
children,
@@ -121,7 +122,9 @@ export function Frame({
{issue ? <Issue issue={issue} /> : null}
</div>
) : null}
<div className="dui-frame-body">{children}</div>
<div className={cn("dui-frame-body", scroll && "dui-frame-scroll")}>
{children}
</div>
{/* No header to sit in, so the fault takes the corner instead. */}
{!title && issue ? (
<div className="dui-frame-corner">
@@ -155,7 +158,7 @@ export function Rail({ entries }: RailProps) {
aria-label="Dashboards on this panel"
data-testid={TESTID.rail}
className={cn(
"gl-surface gl-rail pointer-events-auto absolute inset-y-4 left-4 z-10 flex w-14 flex-col items-center gap-1 p-2",
"gl-surface gl-rail pointer-events-auto absolute inset-y-3 left-3 z-10 flex w-12 flex-col items-center gap-1 p-1",
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
>
@@ -95,8 +95,11 @@
/* --- surfaces ------------------------------------------------------------ */
/* No `position` of its own: this is worn by a tile, a rail and a notice alike,
and each of those is placed by whatever puts it there. Stating it here won
over the utility that placed them — an unlayered rule beats a layered one —
and collapsed the rail into the flow. */
.gl-surface {
position: relative;
border: 1px solid var(--gl-border);
border-radius: var(--gl-radius);
background: var(--gl-bg);
@@ -181,7 +184,12 @@
opacity: 0;
}
.gl-pressable > :not(.gl-glow) {
/* Lifted one at a time rather than by a blanket rule over every child: that
one restated `position` on things already placed — a sliding pill, a switch's
thumb — and dropped them back into the flow. */
.gl-switch-thumb,
.gl-segment-label,
.gl-lift {
position: relative;
z-index: 1;
}
@@ -316,7 +324,7 @@
}
.gl-segment[data-active] {
color: var(--foreground);
color: var(--primary-foreground);
}
.gl-segment-thumb {
@@ -324,13 +332,11 @@
inset: 0;
z-index: 0;
border-radius: 9999px;
background: var(--gl-bg-strong);
box-shadow: var(--gl-inset);
background: color-mix(in srgb, var(--primary) 85%, transparent);
box-shadow: var(--gl-glow), var(--gl-inset);
}
.gl-segment-label {
position: relative;
z-index: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -80,7 +80,7 @@ export function Button({
onClick={onClick}
>
<Skin presses={presses} done={done} />
<span className="min-w-0 truncate">{children}</span>
<span className="m3-lift min-w-0 truncate">{children}</span>
</button>
)
}
@@ -207,11 +207,14 @@ export function Select({
disabled={disabled}
className="m3-field m3-pressable justify-between"
>
<span className="min-w-0 truncate">
<span className="m3-lift min-w-0 truncate">
<SelectPrimitive.Value placeholder="Choose" />
</span>
<SelectPrimitive.Icon asChild>
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden />
<ChevronDown
className="m3-lift size-4 shrink-0 opacity-60"
aria-hidden
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
@@ -58,6 +58,7 @@ export function Frame({
title,
issue,
grip,
scroll,
selected,
onClick,
children,
@@ -85,7 +86,9 @@ export function Frame({
{issue ? <Issue issue={issue} /> : null}
</div>
) : null}
<div className="dui-frame-body">{children}</div>
<div className={cn("dui-frame-body", scroll && "dui-frame-scroll")}>
{children}
</div>
{/* No header to sit in, so the fault takes the corner instead — a widget
drawn without its title still says when it is mis-wired. */}
{!title && issue ? (
@@ -121,7 +124,7 @@ export function Rail({ entries }: RailProps) {
aria-label="Dashboards on this panel"
data-testid={TESTID.rail}
className={cn(
"m3-rail pointer-events-auto absolute inset-y-4 left-4 z-10 flex w-14 flex-col items-center gap-1 p-2",
"m3-rail pointer-events-auto absolute inset-y-3 left-3 z-10 flex w-12 flex-col items-center gap-1 p-1",
// More dashboards than the column is tall still scroll, but no bar is
// ever drawn: a wall panel is swiped, and there is no room for one.
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
@@ -100,7 +100,12 @@
background: currentColor;
}
.m3-pressable > :not(.m3-state):not(.m3-ripple) {
/* Lifted one at a time rather than by a blanket rule over every child: that
one restated `position` on things already placed — a sliding pill, a switch's
thumb — and dropped them back into the flow. */
.m3-switch-thumb,
.m3-segment-label,
.m3-lift {
position: relative;
z-index: 1;
}
@@ -233,7 +238,7 @@
}
.m3-segment[data-active] {
color: var(--card-foreground);
color: var(--primary-foreground);
}
.m3-segment-thumb {
@@ -241,12 +246,10 @@
inset: 0;
z-index: 0;
border-radius: 9999px;
background: var(--m3-sc);
background: var(--primary);
}
.m3-segment-label {
position: relative;
z-index: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;