Add the dashboard settings channel, wired for theme and lock

A dashboard could only ever receive as a set of tiles. This adds the dashboard
itself as a receiver: `settings` maps a name to a value plus an optional
binding. Unbound, the setting is simply its value — a wall panel that is always
dark costs no flow. Bound, a flow drives it live and the value is the fallback.

Two settings are wired: `theme` (system/light/dark) and `locked` (read-only).
There is no schedule field on purpose — a node publishing to the bound message
on a cron is what a schedule is here, which is the point of a channel.

- `messages_for()` now walks a dashboard's bound settings as well as its
  widgets' bindings. Without this a paired screen is refused its own theme
  message, on the one surface the setting exists for; it bounds the socket too.
- `locked` is gated in `usePublish`, so every control inherits it, and each
  control also draws itself disabled — a dead button reads as broken otherwise.
  The panel surface says Read-only in the corner.
- The theme is a class on the dashboard's own surface, never the root: inside
  the app shell it must not flip the chrome. `.light` gains the tokens `.dark`
  already had (mirrored in the index repo) so both directions work on a subtree.
- Settings bindings are type-checked from the document alone, the rule widget
  bindings follow, and mirrored on the server.
- A bound setting is drawn on the flow canvas as a dashboard-level endpoint.
- The demo's house flow now publishes `home.panel_theme`, which the demo
  dashboard's theme binds to: the panel goes dark after sunset, at no tile cost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
This commit is contained in:
2026-08-22 13:17:56 +02:00
co-authored by Claude Opus 5
parent 3e7b161950
commit d958d7cde6
18 changed files with 784 additions and 62 deletions
+44
View File
@@ -435,6 +435,13 @@ export const DashboardDef_InputSchema = {
type: 'array',
title: 'Pages'
},
settings: {
additionalProperties: {
'$ref': '#/components/schemas/SettingDef'
},
type: 'object',
title: 'Settings'
},
version: {
type: 'integer',
title: 'Version',
@@ -496,6 +503,13 @@ export const DashboardDef_OutputSchema = {
type: 'array',
title: 'Pages'
},
settings: {
additionalProperties: {
'$ref': '#/components/schemas/SettingDef'
},
type: 'object',
title: 'Settings'
},
version: {
type: 'integer',
title: 'Version',
@@ -2640,6 +2654,36 @@ export const SeriesPointSchema = {
title: 'SeriesPoint'
} as const;
export const SettingDefSchema = {
properties: {
value: {
title: 'Value'
},
message: {
type: 'string',
title: 'Message',
default: ''
},
dtype: {
type: 'string',
title: 'Dtype',
default: ''
}
},
type: 'object',
title: 'SettingDef',
description: `One dashboard-wide setting: a value, and optionally where it comes from.
Unbound — no \`\`message\`\` — the setting is simply \`\`value\`\`, which is what
makes a panel that is always dark cost no flow at all. Bound, a flow drives
it live and \`\`value\`\` is the fallback: what the dashboard uses until
something arrives, and whenever the message is silent.
A schedule is not a third case. A node publishing to the bound message on a
cron *is* the schedule, which is the whole reason this is a channel rather
than a switching rule per setting.`
} as const;
export const ShareRequestSchema = {
properties: {
lib_name: {
+24
View File
@@ -117,6 +117,9 @@ export type DashboardDef_Input = {
canvas_height?: number;
icon?: string;
pages?: Array<PageDef_Input>;
settings?: {
[key: string]: SettingDef;
};
version?: number;
has_draft?: boolean;
};
@@ -132,6 +135,9 @@ export type DashboardDef_Output = {
canvas_height?: number;
icon?: string;
pages?: Array<PageDef_Output>;
settings?: {
[key: string]: SettingDef;
};
version?: number;
has_draft?: boolean;
};
@@ -919,6 +925,24 @@ export type SeriesPoint = {
avg_lag_ms: number;
};
/**
* One dashboard-wide setting: a value, and optionally where it comes from.
*
* Unbound — no ``message`` — the setting is simply ``value``, which is what
* makes a panel that is always dark cost no flow at all. Bound, a flow drives
* it live and ``value`` is the fallback: what the dashboard uses until
* something arrives, and whenever the message is silent.
*
* A schedule is not a third case. A node publishing to the bound message on a
* cron *is* the schedule, which is the whole reason this is a channel rather
* than a switching rule per setting.
*/
export type SettingDef = {
value?: unknown;
message?: string;
dtype?: string;
};
export type ShareRequest = {
lib_name: string;
};