Files
app/frontend/src/components/Dashboard/settings.tsx
T
stroblme 3604e85efb Add the Fluksio look, and make it the one a dashboard starts in
Two looks were somebody else's language spoken well, and neither was the
product's. A dashboard nobody has dressed yet should look like the rest
of the app, so there is now a third set that follows the root
DESIGN-GUIDELINES.md to the letter — `--card` surfaces told from the page
by a hairline and a low shadow rather than by colour, every control a
pill, 16px panels, frosted floating chrome, one slate-blue accent spent
on what a person can act on — and it is what `look` means when nothing
says otherwise.

That also turns the exemption the other way round. The dashboard is still
allowed to look unlike the product; it just no longer does so by default.
An existing dashboard, which has never named a look, lands on the design
it had before any of this.

Restraint is the style rather than an omission here: no ripple, no glow,
no lift, and a press answered by the colour changing. The one deliberate
departure is the selector, which holds its choice in `--primary` rather
than the `--accent` the segmented rule asks for — that is a decision
about the widget, not about the look, and a control must not change what
it signals when the drawing changes. All three sets hold it the same way.
2026-08-24 14:25:13 +02:00

219 lines
8.1 KiB
TypeScript

import { createContext, useContext } from "react"
import type { DashboardDef_Output, SettingDef } from "@/client"
import { NO_PALETTE } from "@/components/Common/UplotChart"
import { useLiveValue } from "@/components/Flow/liveStore"
import { isLight, parsePalette, rolesOf } from "./ui/core/theme"
/**
* 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 = {
theme: "str",
locked: "bool",
/** `fluksio`, `material` or `glass`; anything else is `fluksio`. */
look: "str",
/** Hex colours, roles by position — see `ui/core/theme.ts`. */
palette: "list",
/** The URL of an image drawn under the widgets. */
background: "str",
/** Bigger controls, for a panel that is touched rather than pointed at. */
touch: "bool",
} as const satisfies Record<string, string>
/** The settings this build actually wires up. */
export type SettingName = keyof typeof SETTING_DTYPES
/** 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 as SettingName] as string | undefined
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. */
export 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
}
/** What a dashboard is drawn as. Anything unrecorded wears the app's own. */
export type Look = "fluksio" | "glass" | "material"
/** The looks, as the editor offers them. The dashboard's own comes first. */
export const LOOK_CHOICES = [
["fluksio", "Fluksio"],
["material", "Material"],
["glass", "Glass"],
] as const
export const lookOf = (value: unknown): Look =>
value === "glass" ? "glass" : value === "material" ? "material" : "fluksio"
/** Which of the two component sets draws this dashboard. */
export const useDashboardLook = (
dashboard: DashboardDef_Output | undefined,
): Look => lookOf(useSetting(dashboard, "look"))
/** The dashboard's own colours, as the hexes a palette named. */
export const useDashboardPalette = (
dashboard: DashboardDef_Output | undefined,
): string[] => parsePalette(useSetting(dashboard, "palette"))
/** An image drawn under the widgets, or `""` for the look's own ground. */
export function useDashboardBackground(
dashboard: DashboardDef_Output | undefined,
): string {
const value = useSetting(dashboard, "background")
return typeof value === "string" ? value.trim() : ""
}
/** Whether this dashboard is drawn for a finger rather than a pointer. */
export const useDashboardTouch = (
dashboard: DashboardDef_Output | undefined,
): boolean => useSetting(dashboard, "touch") === true
/**
* 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.
*
* A palette decides this for itself: its first colour *is* the ground, so
* whether the surface is light or dark is a fact about the palette rather than
* a second setting that could disagree with it. The class still goes on,
* because a chart canvas and the shadcn pieces inside the tile read it.
*/
export function useDashboardTheme(
dashboard: DashboardDef_Output | undefined,
): "" | "light" | "dark" {
const value = useSetting(dashboard, "theme")
const roles = rolesOf(useDashboardPalette(dashboard))
if (roles) return isLight(roles.background) ? "light" : "dark"
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 PaletteContext = createContext(NO_PALETTE)
/**
* The data colours everything drawn under it uses.
*
* The colours a palette named, in draw order — the primary first, then the
* accent, then whatever it listed after the roles. Empty when the dashboard
* names no palette, which is not the same as "the ramp": a chart with no
* palette spreads itself across the ramp by how many lines it draws
* (`slotsFor`), and only a named palette overrides that.
*
* Mounted by the view *and* the editor: an editor drawing the automatic
* spread while the panel beside it showed the dashboard's own palette would
* be a preview that lies.
*
* Data colour only. A fault stays `--destructive` and a condition stays its
* `ICON_COLORS` entry, because those name a state rather than tell one series
* from another — a palette they followed could paint a failure the same blue
* as a reading. Only the chart reads this today; a second consumer costs one
* `usePalette()`.
*/
export function PaletteProvider({
dashboard,
children,
}: {
dashboard: DashboardDef_Output | undefined
children: React.ReactNode
}) {
const palette = rolesOf(useDashboardPalette(dashboard))?.series ?? NO_PALETTE
return (
<PaletteContext.Provider value={palette}>
{children}
</PaletteContext.Provider>
)
}
/** The ramp slots the dashboard around this widget named, or none. */
export const usePalette = () => useContext(PaletteContext)
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)