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 /** 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 ( {children} ) } /** 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 ( {children} ) } /** Whether the dashboard around this control is read-only. */ export const useLocked = () => useContext(LockedContext)