A buttons widget: a grid of presses on one message
Docs / docs (push) Successful in 21s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m9s
Playwright Tests / test-playwright (2, 2) (push) Failing after 11s
pre-commit / pre-commit (push) Failing after 2m6s
Test Backend / test-backend (push) Failing after 2m27s
Compose Smoke Test / test-compose (push) Failing after 1m54s
Playwright Tests / merge-reports (push) Failing after 2m16s

Several stateless instructions where a single button is one — six presets are
otherwise six tiles to place, six titles to read, and the message they share
repeated six times. Nothing is read back, as for a single button: what these
send is an instruction, and the last one sent is not a state to draw.

Auto-fit columns rather than a configured count: a tile is resized in the
editor and scaled again to whatever panel it hangs on, so how many fit is not
something the document can know.

The editor reads `cfg.buttons` raw rather than through `buttonsOf`, which
drops the blanks — a row being typed into is blank until the first keystroke.

Also carried along by the hooks: the generated SDK was stale (it had no
`RunsReadMetricNames`) and one pre-existing block in `test_panels.py` was
unformatted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bm2HwnRsTDpeWjSPNJxQd
This commit is contained in:
2026-09-04 08:44:16 +02:00
co-authored by Claude Opus 5
parent c8f14b6913
commit 6f0b7c7eb4
11 changed files with 252 additions and 10 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ export const OpenAPI: OpenAPIConfig = {
PASSWORD: undefined,
TOKEN: undefined,
USERNAME: undefined,
VERSION: '0.1.4+dev',
VERSION: '0.1.5+dev',
WITH_CREDENTIALS: false,
interceptors: {
request: new Interceptors(),
+1 -1
View File
@@ -3783,7 +3783,7 @@ export const WidgetDefSchema = {
},
type: {
type: 'string',
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'media', 'player', 'embed', 'button', 'switch', 'slider', 'input', 'dropdown', 'color'],
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'media', 'player', 'embed', 'button', 'buttons', 'switch', 'slider', 'input', 'dropdown', 'color'],
title: 'Type'
},
title: {
File diff suppressed because one or more lines are too long
+13 -2
View File
@@ -1302,7 +1302,7 @@ export type WebPushKey = {
*/
export type WidgetDef = {
id: string;
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'buttons' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
title?: string;
layout?: {
[key: string]: Placement;
@@ -1312,7 +1312,7 @@ export type WidgetDef = {
};
};
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'buttons' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
export type WorkerInfo = {
name: string;
@@ -1860,6 +1860,17 @@ export type RunsExportRunsData = {
export type RunsExportRunsResponse = (unknown);
export type RunsReadMetricNamesData = {
flow?: (string | null);
group?: (string | null);
ids?: string;
since?: (string | null);
status?: (string | null);
until?: (string | null);
};
export type RunsReadMetricNamesResponse = (Array<(string)>);
export type RunsReadRunData = {
runId: string;
};
@@ -101,3 +101,21 @@
border-right-color: var(--muted-foreground);
border-bottom-color: var(--muted-foreground);
}
/*
* A grid of presses, inside one tile.
*
* Auto-fit rather than a configured column count: the tile is resized in the
* editor and scaled again to whatever panel it hangs on, so the number of
* columns that fits is not something the document can know. `1fr` rows so the
* presses share the tile's height however many rows they wrap into — a target
* on a wall panel is pressed without looking at it.
*/
.widget-buttons {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(auto-fit, minmax(5.5rem, 1fr));
grid-auto-rows: 1fr;
height: 100%;
min-height: 0;
}
+83 -1
View File
@@ -59,7 +59,13 @@ import {
settingOf,
THEME_CHOICES,
} from "./settings"
import { type BarRow, MAX_ROWS, rowsOf, showTitle } from "./ui/core/config"
import {
type BarRow,
type ButtonEntry,
MAX_ROWS,
rowsOf,
showTitle,
} from "./ui/core/config"
import { parsePalette, roleLabel } from "./ui/core/theme"
import {
acceptsDtype,
@@ -482,6 +488,12 @@ export function WidgetPanel({
// Nothing wrote these until now, so a dropdown's choices were uneditable.
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
const setOptions = (next: typeof options) => set({ options: next })
// A grid's presses, read raw rather than through `buttonsOf`: that drops the
// blanks, and a row being typed into is blank until the first keystroke. An
// empty row stands in for none, so a fresh grid offers the fields.
const written = (cfg.buttons ?? []) as ButtonEntry[]
const buttons: ButtonEntry[] = written.length ? written : [{}]
const setButtons = (next: ButtonEntry[]) => set({ buttons: next })
const querying = cfg.source === "query"
const pinnedRuns = cfg.source === "runs"
// What this chart would refresh at with nothing configured. The viewer can
@@ -1054,6 +1066,76 @@ export function WidgetPanel({
</div>
) : null}
{widget.type === "buttons" ? (
<div className="grid gap-2">
<span className={PANEL_SECTION}>Buttons</span>
<InfoTip label="Buttons">
One press per row, all publishing to the message above. The grid
fits as many columns as the tile is wide; a row with no value
sends its own label.
</InfoTip>
{buttons.map((button, index) => (
<div
// Position is the only identity a press has.
key={`press-${index}`}
className="flex items-end gap-1.5"
>
<Input
className="min-w-0 flex-1"
value={str(button.label)}
placeholder="Label"
aria-label="Button label"
onChange={(event) =>
setButtons(
buttons.map((other, at) =>
at === index
? { ...other, label: event.target.value }
: other,
),
)
}
/>
<Input
className="w-28"
value={str(button.value)}
placeholder="Value"
aria-label="Button value"
onChange={(event) =>
setButtons(
buttons.map((other, at) =>
at === index
? { ...other, value: coerce(event.target.value) }
: other,
),
)
}
/>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove button"
onClick={() =>
setButtons(buttons.filter((_, at) => at !== index))
}
>
<X />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
onClick={() => setButtons([...buttons, {}])}
data-testid="add-button"
>
<Plus />
Add button
</Button>
</div>
) : null}
{widget.type === "dropdown" ? (
<div className="grid gap-2">
<span className={PANEL_SECTION}>Options</span>
@@ -89,6 +89,15 @@ export function asOriginal(
return match ? match.value : selected
}
/** One press a button grid draws, as the document stores it. */
export type ButtonEntry = { label?: string; value?: unknown }
/** The presses a grid draws. A row with neither half is not one. */
export const buttonsOf = (widget: WidgetDef): ButtonEntry[] =>
((config(widget).buttons ?? []) as ButtonEntry[]).filter(
(entry) => entry.label || entry.value !== undefined,
)
/**
* How many readings one bar draws, and a hard ceiling.
*
+46 -1
View File
@@ -19,11 +19,12 @@ import { PlayerWidget } from "./PlayerWidget"
import { usePublish } from "./publish"
import { useUi } from "./ui"
import { COLOR_DTYPES, colorFormatOf } from "./ui/core/color"
import { config, num, rowsOf, text } from "./ui/core/config"
import { buttonsOf, config, num, rowsOf, text } from "./ui/core/config"
/** Widget types that put a value into the graph rather than read one. */
export const INPUT_WIDGETS = new Set([
"button",
"buttons",
"switch",
"slider",
"input",
@@ -88,6 +89,7 @@ export const WIDGET_LABELS: Record<WidgetKind, string> = {
player: "Player",
embed: "Website",
button: "Button",
buttons: "Buttons",
switch: "Switch",
slider: "Slider",
input: "Input",
@@ -111,6 +113,7 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
player: { w: 4, h: 3 },
embed: { w: 6, h: 4 },
button: { w: 3, h: 2 },
buttons: { w: 4, h: 3 },
switch: { w: 3, h: 2 },
slider: { w: 4, h: 2 },
input: { w: 4, h: 2 },
@@ -210,6 +213,11 @@ export function widgetIssue(widget: WidgetDef): string | null {
return "This player does not publish to a message yet."
}
// A grid with nothing in it publishes nothing, however well it is bound.
if (widget.type === "buttons" && !buttonsOf(widget).length) {
return "This grid has no buttons yet."
}
const input = INPUT_WIDGETS.has(widget.type)
const bound = text(cfg[input ? "target" : "message"])
if (!bound) {
@@ -501,6 +509,42 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
)
}
/**
* Several stateless presses, one message.
*
* A grid rather than one tile per press: six presets are six tiles to place
* and six titles to read, and what they have in common — where they publish —
* is then repeated six times. Nothing is read back, exactly as for a single
* button: what these send is an instruction, and the last one sent is not a
* state anything can be drawn as.
*/
function ButtonsWidget({ widget, dashboard }: WidgetProps) {
const { Button } = useUi()
const { target, send, pending, locked, pulse } = usePublish(widget, dashboard)
const buttons = buttonsOf(widget)
if (!target) return <Unbound />
return (
<>
{pulse}
<div className="widget-buttons">
{buttons.map((button, index) => (
<Button
// Two presses may share a label and a value; position is the
// identity, as it is for a chart's series and a bar's rows.
key={`button-${index}`}
variant="tonal"
disabled={pending || locked}
onClick={() => send(button.value ?? button.label ?? true)}
>
{text(button.label, text(button.value, "Send"))}
</Button>
))}
</div>
</>
)
}
/**
* A bool, published and read back — a latch either way it is drawn.
*
@@ -697,6 +741,7 @@ const RENDERERS: Partial<
player: PlayerWidget,
embed: EmbedWidget,
button: ButtonWidget,
buttons: ButtonsWidget,
switch: SwitchWidget,
slider: SliderWidget,
input: InputWidget,
+20
View File
@@ -151,6 +151,20 @@ test.beforeAll(async ({ browser }) => {
],
},
},
{
id: "scenes",
type: "buttons",
title: "Scenes",
layout: { lg: { x: 9, y: 0, w: 3, h: 2 } },
config: {
target: w("mode"),
dtype: "str",
buttons: [
{ label: "Night", value: "night" },
{ label: "Away", value: "away" },
],
},
},
{
id: "lamp",
type: "switch",
@@ -488,6 +502,12 @@ test("a control reads back what it published", async ({ page }) => {
await boost.click()
await expect(boost).toHaveAttribute("aria-pressed", "true")
// A grid draws one press per entry and publishes the value of the one
// pressed — nothing is read back, so the segmented control beside it, bound
// to the same message, is what says the publish landed.
await page.getByRole("button", { name: "Night" }).click()
await expect(boost).toHaveAttribute("aria-pressed", "false")
// The latching button names its state; two presses are a round trip.
const lamp = page.getByRole("button", { name: "Lamp" })
await expect(lamp).toHaveText("Off")