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.
219 lines
8.1 KiB
TypeScript
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)
|