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 2f0e50fc9f
commit 092a5333e8
19 changed files with 803 additions and 62 deletions
+87
View File
@@ -96,6 +96,42 @@ WIDGET_DTYPES: dict[str, set[str]] = {
} }
#: What a dashboard-wide setting may be bound to, by payload type. The channel
#: is general — a setting is a value plus an optional binding — but the wired
#: ones are a closed set, and a name missing here is simply a setting this
#: build does not act on. Mirrored in the client
#: (``frontend/src/components/Dashboard/settings.tsx``).
SETTING_DTYPES: dict[str, str] = {
# "system" | "light" | "dark". A panel in a room has no way to set the
# device preference the app otherwise inherits.
"theme": "str",
# Read-only: the input widgets stop publishing.
"locked": "bool",
}
class SettingDef(BaseModel):
"""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.
"""
value: Any = None
#: The message that drives it, or empty for a setting that is just a value.
message: str = ""
#: The payload type the editor recorded when it bound that message, so the
#: pairing can be judged from the document alone — the rule widget bindings
#: are held to.
dtype: str = ""
class Placement(BaseModel): class Placement(BaseModel):
"""Where a widget sits in its section's grid, in grid units.""" """Where a widget sits in its section's grid, in grid units."""
@@ -311,6 +347,11 @@ class DashboardDef(BaseModel):
#: letters of the title. #: letters of the title.
icon: str = "" icon: str = ""
pages: list[PageDef] = Field(default_factory=list) pages: list[PageDef] = Field(default_factory=list)
#: Settings the whole dashboard carries, by name — see ``SettingDef``. The
#: one channel a dashboard consumes as a dashboard rather than as a set of
#: tiles, so a screen on a wall can be told things nobody standing at it
#: could set.
settings: dict[str, SettingDef] = Field(default_factory=dict)
#: Bumped on every save; a save based on an older one is refused. #: Bumped on every save; a save based on an older one is refused.
version: int = 1 version: int = 1
#: Whether there are unpublished changes. Reported by the store on read, #: Whether there are unpublished changes. Reported by the store on read,
@@ -322,10 +363,35 @@ class DashboardDef(BaseModel):
def _check_name(cls, value: str) -> str: def _check_name(cls, value: str) -> str:
return _validate_name(value) return _validate_name(value)
@model_validator(mode="after")
def _check_settings(self) -> DashboardDef:
"""Refuse a setting driven by a message it cannot carry.
Judged from the document alone, exactly as a widget's binding is: the
picker records the payload type beside the name, so neither the editor
nor a wall panel has to fetch the catalogue to know the wiring is
wrong. A name this build does not know is left alone rather than
refused — an older installation reading a newer document simply does
not act on it.
"""
for name, setting in self.settings.items():
want = SETTING_DTYPES.get(name)
if want and setting.dtype and setting.dtype != want:
raise ValueError(
f"the '{name}' setting needs a '{want}' message, "
f"not a '{setting.dtype}'"
)
return self
@property @property
def widgets(self) -> list[WidgetDef]: def widgets(self) -> list[WidgetDef]:
return [w for p in self.pages for s in p.sections for w in s.widgets] return [w for p in self.pages for s in p.sections for w in s.widgets]
@property
def setting_messages(self) -> list[str]:
"""Every message a bound setting reads. Empty for a static dashboard."""
return [s.message for s in self.settings.values() if s.message]
class DashboardSummary(BaseModel): class DashboardSummary(BaseModel):
"""A dashboard in a list, without its contents.""" """A dashboard in a list, without its contents."""
@@ -544,6 +610,25 @@ class DashboardStore:
defn = DashboardDef.model_validate_json(path.read_text()) defn = DashboardDef.model_validate_json(path.read_text())
except Exception: except Exception:
continue continue
# A bound setting is a consumer too — the dashboard itself reading
# a message rather than any tile on it — so the canvas accounts for
# it the same way. ``widget`` is what the endpoint id is built
# from, and no widget id can collide with it: a dot is not a legal
# name character.
for name, setting in defn.settings.items():
if not setting.message.startswith(prefix):
continue
found.append(
{
"dashboard": defn.name,
"dashboard_title": defn.title or defn.name,
"widget": f"settings.{name}",
"title": f"{defn.title or defn.name} {name}",
"type": "setting",
"provides": "",
"requires": [setting.message],
}
)
for widget in defn.widgets: for widget in defn.widgets:
# A control produces the message; a tile consumes it. # A control produces the message; a tile consumes it.
produces = widget.target if widget.target.startswith(prefix) else "" produces = widget.target if widget.target.startswith(prefix) else ""
@@ -599,6 +684,7 @@ __all__ = [
"DASHBOARD_DIR", "DASHBOARD_DIR",
"HISTORY_CAP", "HISTORY_CAP",
"INPUT_WIDGETS", "INPUT_WIDGETS",
"SETTING_DTYPES",
"WIDGET_DTYPES", "WIDGET_DTYPES",
"DashboardDef", "DashboardDef",
"DashboardExists", "DashboardExists",
@@ -609,6 +695,7 @@ __all__ = [
"PageDef", "PageDef",
"Placement", "Placement",
"SectionDef", "SectionDef",
"SettingDef",
"WidgetDef", "WidgetDef",
"default_dashboard", "default_dashboard",
] ]
+6 -1
View File
@@ -85,11 +85,15 @@ def find(panel_id: str) -> PanelDef | None:
def messages_for(panel_id: str, store: DashboardStore) -> set[str]: def messages_for(panel_id: str, store: DashboardStore) -> set[str]:
"""Every message the widgets of this panel's dashboards read or write. """Every message this panel's dashboards read or write.
What a screen is entitled to see, as its own dashboards define it. Read What a screen is entitled to see, as its own dashboards define it. Read
from the published documents, since that is what a panel draws, and empty from the published documents, since that is what a panel draws, and empty
for a panel that is gone — which is the same answer as "nothing". for a panel that is gone — which is the same answer as "nothing".
A dashboard's own bound settings count, not only its widgets': the theme a
panel is driven to is a message no tile on it draws, and a wall panel
refused its own theme message is the one surface the setting exists for.
""" """
panel = find(panel_id) panel = find(panel_id)
if panel is None: if panel is None:
@@ -100,6 +104,7 @@ def messages_for(panel_id: str, store: DashboardStore) -> set[str]:
defn = store.read(dashboard) defn = store.read(dashboard)
except DashboardNotFound: except DashboardNotFound:
continue continue
names.update(defn.setting_messages)
for widget in defn.widgets: for widget in defn.widgets:
names.update(widget.messages) names.update(widget.messages)
if widget.target: if widget.target:
+80
View File
@@ -313,6 +313,86 @@ def test_a_panel_speaks_only_its_own_widgets_messages(
) )
def test_a_panel_may_read_its_dashboards_own_settings(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""A bound setting is no widget's binding, and a panel still needs it.
The allowlist is a walk of what the dashboards name, so the walk has to
reach the dashboard itself: a theme driven over a message is the one thing
a screen on a wall cannot be told any other way, and a panel refused that
message would leave the feature silently doing nothing on the only surface
it exists for.
"""
from fluksio.api.routes.flows import panel_scope
_dashboard_with(
client, superuser_token_headers, "panel_themed", [PANEL_WIDGETS[-1]]
)
saved = client.get(
f"{DASHBOARDS}/panel_themed?draft=true", headers=superuser_token_headers
).json()
saved["settings"] = {
"theme": {"value": "dark", "message": "demo.panel_theme", "dtype": "str"},
# Bound to nothing: a static setting entitles a panel to nothing.
"locked": {"value": False},
}
written = client.put(
f"{DASHBOARDS}/panel_themed", headers=superuser_token_headers, json=saved
)
assert written.status_code == 200, written.text
published = client.post(
f"{DASHBOARDS}/panel_themed/publish",
headers=superuser_token_headers,
json={"version": written.json()["version"]},
)
assert published.status_code == 200, published.text
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "hall", "dashboards": ["panel_themed"]}]},
)
panel_headers = _pair(client, superuser_token_headers, "hall")
messages = f"{settings.API_V1_STR}/messages"
# Not 403: the gate lets it through, and the engine then answers for a
# message no flow in this suite declares.
assert (
client.get(
f"{messages}/demo.panel_theme/history", headers=panel_headers
).status_code
== 200
)
# And the socket is bounded by the same walk, so the value actually lands.
token = panel_headers["Authorization"][7:]
assert panel_scope(token, client.app) == {
"demo.panel_theme",
"demo.temperature",
}
# A setting still buys nothing beyond itself.
assert (
client.get(
f"{messages}/demo.unrelated/history", headers=panel_headers
).status_code
== 403
)
def test_a_dashboard_setting_bound_to_the_wrong_type_is_refused(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""The same answer the server gives a mis-wired widget."""
saved = _dashboard(client, superuser_token_headers, "panel_mistyped")
saved["settings"] = {"theme": {"value": "dark", "message": "a.b", "dtype": "float"}}
refused = client.put(
f"{DASHBOARDS}/panel_mistyped", headers=superuser_token_headers, json=saved
)
assert refused.status_code == 422, refused.text
def test_unpairing_a_screen_leaves_its_panel_standing( def test_unpairing_a_screen_leaves_its_panel_standing(
client: TestClient, superuser_token_headers: dict[str, str] client: TestClient, superuser_token_headers: dict[str, str]
) -> None: ) -> None:
+60
View File
@@ -8,6 +8,7 @@ from fluksio.flow.dashboards import (
DashboardStore, DashboardStore,
PageDef, PageDef,
SectionDef, SectionDef,
SettingDef,
WidgetDef, WidgetDef,
default_dashboard, default_dashboard,
) )
@@ -353,3 +354,62 @@ def test_a_dashboard_is_cut_into_a_sane_number_of_columns():
for columns in (0, 49): for columns in (0, 49):
with pytest.raises(ValueError): with pytest.raises(ValueError):
DashboardDef(name="house", columns=columns) DashboardDef(name="house", columns=columns)
def test_a_setting_refuses_a_message_it_cannot_carry():
"""The same rule a widget binding is held to, from the document alone."""
DashboardDef(
name="house",
settings={"theme": SettingDef(value="dark", message="a.b", dtype="str")},
)
DashboardDef(
name="house",
settings={"locked": SettingDef(value=False, message="a.b", dtype="bool")},
)
with pytest.raises(ValueError):
DashboardDef(
name="house",
settings={"theme": SettingDef(value="dark", message="a.b", dtype="float")},
)
with pytest.raises(ValueError):
DashboardDef(
name="house",
settings={"locked": SettingDef(value=False, message="a.b", dtype="str")},
)
def test_an_unbound_setting_is_just_its_value():
"""The static case, which is what makes a dark panel cost no flow.
No message means nothing to type-check and nothing for a panel to be
entitled to — and a name this build does not know is left alone rather
than refused, so an older installation reads a newer document.
"""
defn = DashboardDef(
name="house",
settings={
"theme": SettingDef(value="dark"),
"someday": SettingDef(value=7, message="a.b", dtype="int"),
},
)
assert defn.settings["theme"].value == "dark"
assert defn.setting_messages == ["a.b"]
def test_a_bound_setting_is_drawn_on_the_canvas(store: DashboardStore):
"""A dashboard consuming a message is an endpoint like a tile is."""
store.write(
DashboardDef(
name="house",
settings={"theme": SettingDef(value="system", message="home.theme")},
)
)
(binding,) = store.bindings_for("home")
assert binding["widget"] == "settings.theme"
assert binding["type"] == "setting"
assert binding["requires"] == ["home.theme"]
assert not binding["provides"]
assert store.bindings_for("other") == []
+44 -3
View File
@@ -86,6 +86,44 @@ it, so swapping the store is a change to one flow and nothing else. The answer
also states what it was computed for, so an answer to a different question is also states what it was computed for, so an answer to a different question is
ignored rather than two charts overwriting each other's picture. ignored rather than two charts overwriting each other's picture.
## Dashboard settings
Most of what a dashboard carries is a widget: a tile bound to a message. Two
things are not, because they belong to the whole surface rather than to any
tile on it — and a screen bolted to a wall has nobody standing at it to set
them.
| Setting | Is | Driven by |
|---|---|---|
| **Theme** | `System`, `Light` or `Dark` | a `str` message |
| **Lock** | read-only on or off | a `bool` message |
Both work the same way, and both halves are optional:
- **Just a value.** Set Theme to `Dark` and that dashboard is dark wherever it
is shown, whatever the device or the browser prefers. This costs no flow at
all, and it is what most wall panels want.
- **Driven by a message.** Pick one in **Driven by** and a flow takes the
setting over, exactly as it drives a tile. The value you set stays the
fallback: what the dashboard uses before the first message arrives, and
whenever the message is silent.
A bound setting is type-checked like a widget binding — a Theme pointed at a
`float` is refused by the editor and by the server — and the panel's credential
is extended to it, so a paired screen may read its own theme message and
nothing further.
There is no schedule field, on purpose. **A schedule is a node publishing to
the bound message**: an `inject` with a cron expression, feeding a `change`
node that maps the hour onto `"dark"` or `"light"`, is the whole of "dark after
sunset" — and the same channel then serves anything else you want to drive,
including locking a panel down remotely.
Lock is a read-only *surface*, not a permission. The controls stay visible,
stop publishing and read as disabled, and the panel says **Read-only** in the
corner. What a paired screen is allowed to reach is still decided by its own
credential, below.
## Showing one ## Showing one
- `/view/{name}` — a browser tab pointed at one dashboard. Needs an ordinary - `/view/{name}` — a browser tab pointed at one dashboard. Needs an ordinary
@@ -110,9 +148,12 @@ What the screen holds is not a login. It reaches that panel's published
dashboards and the messages its own widgets read or publish, and nothing else — dashboards and the messages its own widgets read or publish, and nothing else —
a message no tile on it draws is refused in both directions. a message no tile on it draws is refused in both directions.
It cannot be made strictly read-only, and that is honest rather than an The *credential* cannot be made strictly read-only, and that is honest rather
oversight: a querying chart publishes its request, and a control on a panel is than an oversight: a querying chart publishes its request, and a control on a
the reason you put one there. panel is the reason you put one there. The dashboard's own **Lock** setting
above stops its controls publishing and can be driven by a flow, which is how
you quieten a screen remotely — but that is the surface behaving, not the
credential being narrowed.
Deleting the panel revokes the credential and the assignment together, which is Deleting the panel revokes the credential and the assignment together, which is
how you retire a device and what it showed. Unpairing revokes only the how you retire a device and what it showed. Unpairing revokes only the
+44
View File
@@ -435,6 +435,13 @@ export const DashboardDef_InputSchema = {
type: 'array', type: 'array',
title: 'Pages' title: 'Pages'
}, },
settings: {
additionalProperties: {
'$ref': '#/components/schemas/SettingDef'
},
type: 'object',
title: 'Settings'
},
version: { version: {
type: 'integer', type: 'integer',
title: 'Version', title: 'Version',
@@ -496,6 +503,13 @@ export const DashboardDef_OutputSchema = {
type: 'array', type: 'array',
title: 'Pages' title: 'Pages'
}, },
settings: {
additionalProperties: {
'$ref': '#/components/schemas/SettingDef'
},
type: 'object',
title: 'Settings'
},
version: { version: {
type: 'integer', type: 'integer',
title: 'Version', title: 'Version',
@@ -2640,6 +2654,36 @@ export const SeriesPointSchema = {
title: 'SeriesPoint' title: 'SeriesPoint'
} as const; } 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 = { export const ShareRequestSchema = {
properties: { properties: {
lib_name: { lib_name: {
+24
View File
@@ -117,6 +117,9 @@ export type DashboardDef_Input = {
canvas_height?: number; canvas_height?: number;
icon?: string; icon?: string;
pages?: Array<PageDef_Input>; pages?: Array<PageDef_Input>;
settings?: {
[key: string]: SettingDef;
};
version?: number; version?: number;
has_draft?: boolean; has_draft?: boolean;
}; };
@@ -132,6 +135,9 @@ export type DashboardDef_Output = {
canvas_height?: number; canvas_height?: number;
icon?: string; icon?: string;
pages?: Array<PageDef_Output>; pages?: Array<PageDef_Output>;
settings?: {
[key: string]: SettingDef;
};
version?: number; version?: number;
has_draft?: boolean; has_draft?: boolean;
}; };
@@ -919,6 +925,24 @@ export type SeriesPoint = {
avg_lag_ms: number; 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 = { export type ShareRequest = {
lib_name: string; lib_name: string;
}; };
@@ -166,11 +166,13 @@ const handleAt = (hue: number) => ({
function Level({ function Level({
label, label,
value, value,
disabled,
onChange, onChange,
onCommit, onCommit,
}: { }: {
label: string label: string
value: number value: number
disabled?: boolean
onChange: (value: number) => void onChange: (value: number) => void
onCommit: () => void onCommit: () => void
}) { }) {
@@ -185,6 +187,7 @@ function Level({
min={0} min={0}
max={100} max={100}
value={value} value={value}
disabled={disabled}
className="h-11 w-full accent-[var(--primary)] md:h-8" className="h-11 w-full accent-[var(--primary)] md:h-8"
onChange={(event) => onChange(Number(event.target.value))} onChange={(event) => onChange(Number(event.target.value))}
// Only the release publishes, as the slider widget does: a drag would // Only the release publishes, as the slider widget does: a drag would
@@ -211,7 +214,7 @@ function Level({
* control that can announce one, and neither of them on a keyboard. * control that can announce one, and neither of them on a keyboard.
*/ */
export function ColorWidget({ widget, dashboard }: WidgetProps) { export function ColorWidget({ widget, dashboard }: WidgetProps) {
const { target, value, send, pulse } = usePublish(widget, dashboard) const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
// While dragging, the wheel follows the finger rather than the engine. // While dragging, the wheel follows the finger rather than the engine.
const [draft, setDraft] = useState<Triple | null>(null) const [draft, setDraft] = useState<Triple | null>(null)
if (!target) if (!target)
@@ -230,6 +233,7 @@ export function ColorWidget({ widget, dashboard }: WidgetProps) {
/** The hue under the pointer: where it is relative to the wheel's centre. */ /** The hue under the pointer: where it is relative to the wheel's centre. */
const aim = (event: React.PointerEvent<HTMLDivElement>) => { const aim = (event: React.PointerEvent<HTMLDivElement>) => {
if (locked) return
const box = event.currentTarget.getBoundingClientRect() const box = event.currentTarget.getBoundingClientRect()
const x = event.clientX - (box.left + box.width / 2) const x = event.clientX - (box.left + box.width / 2)
const y = event.clientY - (box.top + box.height / 2) const y = event.clientY - (box.top + box.height / 2)
@@ -252,7 +256,11 @@ export function ColorWidget({ widget, dashboard }: WidgetProps) {
circle, so it says what it is and answers the same keys. */} circle, so it says what it is and answers the same keys. */}
<div <div
role="slider" role="slider"
tabIndex={0} // Not a native control, so the state it is in is said rather
// than inherited — and the ring keeps its colours, which are the
// reading, while the handle stops answering.
tabIndex={locked ? -1 : 0}
aria-disabled={locked || undefined}
aria-label={`${name} hue`} aria-label={`${name} hue`}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={359} aria-valuemax={359}
@@ -271,6 +279,7 @@ export function ColorWidget({ widget, dashboard }: WidgetProps) {
}} }}
onPointerUp={commit} onPointerUp={commit}
onKeyDown={(event) => { onKeyDown={(event) => {
if (locked) return
const step = const step =
event.key === "ArrowRight" || event.key === "ArrowUp" event.key === "ArrowRight" || event.key === "ArrowUp"
? HUE_STEP ? HUE_STEP
@@ -302,12 +311,14 @@ export function ColorWidget({ widget, dashboard }: WidgetProps) {
<Level <Level
label="Saturation" label="Saturation"
value={saturation} value={saturation}
disabled={locked}
onChange={(next) => setDraft([hue, next, brightness])} onChange={(next) => setDraft([hue, next, brightness])}
onCommit={commit} onCommit={commit}
/> />
<Level <Level
label="Brightness" label="Brightness"
value={brightness} value={brightness}
disabled={locked}
onChange={(next) => setDraft([hue, saturation, next])} onChange={(next) => setDraft([hue, saturation, next])}
onCommit={commit} onCommit={commit}
/> />
@@ -78,6 +78,7 @@ import {
usePublishDashboard, usePublishDashboard,
useSaveDashboard, useSaveDashboard,
} from "./queries" } from "./queries"
import { useDashboardTheme } from "./settings"
import { import {
WIDGET_LABELS, WIDGET_LABELS,
WIDGET_SIZES, WIDGET_SIZES,
@@ -211,6 +212,10 @@ export function DashboardEditor({
// A phone reads the dashboard rather than arranges it, so the grid library // A phone reads the dashboard rather than arranges it, so the grid library
// never mounts there. See DESIGN-GUIDELINES.md → Responsive. // never mounts there. See DESIGN-GUIDELINES.md → Responsive.
const stacked = useIsMobile() const stacked = useIsMobile()
// The dashboard's own theme, on the surface only: the shell around the
// canvas stays whatever the person editing chose for the app. Non-stacked,
// `CanvasSurface` states it on the canvas box itself.
const theme = useDashboardTheme(draft)
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const save = useSaveDashboard(dashboard.name) const save = useSaveDashboard(dashboard.name)
@@ -444,7 +449,12 @@ export function DashboardEditor({
) : stacked ? ( ) : stacked ? (
// One column at the viewport's width. Edit mode still picks a widget and // One column at the viewport's width. Edit mode still picks a widget and
// opens its settings; only the arrangement is missing. // opens its settings; only the arrangement is missing.
<div className="h-full overflow-y-auto"> <div
className={cn(
"h-full overflow-y-auto bg-background text-foreground",
theme,
)}
>
<DashboardView <DashboardView
dashboard={draft} dashboard={draft}
stacked stacked
@@ -9,6 +9,7 @@ import type {
} from "@/client" } from "@/client"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import "./dashboard.css" import "./dashboard.css"
import { LockedProvider, useDashboardTheme } from "./settings"
import { WidgetBody, WidgetFrame, widgetIssue } from "./widgets" import { WidgetBody, WidgetFrame, widgetIssue } from "./widgets"
export type Dashboard = DashboardDef_Output export type Dashboard = DashboardDef_Output
@@ -95,6 +96,11 @@ export const rowsOf = (dashboard: Dashboard) =>
* Scaling rather than reflowing is the point. A side panel opening, or a * Scaling rather than reflowing is the point. A side panel opening, or a
* narrower screen, changes only the scale — the arrangement being designed, * narrower screen, changes only the scale — the arrangement being designed,
* and the dot grid under it, stay the layout the panel will actually show. * and the dot grid under it, stay the layout the panel will actually show.
*
* This box is also exactly what the dashboard's `theme` setting applies to: it
* is the panel, so a dashboard forced light or dark paints its own ground here
* and leaves the shell around it alone. Which is why it states a background at
* all — without one it would borrow whatever it was dropped into.
*/ */
export function CanvasSurface({ export function CanvasSurface({
dashboard, dashboard,
@@ -124,6 +130,7 @@ export function CanvasSurface({
const { width, height } = canvasOf(dashboard) const { width, height } = canvasOf(dashboard)
const scale = Math.min(box.width / width, box.height / height) const scale = Math.min(box.width / width, box.height / height)
const theme = useDashboardTheme(dashboard)
return ( return (
<div ref={ref} className="relative size-full overflow-hidden"> <div ref={ref} className="relative size-full overflow-hidden">
@@ -131,7 +138,11 @@ export function CanvasSurface({
then move it. */} then move it. */}
{scale > 0 ? ( {scale > 0 ? (
<div <div
className={cn("absolute overflow-hidden", dots && "dot-canvas")} className={cn(
"absolute overflow-hidden bg-background text-foreground",
theme,
dots && "dot-canvas",
)}
data-testid="canvas-surface" data-testid="canvas-surface"
style={ style={
{ {
@@ -292,6 +303,7 @@ export function DashboardView({
const columns = columnsOf(dashboard) const columns = columnsOf(dashboard)
return ( return (
<LockedProvider dashboard={dashboard}>
<div <div
className={cn("widget-grid", stacked && "widget-stacked")} className={cn("widget-grid", stacked && "widget-stacked")}
data-placed={isPlaced(widgets) || undefined} data-placed={isPlaced(widgets) || undefined}
@@ -320,5 +332,6 @@ export function DashboardView({
</div> </div>
))} ))}
</div> </div>
</LockedProvider>
) )
} }
@@ -1,8 +1,11 @@
import { Lock } from "lucide-react"
import type { Dashboard } from "@/components/Dashboard/DashboardView" import type { Dashboard } from "@/components/Dashboard/DashboardView"
import { import {
CanvasSurface, CanvasSurface,
DashboardView, DashboardView,
} from "@/components/Dashboard/DashboardView" } from "@/components/Dashboard/DashboardView"
import { useDashboardLocked } from "@/components/Dashboard/settings"
/** /**
* One dashboard filling whatever screen it landed on. * One dashboard filling whatever screen it landed on.
@@ -10,6 +13,10 @@ import {
* The whole of what a wall panel draws, shared by the single-dashboard route * The whole of what a wall panel draws, shared by the single-dashboard route
* (`/view/{name}`) and the paired-panel one (`/panel/{id}`) so a device shows * (`/view/{name}`) and the paired-panel one (`/panel/{id}`) so a device shows
* the same thing either way — the second merely has a rail beside it. * the same thing either way — the second merely has a rail beside it.
*
* The dashboard's theme is stated by the route rather than here: on these two
* the screen *is* the dashboard, so it has to cover the letterbox and the rail
* as well as the canvas.
*/ */
export function PanelSurface({ export function PanelSurface({
dashboard, dashboard,
@@ -20,13 +27,48 @@ export function PanelSurface({
* its size, which reads as nothing at all. Stack it instead. */ * its size, which reads as nothing at all. Stack it instead. */
stacked?: boolean stacked?: boolean
}) { }) {
if (stacked) return <DashboardView dashboard={dashboard} stacked /> return (
<div className="relative size-full">
{stacked ? (
<DashboardView dashboard={dashboard} stacked />
) : (
// The panel's own surface, scaled to fit. No dots: nothing is being // The panel's own surface, scaled to fit. No dots: nothing is being
// arranged here. // arranged here.
return (
<CanvasSurface dashboard={dashboard}> <CanvasSurface dashboard={dashboard}>
{() => <DashboardView dashboard={dashboard} />} {() => <DashboardView dashboard={dashboard} />}
</CanvasSurface> </CanvasSurface>
)}
<LockNotice dashboard={dashboard} />
</div>
)
}
/**
* What a locked dashboard looks like, beyond controls that read as disabled.
*
* Without it a read-only panel is a panel whose buttons do nothing, which
* reads as broken rather than as locked. Frosted chrome over content, like
* every other floating surface, and it says the state in words — a glyph on
* its own is not a label.
*
* Live, because `locked` may be driven by a flow: the notice appears and goes
* with the lock rather than with the page load.
*/
function LockNotice({ dashboard }: { dashboard: Dashboard }) {
// Read straight from the document rather than through `useLocked`: that
// provider sits inside the grid, and this notice is beside it.
const locked = useDashboardLocked(dashboard)
if (!locked) return null
return (
<div
// Announced rather than merely drawn: `locked` may be driven by a flow,
// so the state can change under someone already looking at the page.
aria-live="polite"
data-testid="dashboard-locked"
className="pointer-events-none absolute bottom-0 right-0 flex items-center gap-1.5 rounded-full border border-border bg-card/80 px-3 py-1.5 text-sm text-muted-foreground shadow-e2 backdrop-blur-md"
>
<Lock className="size-4" aria-hidden />
Read-only
</div>
) )
} }
+142 -4
View File
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"
import { Ban, ChevronDown, Plus, X } from "lucide-react" import { Ban, ChevronDown, Plus, X } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import type { MessageInfo, WidgetDef } from "@/client" import type { MessageInfo, SettingDef, WidgetDef } from "@/client"
import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker" import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker"
import { import {
PANEL_SECTION, PANEL_SECTION,
@@ -33,6 +33,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget" import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget"
import { MAX_SERIES, refreshFor } from "./ChartWidget" import { MAX_SERIES, refreshFor } from "./ChartWidget"
@@ -46,6 +47,13 @@ import {
} from "./DashboardView" } from "./DashboardView"
import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons" import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons"
import { messageCatalogQueryOptions } from "./queries" import { messageCatalogQueryOptions } from "./queries"
import {
SETTING_DTYPES,
type SettingName,
settingIssue,
settingOf,
THEME_CHOICES,
} from "./settings"
import { import {
acceptsDtype, acceptsDtype,
INPUT_WIDGETS, INPUT_WIDGETS,
@@ -78,13 +86,17 @@ function MessagePicker({
value, value,
label, label,
testId, testId,
placeholder = "Pick a message",
filter, filter,
onPick, onPick,
}: { }: {
kind: WidgetKind /** Omitted where the slot is not a widget's at all — a dashboard setting
* binds by payload type alone, and hands in `filter` instead. */
kind?: WidgetKind
value: string value: string
label: string label: string
testId?: string testId?: string
placeholder?: string
/** What this slot takes, when the widget's own type does not decide it — /** What this slot takes, when the widget's own type does not decide it —
* a querying chart asks with one shape and draws another. */ * a querying chart asks with one shape and draws another. */
filter?: (message: MessageInfo) => boolean filter?: (message: MessageInfo) => boolean
@@ -92,7 +104,11 @@ function MessagePicker({
}) { }) {
const { data } = useQuery(messageCatalogQueryOptions()) const { data } = useQuery(messageCatalogQueryOptions())
const catalog = data?.data ?? [] const catalog = data?.data ?? []
const choices = filter ? catalog.filter(filter) : choicesFor(kind, catalog) const choices = filter
? catalog.filter(filter)
: kind
? choicesFor(kind, catalog)
: catalog
return ( return (
<div className="grid gap-1.5"> <div className="grid gap-1.5">
@@ -107,7 +123,7 @@ function MessagePicker({
} }
> >
<SelectTrigger data-testid={testId}> <SelectTrigger data-testid={testId}>
<SelectValue placeholder="Pick a message" /> <SelectValue placeholder={placeholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{choices.map((message) => ( {choices.map((message) => (
@@ -944,6 +960,73 @@ export function WidgetPanel({
const sizeKey = (size: { width: number; height: number }) => const sizeKey = (size: { width: number; height: number }) =>
`${size.width}x${size.height}` `${size.width}x${size.height}`
/**
* The optional half of a setting: which message, if any, drives it.
*
* Deliberately drawn as an addition rather than a requirement — the placeholder
* says what leaving it alone means, and the value control above it is the whole
* setting until something is picked here. Binding is how a flow takes the
* setting over; a node publishing on a cron is what a schedule is in this
* system, so there is no scheduling UI to build.
*
* The picker records the payload type beside the name, which is what lets the
* pairing be judged from the document alone — the same rule a widget's binding
* is held to, and the same one the server enforces.
*/
function SettingBinding({
name,
setting,
onChange,
}: {
name: SettingName
setting: SettingDef
onChange: (setting: SettingDef) => void
}) {
const want = SETTING_DTYPES[name]
const issue = settingIssue(name, setting)
return (
<div className="grid gap-1.5">
<div className="flex items-end gap-2">
<div className="min-w-0 flex-1">
<MessagePicker
value={str(setting.message)}
label="Driven by"
testId={`dashboard-${name}-message`}
placeholder={`Nothing — always ${valueLabel(name, setting.value)}`}
filter={(message) => message.dtype === want}
onPick={(message, dtype) =>
onChange({ ...setting, message, dtype })
}
/>
</div>
{setting.message ? (
<Button
variant="ghost"
size="icon"
aria-label={`Stop driving ${name}`}
data-testid={`dashboard-${name}-unbind`}
onClick={() => onChange({ value: setting.value })}
>
<X />
</Button>
) : null}
</div>
{issue ? (
<p className="text-sm text-destructive" role="alert">
{issue}
</p>
) : null}
</div>
)
}
/** How a setting's own value reads in the "nothing is driving it" line. */
function valueLabel(name: SettingName, value: unknown): string {
if (name === "locked") return value === true ? "read-only" : "editable"
const chosen = THEME_CHOICES.find(([option]) => option === value)
return (chosen?.[1] ?? "System").toLowerCase()
}
/** /**
* The dashboard's own settings, in the panel its widgets use. * The dashboard's own settings, in the panel its widgets use.
* *
@@ -968,6 +1051,12 @@ export function DashboardPanel({
}) { }) {
const [confirmOpen, setConfirmOpen] = useState(false) const [confirmOpen, setConfirmOpen] = useState(false)
const canvas = canvasOf(dashboard) const canvas = canvasOf(dashboard)
const theme = settingOf(dashboard, "theme")
const locked = settingOf(dashboard, "locked")
/** Settings are a map, so one of them changing rewrites the whole of it. */
const setSetting = (name: SettingName, setting: SettingDef) =>
onChange({ settings: { ...(dashboard.settings ?? {}), [name]: setting } })
return ( return (
<> <>
@@ -1095,6 +1184,55 @@ export function DashboardPanel({
</p> </p>
</div> </div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Theme</span>
<Segmented
value={str(theme.value) || "system"}
options={THEME_CHOICES}
label="Dashboard theme"
testId="dashboard-theme"
onChange={(value) => setSetting("theme", { ...theme, value })}
/>
<SettingBinding
name="theme"
setting={theme}
onChange={(setting) => setSetting("theme", setting)}
/>
<p className="text-sm text-muted-foreground">
What this dashboard wears wherever it is shown a screen on a
wall has nobody to set the device preference System otherwise
follows. Bind a message and a flow drives it instead: a node
publishing on a cron is what a schedule looks like here, and the
choice above stays the fallback.
</p>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Lock</span>
<div className="flex items-center justify-between gap-2 text-sm">
Read-only
<Switch
checked={locked.value === true}
aria-label="Read-only"
data-testid="dashboard-lock"
onCheckedChange={(value) =>
setSetting("locked", { ...locked, value })
}
/>
</div>
<SettingBinding
name="locked"
setting={locked}
onChange={(setting) => setSetting("locked", setting)}
/>
<p className="text-sm text-muted-foreground">
Locked, the controls on this dashboard are shown but stop
publishing, and the surface says so. It is a read-only surface
rather than a permission: what a paired screen may reach is still
decided by its own credential.
</p>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<span className={PANEL_SECTION}>Contents</span> <span className={PANEL_SECTION}>Contents</span>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
+16 -1
View File
@@ -8,6 +8,7 @@ import { handleError } from "@/utils"
// chunked per entry — so the sheet is pulled in wherever the pulse is drawn. // chunked per entry — so the sheet is pulled in wherever the pulse is drawn.
import "./dashboard.css" import "./dashboard.css"
import { usePublishMessage } from "./queries" import { usePublishMessage } from "./queries"
import { useLocked } from "./settings"
/** /**
* How long a control shows what it sent before falling back to the engine. * How long a control shows what it sent before falling back to the engine.
@@ -35,6 +36,17 @@ function confirms(live: unknown, sent: unknown): boolean {
* is refused, or on `HOLD_MS`; success is silent, because the echo is the * is refused, or on `HOLD_MS`; success is silent, because the echo is the
* confirmation. * confirmation.
* *
* A locked dashboard is gated here and only here: every control publishes
* through this hook, so one check covers all of them and a control added later
* inherits it. `locked` is handed back so each control can also *read* as
* disabled — a dashboard that silently swallows a press looks broken rather
* than locked.
*
* ponytail: this is a read-only surface, not an authorisation boundary. The
* server still takes a publish from a panel credential whose dashboard says
* locked, because the credential's own allowlist is what bounds it. Making it
* a real lock means carrying the flag into `_panel_may`.
*
* Its own module rather than `widgets.tsx`, which every widget file is * Its own module rather than `widgets.tsx`, which every widget file is
* imported *by*: a control drawn in a file of its own can only reach this * imported *by*: a control drawn in a file of its own can only reach this
* without closing that circle if it does not sit there. * without closing that circle if it does not sit there.
@@ -42,6 +54,7 @@ function confirms(live: unknown, sent: unknown): boolean {
export function usePublish(widget: WidgetDef, dashboard: string) { export function usePublish(widget: WidgetDef, dashboard: string) {
const cfg = (widget.config ?? {}) as Record<string, unknown> const cfg = (widget.config ?? {}) as Record<string, unknown>
const target = cfg.target == null ? "" : String(cfg.target) const target = cfg.target == null ? "" : String(cfg.target)
const locked = useLocked()
const publish = usePublishMessage() const publish = usePublishMessage()
const live = useLiveValue(target || undefined) const live = useLiveValue(target || undefined)
const { showErrorToast } = useCustomToast() const { showErrorToast } = useCustomToast()
@@ -62,8 +75,10 @@ export function usePublish(widget: WidgetDef, dashboard: string) {
target, target,
/** What the control draws: what it sent, until the engine answers. */ /** What the control draws: what it sent, until the engine answers. */
value: held ? held.value : live?.value, value: held ? held.value : live?.value,
/** Whether this dashboard is read-only; controls draw themselves disabled. */
locked,
send: (value: unknown) => { send: (value: unknown) => {
if (!target) return if (!target || locked) return
setHeld({ value }) setHeld({ value })
publish.mutate( publish.mutate(
{ {
@@ -0,0 +1,126 @@
import { createContext, useContext } from "react"
import type { DashboardDef_Output, SettingDef } from "@/client"
import { useLiveValue } from "@/components/Flow/liveStore"
/**
* The dashboard's own settings channel.
*
* A widget is a receiver a dashboard *contains*; this is the dashboard itself
* as one. Each setting is a value plus an optional binding:
*
* - **Unbound** — no message — the setting is simply its value. That is what
* gives a wall panel which is always dark without a flow behind it.
* - **Bound** — a flow drives it live, and the stored 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 here, which is the whole reason this is a channel
* rather than a switching rule bolted onto each setting.
*
* A bound setting is read the way a widget reads a value — the same live store
* and the same socket — so a panel's own settings messages are part of what
* its credential is entitled to (`flow/panels.py`, `messages_for`).
*/
/**
* What a setting may be driven by, by payload type. The same table is enforced
* on the server (`app/flow/dashboards.py`); a name missing from it is a
* setting this build does not act on rather than an error.
*/
export const SETTING_DTYPES: Record<string, string> = {
theme: "str",
locked: "bool",
}
/** The settings this build actually wires up. */
export type SettingName = "theme" | "locked"
/** What `theme` may be set to. `system` follows whatever the device says. */
export const THEME_CHOICES = [
["system", "System"],
["light", "Light"],
["dark", "Dark"],
] as const
/** The generated client marks the map optional, because the server fills it in. */
export function settingOf(
dashboard: DashboardDef_Output | undefined,
name: SettingName,
): SettingDef {
return dashboard?.settings?.[name] ?? {}
}
/**
* What is wrong with a setting's binding, if anything.
*
* Judged from the document alone — the picker records the payload type beside
* the name — so this is the same rule `widgetIssue` holds a tile to, and the
* editor cannot author a document the server would refuse.
*/
export function settingIssue(name: string, setting: SettingDef): string | null {
const want = SETTING_DTYPES[name]
if (!want || !setting.message || !setting.dtype) return null
if (setting.dtype === want) return null
return `${setting.message} is a ${setting.dtype}; ${name} is driven by a ${want}.`
}
/** What the setting is worth now: live if it is bound, stored otherwise. */
function useSetting(
dashboard: DashboardDef_Output | undefined,
name: SettingName,
): unknown {
const setting = settingOf(dashboard, name)
// `undefined` while nothing has arrived, which is exactly when the stored
// value is meant to stand in.
const live = useLiveValue(setting.message || undefined)
return live?.value ?? setting.value
}
/**
* The class that themes a dashboard's own surface, or `""` to follow the app.
*
* A class rather than the root, because inside the app shell this must not
* flip the chrome around it. `.light` and `.dark` both redefine the tokens on
* whatever carries them (`index.css`), so either direction works on a subtree.
*/
export function useDashboardTheme(
dashboard: DashboardDef_Output | undefined,
): "" | "light" | "dark" {
const value = useSetting(dashboard, "theme")
return value === "dark" || value === "light" ? value : ""
}
/** Whether this dashboard is read-only, live if the setting is bound. */
export function useDashboardLocked(
dashboard: DashboardDef_Output | undefined,
): boolean {
return useSetting(dashboard, "locked") === true
}
const LockedContext = createContext(false)
/**
* Marks everything drawn under it read-only.
*
* A context rather than a prop threaded through sixteen renderers: what a
* control needs to know is one bit, and the one place it is acted on is
* `usePublish`. Edit mode deliberately never mounts this — arranging a
* dashboard is not the same as using it.
*/
export function LockedProvider({
dashboard,
children,
}: {
dashboard: DashboardDef_Output | undefined
children: React.ReactNode
}) {
const locked = useDashboardLocked(dashboard)
return (
<LockedContext.Provider value={locked}>{children}</LockedContext.Provider>
)
}
/** Whether the dashboard around this control is read-only. */
export const useLocked = () => useContext(LockedContext)
+17 -8
View File
@@ -569,7 +569,7 @@ function NotificationWidget({ widget }: WidgetProps) {
function ButtonWidget({ widget, dashboard }: WidgetProps) { function ButtonWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget) const cfg = config(widget)
const { target, send, pending, pulse } = usePublish(widget, dashboard) const { target, send, pending, pulse, locked } = usePublish(widget, dashboard)
if (!target) return <Unbound /> if (!target) return <Unbound />
// Nothing to hold: a button carries no reading, so the pulse and a refusal // Nothing to hold: a button carries no reading, so the pulse and a refusal
// are the whole of its feedback. // are the whole of its feedback.
@@ -579,7 +579,7 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
<Button <Button
variant="secondary" variant="secondary"
className="w-full min-w-0" className="w-full min-w-0"
disabled={pending} disabled={pending || locked}
onClick={() => send(cfg.value ?? true)} onClick={() => send(cfg.value ?? true)}
> >
<span className="truncate"> <span className="truncate">
@@ -598,7 +598,7 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
*/ */
function SwitchWidget({ widget, dashboard }: WidgetProps) { function SwitchWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget) const cfg = config(widget)
const { target, value, send, pulse } = usePublish(widget, dashboard) const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
if (!target) return <Unbound /> if (!target) return <Unbound />
const on = value === true const on = value === true
@@ -610,6 +610,7 @@ function SwitchWidget({ widget, dashboard }: WidgetProps) {
className="w-full min-w-0" className="w-full min-w-0"
aria-pressed={on} aria-pressed={on}
aria-label={widget.title || target} aria-label={widget.title || target}
disabled={locked}
onClick={() => send(!on)} onClick={() => send(!on)}
> >
<span className="truncate">{on ? "On" : "Off"}</span> <span className="truncate">{on ? "On" : "Off"}</span>
@@ -622,6 +623,7 @@ function SwitchWidget({ widget, dashboard }: WidgetProps) {
<Switch <Switch
checked={on} checked={on}
aria-label={widget.title || target} aria-label={widget.title || target}
disabled={locked}
onCheckedChange={(checked) => send(checked)} onCheckedChange={(checked) => send(checked)}
/> />
</div> </div>
@@ -693,7 +695,7 @@ function SliderTicks({
function SliderWidget({ widget, dashboard }: WidgetProps) { function SliderWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget) const cfg = config(widget)
const { target, value, send, pulse } = usePublish(widget, dashboard) const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
const min = num(cfg.min, 0) const min = num(cfg.min, 0)
const max = num(cfg.max, 100) const max = num(cfg.max, 100)
const step = num(cfg.step, 1) const step = num(cfg.step, 1)
@@ -721,6 +723,7 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
step={step} step={step}
value={current} value={current}
aria-label={widget.title || target} aria-label={widget.title || target}
disabled={locked}
className="h-11 w-full accent-[var(--primary)] md:h-8" className="h-11 w-full accent-[var(--primary)] md:h-8"
onChange={(event) => setDragging(Number(event.target.value))} onChange={(event) => setDragging(Number(event.target.value))}
// Only the release publishes: dragging would otherwise send a value // Only the release publishes: dragging would otherwise send a value
@@ -742,7 +745,7 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
function InputWidget({ widget, dashboard }: WidgetProps) { function InputWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget) const cfg = config(widget)
const { target, value, send, pulse } = usePublish(widget, dashboard) const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
const [draft, setDraft] = useState<string | null>(null) const [draft, setDraft] = useState<string | null>(null)
if (!target) return <Unbound /> if (!target) return <Unbound />
@@ -760,6 +763,7 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
value={draft ?? text(value)} value={draft ?? text(value)}
type={asNumber ? "number" : "text"} type={asNumber ? "number" : "text"}
aria-label={widget.title || target} aria-label={widget.title || target}
disabled={locked}
onChange={(event) => setDraft(event.target.value)} onChange={(event) => setDraft(event.target.value)}
onBlur={commit} onBlur={commit}
onKeyDown={(event) => { onKeyDown={(event) => {
@@ -778,7 +782,7 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
*/ */
function DropdownWidget({ widget, dashboard }: WidgetProps) { function DropdownWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget) const cfg = config(widget)
const { target, value, send, pulse } = usePublish(widget, dashboard) const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[] const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
if (!target) return <Unbound /> if (!target) return <Unbound />
@@ -820,9 +824,10 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) {
key={text(option.value)} key={text(option.value)}
type="button" type="button"
aria-pressed={index === chosen} aria-pressed={index === chosen}
disabled={locked}
onClick={() => send(option.value)} onClick={() => send(option.value)}
className={cn( className={cn(
"relative z-10 h-11 min-w-0 truncate rounded-full px-2.5 text-sm transition-colors md:h-8", "relative z-10 h-11 min-w-0 truncate rounded-full px-2.5 text-sm transition-colors disabled:opacity-50 md:h-8",
index === chosen index === chosen
? "text-accent-foreground" ? "text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50", : "text-muted-foreground hover:bg-accent/50",
@@ -844,7 +849,11 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) {
value={text(value)} value={text(value)}
onValueChange={(selected) => send(asOriginal(selected, options))} onValueChange={(selected) => send(asOriginal(selected, options))}
> >
<SelectTrigger className="w-full" aria-label={widget.title || target}> <SelectTrigger
className="w-full"
aria-label={widget.title || target}
disabled={locked}
>
<SelectValue placeholder="Choose" /> <SelectValue placeholder="Choose" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
+5
View File
@@ -98,6 +98,11 @@
* segment is therefore drawn inside a gutter of the fill rather than ever * segment is therefore drawn inside a gutter of the fill rather than ever
* bordering the track. * bordering the track.
*/ */
/* `.light` is the symmetric half of `.dark` below: both are plain classes, so
either themes a subtree as well as the whole document — which is what lets a
dashboard forced to one theme sit inside a shell on the other. `:root` is
listed second so the parity grep keeps its anchor line. */
.light,
:root { :root {
--background: #ffffff; --background: #ffffff;
--foreground: #333232; --foreground: #333232;
+8 -1
View File
@@ -8,6 +8,7 @@ import {
dashboardQueryOptions, dashboardQueryOptions,
panelQueryOptions, panelQueryOptions,
} from "@/components/Dashboard/queries" } from "@/components/Dashboard/queries"
import { useDashboardTheme } from "@/components/Dashboard/settings"
import { useFlowSocket } from "@/components/Flow/useFlowSocket" import { useFlowSocket } from "@/components/Flow/useFlowSocket"
import { isLoggedIn } from "@/hooks/useAuth" import { isLoggedIn } from "@/hooks/useAuth"
import { useIsMobile } from "@/hooks/useMobile" import { useIsMobile } from "@/hooks/useMobile"
@@ -55,6 +56,11 @@ function PanelRoute() {
}) })
const stacked = useIsMobile() const stacked = useIsMobile()
const rail = dashboards.length > 1 const rail = dashboards.length > 1
// The screen is the dashboard here, so its theme covers the whole of it —
// the rail and the letterbox around a scaled canvas included. This is the
// surface the setting exists for: a panel in a room has no other way to be
// told which theme to wear.
const theme = useDashboardTheme(dashboard as Dashboard | undefined)
if (panel && dashboards.length === 0) { if (panel && dashboards.length === 0) {
return ( return (
@@ -69,7 +75,8 @@ function PanelRoute() {
return ( return (
<main <main
className={cn( className={cn(
"relative h-svh w-full p-4", "relative h-svh w-full bg-background p-4 text-foreground",
theme,
stacked ? "overflow-y-auto" : "overflow-hidden", stacked ? "overflow-y-auto" : "overflow-hidden",
)} )}
style={rail ? { paddingLeft: RAIL_INSET } : undefined} style={rail ? { paddingLeft: RAIL_INSET } : undefined}
+10 -5
View File
@@ -4,9 +4,11 @@ import { createFileRoute, redirect } from "@tanstack/react-router"
import type { Dashboard } from "@/components/Dashboard/DashboardView" import type { Dashboard } from "@/components/Dashboard/DashboardView"
import { PanelSurface } from "@/components/Dashboard/PanelSurface" import { PanelSurface } from "@/components/Dashboard/PanelSurface"
import { dashboardQueryOptions } from "@/components/Dashboard/queries" import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import { useDashboardTheme } from "@/components/Dashboard/settings"
import { useFlowSocket } from "@/components/Flow/useFlowSocket" import { useFlowSocket } from "@/components/Flow/useFlowSocket"
import { isLoggedIn } from "@/hooks/useAuth" import { isLoggedIn } from "@/hooks/useAuth"
import { useIsMobile } from "@/hooks/useMobile" import { useIsMobile } from "@/hooks/useMobile"
import { cn } from "@/lib/utils"
/** /**
* What a wall panel is pointed at when it shows one dashboard and nothing else. * What a wall panel is pointed at when it shows one dashboard and nothing else.
@@ -34,16 +36,19 @@ function PanelView() {
useFlowSocket() useFlowSocket()
const { data: dashboard } = useQuery(dashboardQueryOptions(name)) const { data: dashboard } = useQuery(dashboardQueryOptions(name))
const stacked = useIsMobile() const stacked = useIsMobile()
// A tab pointed at one dashboard is that dashboard, so its theme covers the
// whole page rather than only the canvas inside it.
const theme = useDashboardTheme(dashboard as Dashboard | undefined)
if (!dashboard) return <main className="h-svh w-full" /> if (!dashboard) return <main className="h-svh w-full" />
return ( return (
<main <main
className={ className={cn(
stacked "h-svh w-full bg-background p-4 text-foreground",
? "h-svh w-full overflow-y-auto p-4" theme,
: "h-svh w-full overflow-hidden p-4" stacked ? "overflow-y-auto" : "overflow-hidden",
} )}
> >
<PanelSurface dashboard={dashboard as Dashboard} stacked={stacked} /> <PanelSurface dashboard={dashboard as Dashboard} stacked={stacked} />
</main> </main>
+19
View File
@@ -146,6 +146,11 @@ def process(tick, mode="comfort", away=False, setpoint=21.0):
"indoor": round(indoor, 2), "indoor": round(indoor, 2),
"outdoor": round(outdoor, 2), "outdoor": round(outdoor, 2),
"humidity": round(humidity, 1), "humidity": round(humidity, 1),
# Not a reading: what the panel on the wall should wear. The dashboard
# binds its `theme` setting to this, which is what "switch the panel at
# sunset" looks like here — a flow publishing, not a rule in the
# dashboard.
"panel_theme": "dark" if hour < 7.0 or hour >= 20.0 else "light",
} }
''' '''
@@ -331,6 +336,8 @@ HOUSE_NODES = [
# downstream of it is paced by this rather than by the tick. # downstream of it is paced by this rather than by the tick.
{"name": "outdoor", "dtype": "float", "interval": 60}, {"name": "outdoor", "dtype": "float", "interval": 60},
{"name": "humidity", "dtype": "float"}, {"name": "humidity", "dtype": "float"},
# Twice a day at most, so it is paced rather than sent every tick.
{"name": "panel_theme", "dtype": "str", "interval": 60},
], ],
}, },
{ {
@@ -1214,6 +1221,18 @@ def seed_dashboard(api: Api) -> None:
"columns": COLUMNS, "columns": COLUMNS,
"canvas_width": CANVAS[0], "canvas_width": CANVAS[0],
"canvas_height": CANVAS[1], "canvas_height": CANVAS[1],
# The settings channel: the dashboard itself as a receiver. Costs
# no tile, which is why it fits a panel with all 17 rows spoken
# for. `system` is the fallback until the flow first says
# otherwise; `locked` is left unset, which is the static default
# and keeps every control on the demo usable.
"settings": {
"theme": {
"value": "system",
"message": msg(HOUSE, "panel_theme"),
"dtype": "str",
}
},
"pages": PAGES, "pages": PAGES,
}, },
) )