diff --git a/backend/fluksio/flow/dashboards.py b/backend/fluksio/flow/dashboards.py
index 5017708..be5a25e 100644
--- a/backend/fluksio/flow/dashboards.py
+++ b/backend/fluksio/flow/dashboards.py
@@ -112,9 +112,9 @@ SETTING_DTYPES: dict[str, str] = {
"theme": "str",
# Read-only: the input widgets stop publishing.
"locked": "bool",
- # "glass" | "material": which of the two component sets draws this
- # dashboard. Anything else is material. The two share every feature; only
- # the drawing differs.
+ # "fluksio" | "material" | "glass": which component set draws this
+ # dashboard. Anything else is "fluksio", the app's own design. The three
+ # share every feature; only the drawing differs.
"look": "str",
# The dashboard's own colours, as an ordered list of hex strings. Position
# is the role: background, surface, primary, accent, text, and anything
diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md
index 8a824d4..9e849a1 100644
--- a/docs/interface/dashboards.md
+++ b/docs/interface/dashboards.md
@@ -100,7 +100,7 @@ set them.
| Setting | Is | Driven by |
|---|---|---|
-| **Look** | `Material` or `Glass` | a `str` message |
+| **Look** | `Fluksio`, `Material` or `Glass` | a `str` message |
| **Theme** | `System`, `Light` or `Dark` | a `str` message |
| **Palette** | the dashboard's colours, in order | a `list` message |
| **Background** | the URL of an image | a `str` message |
@@ -130,11 +130,14 @@ locking a panel down remotely.
### Look
-**Material** lays flat, tonal cards on a plain ground. **Glass** floats
-translucent tiles over a soft, slowly moving one. They are two complete sets of
-components, not two stylesheets — but they are the same dashboard: every widget,
-every control and every keystroke behaves identically, so switching look never
-changes what a panel can do.
+**Fluksio** is the app's own design — `--card` surfaces separated by a hairline
+and a low shadow, pill controls, one slate-blue accent — and is what a dashboard
+wears until you choose otherwise. **Material** lays flat, tonal cards on a plain
+ground. **Glass** floats translucent tiles over a soft, slowly moving one.
+
+They are three complete sets of components, not three stylesheets — but they are
+the same dashboard: every widget, every control and every keystroke behaves
+identically, so switching look never changes what a panel can do.
### Palette
diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx
index 329c504..d122aa8 100644
--- a/frontend/src/components/Dashboard/panels.tsx
+++ b/frontend/src/components/Dashboard/panels.tsx
@@ -1421,10 +1421,12 @@ export function DashboardPanel({
onChange={(setting) => setSetting("look", setting)}
/>
- How this dashboard is drawn. Glass floats translucent tiles over a
- soft moving ground; Material lays flat tonal cards on a plain one.
- The widgets are the same either way — a look changes what they
- look like and nothing about what they do.
+ How this dashboard is drawn. Fluksio is the app's own design, and
+ what a dashboard wears until you choose otherwise; Material lays
+ flat tonal cards on a plain ground; Glass floats translucent tiles
+ over a soft moving one. The widgets are the same whichever you
+ pick — a look changes what they look like and nothing about what
+ they do.
diff --git a/frontend/src/components/Dashboard/settings.tsx b/frontend/src/components/Dashboard/settings.tsx
index de496fe..d9e813f 100644
--- a/frontend/src/components/Dashboard/settings.tsx
+++ b/frontend/src/components/Dashboard/settings.tsx
@@ -34,7 +34,7 @@ import { isLight, parsePalette, rolesOf } from "./ui/core/theme"
export const SETTING_DTYPES = {
theme: "str",
locked: "bool",
- /** `glass` or `material`; anything else is material. */
+ /** `fluksio`, `material` or `glass`; anything else is `fluksio`. */
look: "str",
/** Hex colours, roles by position — see `ui/core/theme.ts`. */
palette: "list",
@@ -88,17 +88,18 @@ export function useSetting(
return live?.value ?? setting.value
}
-/** What a dashboard is drawn as. Anything unrecorded is material. */
-export type Look = "glass" | "material"
+/** 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 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" : "material"
+ value === "glass" ? "glass" : value === "material" ? "material" : "fluksio"
/** Which of the two component sets draws this dashboard. */
export const useDashboardLook = (
diff --git a/frontend/src/components/Dashboard/ui/core/motion.ts b/frontend/src/components/Dashboard/ui/core/motion.ts
index 3a050ea..cf70814 100644
--- a/frontend/src/components/Dashboard/ui/core/motion.ts
+++ b/frontend/src/components/Dashboard/ui/core/motion.ts
@@ -28,6 +28,17 @@ type LookMotion = {
}
export const LOOK: Record = {
+ fluksio: {
+ // The app's own timing, as `lib/motion.ts` states it: quick, and settling
+ // without overshoot. A dashboard wearing the product's design should move
+ // the way the rest of the product does.
+ spring: { type: "spring", stiffness: 420, damping: 34, mass: 1 },
+ enter: {
+ hidden: { opacity: 0, y: 8 },
+ visible: { opacity: 1, y: 0 },
+ },
+ drift: 0,
+ },
material: {
spring: { type: "spring", stiffness: 550, damping: 38, mass: 1 },
enter: {
diff --git a/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx b/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx
new file mode 100644
index 0000000..bd3e62a
--- /dev/null
+++ b/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx
@@ -0,0 +1,223 @@
+/**
+ * Fluksio: the controls.
+ *
+ * Every control is a pill, and a press is answered by the colour changing and
+ * nothing else — no ripple, no glow, no lift. That restraint is the house
+ * style rather than an omission: the one accent is spent on saying what is
+ * selected, not on saying that something was touched.
+ *
+ * Every one of these is a `ui/core` hook in a different coat — the state, the
+ * keyboard and the `aria-` come from there.
+ */
+
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { Check, ChevronDown } from "lucide-react"
+import { motion } from "motion/react"
+
+import { cn } from "@/lib/utils"
+import { asOriginal, text } from "../core/config"
+import type {
+ ButtonProps,
+ InputProps,
+ SegmentedProps,
+ SelectProps,
+ SliderProps,
+ SwitchProps,
+} from "../core/contract"
+import { useSegmented, useSliderDrag, useSwitch } from "../core/controls"
+import { useLook } from "../core/look"
+import { LOOK } from "../core/motion"
+
+export function Button({
+ variant = "tonal",
+ pressed,
+ disabled,
+ label,
+ onClick,
+ children,
+}: ButtonProps) {
+ return (
+
+ )
+}
+
+export function Switch(props: SwitchProps) {
+ const { buttonProps } = useSwitch(props)
+ return (
+
+ )
+}
+
+export function Slider(props: SliderProps) {
+ const { fraction, inputProps, marks } = useSliderDrag(props)
+ const orientation = props.orientation ?? "horizontal"
+ return (
+
+
+
+
+
+ {/* The control itself, laid transparent over what is drawn: it keeps
+ the keyboard, the pointer and every `aria-`, and owes the look
+ nothing. */}
+
+
+
+ {marks.length > 0 ? (
+ // Decoration: the input itself announces min, max and where it stands.
+
+ {row.label}
+
+
+
+ {/* Beside the track rather than on it: a number written on a fill has to
+ clear the fill it sits on and the track it slides onto, and one that
+ reads across a room cannot do both. */}
+
+ {format(row.value, row.precision)}
+ {row.unit ?? ""}
+
+
+ )
+}
+
+export function Bar({ rows }: BarProps) {
+ // No group role of its own: each row already announces what it reads and
+ // what it is worth, and the tile's title is on the frame around them.
+ return (
+
+ {rows.map((row, index) => (
+ // Two rows can read the same message under different labels; position
+ // is the identity, as it is for chart series.
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/components/Dashboard/ui/fluksio/Surfaces.tsx b/frontend/src/components/Dashboard/ui/fluksio/Surfaces.tsx
new file mode 100644
index 0000000..c970e36
--- /dev/null
+++ b/frontend/src/components/Dashboard/ui/fluksio/Surfaces.tsx
@@ -0,0 +1,182 @@
+/**
+ * Fluksio: the surfaces.
+ *
+ * The house style. A widget is a `--card` told from the page by a hairline and
+ * a low shadow rather than by colour, floating chrome is frosted, and the one
+ * accent is spent on what a person can act on. Nothing here holds state: the
+ * behaviour is in `ui/core`.
+ */
+import { Link } from "@tanstack/react-router"
+import { Lock } from "lucide-react"
+import { motion } from "motion/react"
+
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { cn } from "@/lib/utils"
+import { ICONS } from "../../icons"
+import type {
+ BackdropProps,
+ FrameProps,
+ NoticeProps,
+ RailProps,
+} from "../core/contract"
+import { TESTID } from "../core/contract"
+import { LOOK } from "../core/motion"
+
+/** The page colour is the ground; only an image is drawn over it. */
+export function Backdrop({ image }: BackdropProps) {
+ if (!image) return null
+ return (
+
+ )
+}
+
+function Issue({ issue }: { issue: string }) {
+ return (
+
+
+
+
+ {issue}
+
+ )
+}
+
+export function Frame({
+ title,
+ issue,
+ grip,
+ scroll,
+ selected,
+ onClick,
+ children,
+}: FrameProps) {
+ return (
+ // A card is not a control: the click only picks it in edit mode, and every
+ // interactive element inside keeps its own role and keyboard handling.
+ // biome-ignore lint/a11y/useKeyWithClickEvents: see above.
+ // biome-ignore lint/a11y/noStaticElementInteractions: see above.
+
+ {title ? (
+
+ {title}
+ {issue ? : null}
+
+ ) : null}
+
+ {children}
+
+ {/* No header to sit in, so the fault takes the corner instead — a widget
+ drawn without its title still says when it is mis-wired. */}
+ {!title && issue ? (
+
+
+
+ ) : null}
+ {/* And the editor still needs something to drag it by. */}
+ {!title && grip ? (
+
+ ) : null}
+
+ )
+}
+
+/** Two letters off the title, so a rail of four reads as four different things. */
+function initials(label: string): string {
+ const words = label.split(/[\s_-]+/).filter(Boolean)
+ if (words.length === 0) return "?"
+ if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
+ return (words[0][0] + words[1][0]).toUpperCase()
+}
+
+export function Rail({ entries }: RailProps) {
+ return (
+
+ )
+}
+
+export function Notice({ children }: NoticeProps) {
+ return (
+
+
+ {children}
+
+ )
+}
diff --git a/frontend/src/components/Dashboard/ui/fluksio/fluksio.css b/frontend/src/components/Dashboard/ui/fluksio/fluksio.css
new file mode 100644
index 0000000..9941311
--- /dev/null
+++ b/frontend/src/components/Dashboard/ui/fluksio/fluksio.css
@@ -0,0 +1,317 @@
+/*
+ * Fluksio — the dashboard wearing the product's own design.
+ *
+ * The other two looks are somebody else's language spoken well. This one is
+ * the house style, and it follows the root `DESIGN-GUIDELINES.md` to the
+ * letter: `--card` surfaces told apart from the page by a hairline and a low
+ * shadow rather than by colour, every control a pill, 16px panels, one
+ * slate-blue accent and nothing else competing with it.
+ *
+ * Which makes it the sober one, and the right default: a dashboard nobody has
+ * dressed yet should look like the rest of the app.
+ *
+ * The one deliberate departure is the selector, whose choice is held in
+ * `--primary` rather than `--accent`. That is a decision about the widget
+ * rather than about this look — a control must not change what it signals
+ * when the look changes — so all three sets hold it the same way.
+ *
+ * Paint only; the measurements are in `../core/core.css`.
+ */
+
+[data-look="fluksio"] {
+ --fx-radius: var(--radius-lg);
+ --fx-field: var(--radius-md);
+}
+
+/* --- surfaces ------------------------------------------------------------ */
+
+.fx-frame {
+ border: 1px solid var(--border);
+ border-radius: var(--fx-radius);
+ background: var(--card);
+ color: var(--card-foreground);
+ box-shadow: var(--shadow-e1);
+}
+
+.fx-frame[data-selected] {
+ border-color: var(--primary);
+ outline: 2px solid var(--primary);
+ outline-offset: -2px;
+}
+
+.fx-frame-title {
+ color: var(--muted-foreground);
+}
+
+/* Floating chrome over content is frosted (guidelines → Overlay surfaces). */
+.fx-rail,
+.fx-notice {
+ border: 1px solid var(--border);
+ background: color-mix(in srgb, var(--card) 80%, transparent);
+ box-shadow: var(--shadow-e2);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+}
+
+.fx-rail {
+ border-radius: var(--fx-radius);
+ color: var(--card-foreground);
+}
+
+.fx-notice {
+ border-radius: 9999px;
+ color: var(--muted-foreground);
+}
+
+/* A content chip is transparent at rest and fills on hover or when active. */
+.fx-rail-item {
+ border-radius: 9999px;
+ color: var(--muted-foreground);
+ transition:
+ background-color var(--duration-fast) var(--ease-standard),
+ color var(--duration-fast) var(--ease-standard);
+}
+
+.fx-rail-item[aria-current="page"] {
+ color: var(--foreground);
+}
+
+[data-look="fluksio"]:not([data-touch]) .fx-rail-item:hover {
+ background: color-mix(in srgb, var(--accent) 50%, transparent);
+}
+
+/* --- controls ------------------------------------------------------------ */
+
+.fx-button {
+ display: inline-flex;
+ min-width: 0;
+ min-height: var(--dui-control);
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ border-radius: 9999px;
+ padding-inline: 1.25rem;
+ font-size: var(--dui-text);
+ font-weight: 500;
+ background: var(--secondary);
+ color: var(--secondary-foreground);
+ transition: background-color var(--duration-fast) var(--ease-standard);
+}
+
+.fx-button[data-variant="filled"],
+.fx-button[aria-pressed="true"] {
+ background: var(--primary);
+ color: var(--primary-foreground);
+}
+
+.fx-button[data-variant="text"] {
+ background: transparent;
+}
+
+[data-look="fluksio"]:not([data-touch]) .fx-button:hover {
+ background: color-mix(in srgb, var(--secondary) 80%, var(--foreground));
+}
+
+[data-look="fluksio"]:not([data-touch]) .fx-button[data-variant="filled"]:hover,
+[data-look="fluksio"]:not([data-touch]) .fx-button[aria-pressed="true"]:hover {
+ background: color-mix(in srgb, var(--primary) 90%, var(--foreground));
+}
+
+.fx-button:disabled {
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+.fx-switch {
+ position: relative;
+ display: inline-flex;
+ width: calc(var(--dui-control) * 1.6);
+ height: calc(var(--dui-control) * 0.75);
+ flex: none;
+ align-items: center;
+ justify-content: flex-start;
+ border-radius: 9999px;
+ padding: 0.125rem;
+ background: var(--input);
+ transition: background-color var(--duration-fast) var(--ease-standard);
+}
+
+.fx-switch[data-state="on"] {
+ justify-content: flex-end;
+ background: var(--primary);
+}
+
+.fx-switch-thumb {
+ display: block;
+ height: calc(var(--dui-control) * 0.75 - 0.25rem);
+ aspect-ratio: 1;
+ border-radius: 50%;
+ background: var(--background);
+ box-shadow: var(--shadow-e1);
+}
+
+.fx-switch:disabled {
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+.fx-slider .dui-slider-rail {
+ border-radius: 9999px;
+ background: var(--muted);
+}
+
+.fx-slider .dui-slider-fill {
+ border-radius: 9999px;
+ background: var(--primary);
+}
+
+.fx-slider .dui-slider-thumb {
+ border: 2px solid var(--primary);
+ background: var(--background);
+ box-shadow: var(--shadow-e1);
+}
+
+/* The one segmented shape: a single border pill, no dividers, transparent
+ segments — with the choice held in the primary the widget signals with. */
+.fx-segmented {
+ position: relative;
+ display: grid;
+ min-width: 0;
+ align-items: center;
+ border: 1px solid var(--border);
+ border-radius: 9999px;
+ padding: 0.25rem;
+}
+
+.fx-segmented[data-orientation="vertical"] {
+ height: 100%;
+ grid-auto-flow: row;
+ grid-auto-rows: minmax(0, 1fr);
+ border-radius: var(--fx-radius);
+}
+
+.fx-segment {
+ position: relative;
+ display: flex;
+ min-width: 0;
+ min-height: calc(var(--dui-control) - 0.5rem);
+ align-items: center;
+ justify-content: center;
+ border-radius: 9999px;
+ padding-inline: 0.625rem;
+ font-size: var(--dui-text);
+ color: var(--muted-foreground);
+ transition: color var(--duration-fast) var(--ease-standard);
+}
+
+.fx-segment[data-active] {
+ color: var(--primary-foreground);
+}
+
+[data-look="fluksio"]:not([data-touch]) .fx-segment:not([data-active]):hover {
+ background: color-mix(in srgb, var(--accent) 50%, transparent);
+}
+
+.fx-segment-thumb {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ border-radius: 9999px;
+ background: var(--primary);
+}
+
+.fx-segment-label,
+.fx-switch-thumb,
+.fx-lift {
+ position: relative;
+ z-index: 1;
+}
+
+.fx-segment-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.fx-field {
+ display: flex;
+ width: 100%;
+ min-width: 0;
+ min-height: var(--dui-control);
+ align-items: center;
+ gap: 0.5rem;
+ border: 1px solid var(--input);
+ border-radius: 9999px;
+ background: var(--background);
+ padding-inline: 1rem;
+ font-size: var(--dui-text);
+ color: var(--foreground);
+ text-align: left;
+ transition:
+ border-color var(--duration-fast) var(--ease-standard),
+ box-shadow var(--duration-fast) var(--ease-standard);
+}
+
+.fx-field::placeholder {
+ color: var(--muted-foreground);
+}
+
+.fx-field:disabled {
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+/* Menus and popovers are 12px with the floating elevation. */
+.fx-menu {
+ border: 1px solid var(--border);
+ border-radius: var(--fx-field);
+ background: var(--popover);
+ color: var(--popover-foreground);
+ box-shadow: var(--shadow-e2);
+}
+
+.fx-menu-item {
+ border-radius: var(--radius-sm);
+}
+
+.fx-menu-item[data-highlighted] {
+ background: var(--accent);
+ color: var(--accent-foreground);
+}
+
+/* --- readings ------------------------------------------------------------ */
+
+.fx-track {
+ border-radius: 9999px;
+ background: var(--muted);
+}
+
+.fx-fill {
+ border-radius: 9999px;
+ background: var(--dui-fill, var(--primary));
+}
+
+.fx-disc-thumb {
+ border: 3px solid var(--card);
+ box-shadow: var(--shadow-e1);
+}
+
+/* --- focus --------------------------------------------------------------- */
+
+[data-look="fluksio"]
+ :is(
+ .fx-button,
+ .fx-switch,
+ .fx-segment,
+ .fx-field,
+ .fx-rail-item,
+ .dui-disc
+ ):focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
+}
+
+[data-look="fluksio"] .dui-slider-input:focus-visible ~ .dui-slider-thumb {
+ outline: none;
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
+}
diff --git a/frontend/src/components/Dashboard/ui/fluksio/index.ts b/frontend/src/components/Dashboard/ui/fluksio/index.ts
new file mode 100644
index 0000000..9aa733c
--- /dev/null
+++ b/frontend/src/components/Dashboard/ui/fluksio/index.ts
@@ -0,0 +1,30 @@
+/**
+ * Fluksio — the dashboard in the product's own design, and the one a
+ * dashboard wears until somebody chooses otherwise.
+ *
+ * Every entry of `ComponentSet` is implemented here; the behaviour comes from
+ * `ui/core`, so this set cannot do anything the others cannot.
+ */
+import "./fluksio.css"
+
+import type { ComponentSet } from "../core/contract"
+import { Button, Input, Segmented, Select, Slider, Switch } from "./Controls"
+import { Bar, ColorDisk, Gauge, Readout } from "./Data"
+import { Backdrop, Frame, Notice, Rail } from "./Surfaces"
+
+export const fluksio: ComponentSet = {
+ Backdrop,
+ Frame,
+ Button,
+ Switch,
+ Slider,
+ Segmented,
+ Select,
+ Input,
+ Readout,
+ Gauge,
+ Bar,
+ ColorDisk,
+ Rail,
+ Notice,
+}
diff --git a/frontend/src/components/Dashboard/ui/index.ts b/frontend/src/components/Dashboard/ui/index.ts
index 99e7885..4236dca 100644
--- a/frontend/src/components/Dashboard/ui/index.ts
+++ b/frontend/src/components/Dashboard/ui/index.ts
@@ -2,7 +2,7 @@
* The dashboard's components, in whichever look this dashboard wears.
*
* A widget asks for the set and draws with it; it never learns which one it
- * got, which is what keeps the two looks the same dashboard. Behaviour lives
+ * got, which is what keeps the three looks the same dashboard. Behaviour lives
* under `core/` and is shared, so there is nothing for a set to disagree
* about beyond markup and motion.
*/
@@ -11,10 +11,11 @@ import "./core/core.css"
import type { Look } from "../settings"
import type { ComponentSet } from "./core/contract"
import { useLook } from "./core/look"
+import { fluksio } from "./fluksio"
import { glass } from "./glass"
import { material } from "./material"
-const SETS: Record = { glass, material }
+const SETS: Record = { fluksio, glass, material }
/** The components this dashboard is drawn with. */
export const useUi = (): ComponentSet => SETS[useLook().look]
diff --git a/frontend/tests/widgets.spec.ts b/frontend/tests/widgets.spec.ts
index abc8327..5711d60 100644
--- a/frontend/tests/widgets.spec.ts
+++ b/frontend/tests/widgets.spec.ts
@@ -626,14 +626,14 @@ async function setLook(
})
}
-// Both looks, in both themes. A look is mostly colour and depth, so the four
-// screenshots are the assertion that neither collapses — and the checks around
-// them are that a look changes how the panel is drawn and nothing else.
+// Every look, in both themes. A look is mostly colour and depth, so the
+// screenshots are the assertion that none of them collapses — and the checks
+// around them are that a look changes how the panel is drawn and nothing else.
for (const scheme of ["light", "dark"] as const) {
test.describe(`${scheme} theme`, () => {
test.use({ colorScheme: scheme })
- for (const look of ["material", "glass"] as const) {
+ for (const look of ["fluksio", "material", "glass"] as const) {
test(`the panel reads in ${scheme} ${look}`, async ({ page }) => {
await openPanel(page)
await setLook(page, dashboardName, { look: { value: look } })
@@ -691,7 +691,7 @@ for (const scheme of ["light", "dark"] as const) {
path: `screenshots/widgets/${scheme}-palette.png`,
})
await setLook(page, dashboardName, {
- look: { value: "material" },
+ look: { value: "fluksio" },
palette: { value: [] },
})
})