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:
@@ -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):
|
||||
"""Where a widget sits in its section's grid, in grid units."""
|
||||
|
||||
@@ -311,6 +347,11 @@ class DashboardDef(BaseModel):
|
||||
#: letters of the title.
|
||||
icon: str = ""
|
||||
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.
|
||||
version: int = 1
|
||||
#: 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:
|
||||
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
|
||||
def widgets(self) -> list[WidgetDef]:
|
||||
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):
|
||||
"""A dashboard in a list, without its contents."""
|
||||
@@ -544,6 +610,25 @@ class DashboardStore:
|
||||
defn = DashboardDef.model_validate_json(path.read_text())
|
||||
except Exception:
|
||||
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:
|
||||
# A control produces the message; a tile consumes it.
|
||||
produces = widget.target if widget.target.startswith(prefix) else ""
|
||||
@@ -599,6 +684,7 @@ __all__ = [
|
||||
"DASHBOARD_DIR",
|
||||
"HISTORY_CAP",
|
||||
"INPUT_WIDGETS",
|
||||
"SETTING_DTYPES",
|
||||
"WIDGET_DTYPES",
|
||||
"DashboardDef",
|
||||
"DashboardExists",
|
||||
@@ -609,6 +695,7 @@ __all__ = [
|
||||
"PageDef",
|
||||
"Placement",
|
||||
"SectionDef",
|
||||
"SettingDef",
|
||||
"WidgetDef",
|
||||
"default_dashboard",
|
||||
]
|
||||
|
||||
@@ -85,11 +85,15 @@ def find(panel_id: str) -> PanelDef | None:
|
||||
|
||||
|
||||
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
|
||||
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".
|
||||
|
||||
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)
|
||||
if panel is None:
|
||||
@@ -100,6 +104,7 @@ def messages_for(panel_id: str, store: DashboardStore) -> set[str]:
|
||||
defn = store.read(dashboard)
|
||||
except DashboardNotFound:
|
||||
continue
|
||||
names.update(defn.setting_messages)
|
||||
for widget in defn.widgets:
|
||||
names.update(widget.messages)
|
||||
if widget.target:
|
||||
|
||||
@@ -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(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
|
||||
@@ -8,6 +8,7 @@ from fluksio.flow.dashboards import (
|
||||
DashboardStore,
|
||||
PageDef,
|
||||
SectionDef,
|
||||
SettingDef,
|
||||
WidgetDef,
|
||||
default_dashboard,
|
||||
)
|
||||
@@ -353,3 +354,62 @@ def test_a_dashboard_is_cut_into_a_sane_number_of_columns():
|
||||
for columns in (0, 49):
|
||||
with pytest.raises(ValueError):
|
||||
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") == []
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- `/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 —
|
||||
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
|
||||
oversight: a querying chart publishes its request, and a control on a panel is
|
||||
the reason you put one there.
|
||||
The *credential* cannot be made strictly read-only, and that is honest rather
|
||||
than an oversight: a querying chart publishes its request, and a control on a
|
||||
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
|
||||
how you retire a device and what it showed. Unpairing revokes only the
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -166,11 +166,13 @@ const handleAt = (hue: number) => ({
|
||||
function Level({
|
||||
label,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
onCommit,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
disabled?: boolean
|
||||
onChange: (value: number) => void
|
||||
onCommit: () => void
|
||||
}) {
|
||||
@@ -185,6 +187,7 @@ function Level({
|
||||
min={0}
|
||||
max={100}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
className="h-11 w-full accent-[var(--primary)] md:h-8"
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
// 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.
|
||||
*/
|
||||
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.
|
||||
const [draft, setDraft] = useState<Triple | null>(null)
|
||||
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. */
|
||||
const aim = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (locked) return
|
||||
const box = event.currentTarget.getBoundingClientRect()
|
||||
const x = event.clientX - (box.left + box.width / 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. */}
|
||||
<div
|
||||
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-valuemin={0}
|
||||
aria-valuemax={359}
|
||||
@@ -271,6 +279,7 @@ export function ColorWidget({ widget, dashboard }: WidgetProps) {
|
||||
}}
|
||||
onPointerUp={commit}
|
||||
onKeyDown={(event) => {
|
||||
if (locked) return
|
||||
const step =
|
||||
event.key === "ArrowRight" || event.key === "ArrowUp"
|
||||
? HUE_STEP
|
||||
@@ -302,12 +311,14 @@ export function ColorWidget({ widget, dashboard }: WidgetProps) {
|
||||
<Level
|
||||
label="Saturation"
|
||||
value={saturation}
|
||||
disabled={locked}
|
||||
onChange={(next) => setDraft([hue, next, brightness])}
|
||||
onCommit={commit}
|
||||
/>
|
||||
<Level
|
||||
label="Brightness"
|
||||
value={brightness}
|
||||
disabled={locked}
|
||||
onChange={(next) => setDraft([hue, saturation, next])}
|
||||
onCommit={commit}
|
||||
/>
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
usePublishDashboard,
|
||||
useSaveDashboard,
|
||||
} from "./queries"
|
||||
import { useDashboardTheme } from "./settings"
|
||||
import {
|
||||
WIDGET_LABELS,
|
||||
WIDGET_SIZES,
|
||||
@@ -211,6 +212,10 @@ export function DashboardEditor({
|
||||
// A phone reads the dashboard rather than arranges it, so the grid library
|
||||
// never mounts there. See DESIGN-GUIDELINES.md → Responsive.
|
||||
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 queryClient = useQueryClient()
|
||||
const save = useSaveDashboard(dashboard.name)
|
||||
@@ -444,7 +449,12 @@ export function DashboardEditor({
|
||||
) : stacked ? (
|
||||
// One column at the viewport's width. Edit mode still picks a widget and
|
||||
// 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
|
||||
dashboard={draft}
|
||||
stacked
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from "@/client"
|
||||
import { cn } from "@/lib/utils"
|
||||
import "./dashboard.css"
|
||||
import { LockedProvider, useDashboardTheme } from "./settings"
|
||||
import { WidgetBody, WidgetFrame, widgetIssue } from "./widgets"
|
||||
|
||||
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
|
||||
* narrower screen, changes only the scale — the arrangement being designed,
|
||||
* 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({
|
||||
dashboard,
|
||||
@@ -124,6 +130,7 @@ export function CanvasSurface({
|
||||
|
||||
const { width, height } = canvasOf(dashboard)
|
||||
const scale = Math.min(box.width / width, box.height / height)
|
||||
const theme = useDashboardTheme(dashboard)
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative size-full overflow-hidden">
|
||||
@@ -131,7 +138,11 @@ export function CanvasSurface({
|
||||
then move it. */}
|
||||
{scale > 0 ? (
|
||||
<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"
|
||||
style={
|
||||
{
|
||||
@@ -292,33 +303,35 @@ export function DashboardView({
|
||||
const columns = columnsOf(dashboard)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("widget-grid", stacked && "widget-stacked")}
|
||||
data-placed={isPlaced(widgets) || undefined}
|
||||
style={
|
||||
{
|
||||
"--widget-cols": columns,
|
||||
// The row height follows the canvas, so the CSS grid and the
|
||||
// editor's grid library cannot drift apart.
|
||||
"--row-height": `${rowHeightOf(dashboard)}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{widgets.map((widget) => (
|
||||
<div
|
||||
key={widget.id}
|
||||
style={widgetStyle(widget, columns)}
|
||||
className="widget-cell"
|
||||
>
|
||||
{renderWidget ? (
|
||||
renderWidget(widget)
|
||||
) : (
|
||||
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
|
||||
<WidgetBody widget={widget} dashboard={dashboard.name} />
|
||||
</WidgetFrame>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<LockedProvider dashboard={dashboard}>
|
||||
<div
|
||||
className={cn("widget-grid", stacked && "widget-stacked")}
|
||||
data-placed={isPlaced(widgets) || undefined}
|
||||
style={
|
||||
{
|
||||
"--widget-cols": columns,
|
||||
// The row height follows the canvas, so the CSS grid and the
|
||||
// editor's grid library cannot drift apart.
|
||||
"--row-height": `${rowHeightOf(dashboard)}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{widgets.map((widget) => (
|
||||
<div
|
||||
key={widget.id}
|
||||
style={widgetStyle(widget, columns)}
|
||||
className="widget-cell"
|
||||
>
|
||||
{renderWidget ? (
|
||||
renderWidget(widget)
|
||||
) : (
|
||||
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
|
||||
<WidgetBody widget={widget} dashboard={dashboard.name} />
|
||||
</WidgetFrame>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</LockedProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Lock } from "lucide-react"
|
||||
|
||||
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
||||
import {
|
||||
CanvasSurface,
|
||||
DashboardView,
|
||||
} from "@/components/Dashboard/DashboardView"
|
||||
import { useDashboardLocked } from "@/components/Dashboard/settings"
|
||||
|
||||
/**
|
||||
* 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
|
||||
* (`/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 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({
|
||||
dashboard,
|
||||
@@ -20,13 +27,48 @@ export function PanelSurface({
|
||||
* its size, which reads as nothing at all. Stack it instead. */
|
||||
stacked?: boolean
|
||||
}) {
|
||||
if (stacked) return <DashboardView dashboard={dashboard} stacked />
|
||||
|
||||
// The panel's own surface, scaled to fit. No dots: nothing is being
|
||||
// arranged here.
|
||||
return (
|
||||
<CanvasSurface dashboard={dashboard}>
|
||||
{() => <DashboardView dashboard={dashboard} />}
|
||||
</CanvasSurface>
|
||||
<div className="relative size-full">
|
||||
{stacked ? (
|
||||
<DashboardView dashboard={dashboard} stacked />
|
||||
) : (
|
||||
// The panel's own surface, scaled to fit. No dots: nothing is being
|
||||
// arranged here.
|
||||
<CanvasSurface dashboard={dashboard}>
|
||||
{() => <DashboardView dashboard={dashboard} />}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { Ban, ChevronDown, Plus, X } from "lucide-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 {
|
||||
PANEL_SECTION,
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget"
|
||||
import { MAX_SERIES, refreshFor } from "./ChartWidget"
|
||||
@@ -46,6 +47,13 @@ import {
|
||||
} from "./DashboardView"
|
||||
import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons"
|
||||
import { messageCatalogQueryOptions } from "./queries"
|
||||
import {
|
||||
SETTING_DTYPES,
|
||||
type SettingName,
|
||||
settingIssue,
|
||||
settingOf,
|
||||
THEME_CHOICES,
|
||||
} from "./settings"
|
||||
import {
|
||||
acceptsDtype,
|
||||
INPUT_WIDGETS,
|
||||
@@ -78,13 +86,17 @@ function MessagePicker({
|
||||
value,
|
||||
label,
|
||||
testId,
|
||||
placeholder = "Pick a message",
|
||||
filter,
|
||||
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
|
||||
label: string
|
||||
testId?: string
|
||||
placeholder?: string
|
||||
/** What this slot takes, when the widget's own type does not decide it —
|
||||
* a querying chart asks with one shape and draws another. */
|
||||
filter?: (message: MessageInfo) => boolean
|
||||
@@ -92,7 +104,11 @@ function MessagePicker({
|
||||
}) {
|
||||
const { data } = useQuery(messageCatalogQueryOptions())
|
||||
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 (
|
||||
<div className="grid gap-1.5">
|
||||
@@ -107,7 +123,7 @@ function MessagePicker({
|
||||
}
|
||||
>
|
||||
<SelectTrigger data-testid={testId}>
|
||||
<SelectValue placeholder="Pick a message" />
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{choices.map((message) => (
|
||||
@@ -944,6 +960,73 @@ export function WidgetPanel({
|
||||
const sizeKey = (size: { width: number; height: number }) =>
|
||||
`${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.
|
||||
*
|
||||
@@ -968,6 +1051,12 @@ export function DashboardPanel({
|
||||
}) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
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 (
|
||||
<>
|
||||
@@ -1095,6 +1184,55 @@ export function DashboardPanel({
|
||||
</p>
|
||||
</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">
|
||||
<span className={PANEL_SECTION}>Contents</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { handleError } from "@/utils"
|
||||
// chunked per entry — so the sheet is pulled in wherever the pulse is drawn.
|
||||
import "./dashboard.css"
|
||||
import { usePublishMessage } from "./queries"
|
||||
import { useLocked } from "./settings"
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* 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.
|
||||
@@ -42,6 +54,7 @@ function confirms(live: unknown, sent: unknown): boolean {
|
||||
export function usePublish(widget: WidgetDef, dashboard: string) {
|
||||
const cfg = (widget.config ?? {}) as Record<string, unknown>
|
||||
const target = cfg.target == null ? "" : String(cfg.target)
|
||||
const locked = useLocked()
|
||||
const publish = usePublishMessage()
|
||||
const live = useLiveValue(target || undefined)
|
||||
const { showErrorToast } = useCustomToast()
|
||||
@@ -62,8 +75,10 @@ export function usePublish(widget: WidgetDef, dashboard: string) {
|
||||
target,
|
||||
/** What the control draws: what it sent, until the engine answers. */
|
||||
value: held ? held.value : live?.value,
|
||||
/** Whether this dashboard is read-only; controls draw themselves disabled. */
|
||||
locked,
|
||||
send: (value: unknown) => {
|
||||
if (!target) return
|
||||
if (!target || locked) return
|
||||
setHeld({ value })
|
||||
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)
|
||||
@@ -569,7 +569,7 @@ function NotificationWidget({ widget }: WidgetProps) {
|
||||
|
||||
function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
||||
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 />
|
||||
// Nothing to hold: a button carries no reading, so the pulse and a refusal
|
||||
// are the whole of its feedback.
|
||||
@@ -579,7 +579,7 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full min-w-0"
|
||||
disabled={pending}
|
||||
disabled={pending || locked}
|
||||
onClick={() => send(cfg.value ?? true)}
|
||||
>
|
||||
<span className="truncate">
|
||||
@@ -598,7 +598,7 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
||||
*/
|
||||
function SwitchWidget({ widget, dashboard }: WidgetProps) {
|
||||
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 />
|
||||
|
||||
const on = value === true
|
||||
@@ -610,6 +610,7 @@ function SwitchWidget({ widget, dashboard }: WidgetProps) {
|
||||
className="w-full min-w-0"
|
||||
aria-pressed={on}
|
||||
aria-label={widget.title || target}
|
||||
disabled={locked}
|
||||
onClick={() => send(!on)}
|
||||
>
|
||||
<span className="truncate">{on ? "On" : "Off"}</span>
|
||||
@@ -622,6 +623,7 @@ function SwitchWidget({ widget, dashboard }: WidgetProps) {
|
||||
<Switch
|
||||
checked={on}
|
||||
aria-label={widget.title || target}
|
||||
disabled={locked}
|
||||
onCheckedChange={(checked) => send(checked)}
|
||||
/>
|
||||
</div>
|
||||
@@ -693,7 +695,7 @@ function SliderTicks({
|
||||
|
||||
function SliderWidget({ widget, dashboard }: WidgetProps) {
|
||||
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 max = num(cfg.max, 100)
|
||||
const step = num(cfg.step, 1)
|
||||
@@ -721,6 +723,7 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
|
||||
step={step}
|
||||
value={current}
|
||||
aria-label={widget.title || target}
|
||||
disabled={locked}
|
||||
className="h-11 w-full accent-[var(--primary)] md:h-8"
|
||||
onChange={(event) => setDragging(Number(event.target.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) {
|
||||
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)
|
||||
if (!target) return <Unbound />
|
||||
|
||||
@@ -760,6 +763,7 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
|
||||
value={draft ?? text(value)}
|
||||
type={asNumber ? "number" : "text"}
|
||||
aria-label={widget.title || target}
|
||||
disabled={locked}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(event) => {
|
||||
@@ -778,7 +782,7 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
|
||||
*/
|
||||
function DropdownWidget({ widget, dashboard }: WidgetProps) {
|
||||
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 }[]
|
||||
if (!target) return <Unbound />
|
||||
|
||||
@@ -820,9 +824,10 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) {
|
||||
key={text(option.value)}
|
||||
type="button"
|
||||
aria-pressed={index === chosen}
|
||||
disabled={locked}
|
||||
onClick={() => send(option.value)}
|
||||
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
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50",
|
||||
@@ -844,7 +849,11 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) {
|
||||
value={text(value)}
|
||||
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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -98,6 +98,11 @@
|
||||
* segment is therefore drawn inside a gutter of the fill rather than ever
|
||||
* 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 {
|
||||
--background: #ffffff;
|
||||
--foreground: #333232;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
dashboardQueryOptions,
|
||||
panelQueryOptions,
|
||||
} from "@/components/Dashboard/queries"
|
||||
import { useDashboardTheme } from "@/components/Dashboard/settings"
|
||||
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
||||
import { isLoggedIn } from "@/hooks/useAuth"
|
||||
import { useIsMobile } from "@/hooks/useMobile"
|
||||
@@ -55,6 +56,11 @@ function PanelRoute() {
|
||||
})
|
||||
const stacked = useIsMobile()
|
||||
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) {
|
||||
return (
|
||||
@@ -69,7 +75,8 @@ function PanelRoute() {
|
||||
return (
|
||||
<main
|
||||
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",
|
||||
)}
|
||||
style={rail ? { paddingLeft: RAIL_INSET } : undefined}
|
||||
|
||||
@@ -4,9 +4,11 @@ import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
||||
import { PanelSurface } from "@/components/Dashboard/PanelSurface"
|
||||
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
||||
import { useDashboardTheme } from "@/components/Dashboard/settings"
|
||||
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
||||
import { isLoggedIn } from "@/hooks/useAuth"
|
||||
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.
|
||||
@@ -34,16 +36,19 @@ function PanelView() {
|
||||
useFlowSocket()
|
||||
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
|
||||
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" />
|
||||
|
||||
return (
|
||||
<main
|
||||
className={
|
||||
stacked
|
||||
? "h-svh w-full overflow-y-auto p-4"
|
||||
: "h-svh w-full overflow-hidden p-4"
|
||||
}
|
||||
className={cn(
|
||||
"h-svh w-full bg-background p-4 text-foreground",
|
||||
theme,
|
||||
stacked ? "overflow-y-auto" : "overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<PanelSurface dashboard={dashboard as Dashboard} stacked={stacked} />
|
||||
</main>
|
||||
|
||||
@@ -146,6 +146,11 @@ def process(tick, mode="comfort", away=False, setpoint=21.0):
|
||||
"indoor": round(indoor, 2),
|
||||
"outdoor": round(outdoor, 2),
|
||||
"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.
|
||||
{"name": "outdoor", "dtype": "float", "interval": 60},
|
||||
{"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,
|
||||
"canvas_width": CANVAS[0],
|
||||
"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,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user