Add the Fluksio look, and make it the one a dashboard starts in
Docs / docs (push) Successful in 48s
Playwright Tests / test-playwright (1, 2) (push) Failing after 16s
Playwright Tests / test-playwright (2, 2) (push) Failing after 13s
pre-commit / pre-commit (push) Failing after 2m8s
Test Backend / test-backend (push) Failing after 49s
Compose Smoke Test / test-compose (push) Failing after 26s
Playwright Tests / merge-reports (push) Failing after 13s
Docs / docs (push) Successful in 48s
Playwright Tests / test-playwright (1, 2) (push) Failing after 16s
Playwright Tests / test-playwright (2, 2) (push) Failing after 13s
pre-commit / pre-commit (push) Failing after 2m8s
Test Backend / test-backend (push) Failing after 49s
Compose Smoke Test / test-compose (push) Failing after 26s
Playwright Tests / merge-reports (push) Failing after 13s
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.
This commit is contained in:
@@ -112,9 +112,9 @@ SETTING_DTYPES: dict[str, str] = {
|
|||||||
"theme": "str",
|
"theme": "str",
|
||||||
# Read-only: the input widgets stop publishing.
|
# Read-only: the input widgets stop publishing.
|
||||||
"locked": "bool",
|
"locked": "bool",
|
||||||
# "glass" | "material": which of the two component sets draws this
|
# "fluksio" | "material" | "glass": which component set draws this
|
||||||
# dashboard. Anything else is material. The two share every feature; only
|
# dashboard. Anything else is "fluksio", the app's own design. The three
|
||||||
# the drawing differs.
|
# share every feature; only the drawing differs.
|
||||||
"look": "str",
|
"look": "str",
|
||||||
# The dashboard's own colours, as an ordered list of hex strings. Position
|
# The dashboard's own colours, as an ordered list of hex strings. Position
|
||||||
# is the role: background, surface, primary, accent, text, and anything
|
# is the role: background, surface, primary, accent, text, and anything
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ set them.
|
|||||||
|
|
||||||
| Setting | Is | Driven by |
|
| 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 |
|
| **Theme** | `System`, `Light` or `Dark` | a `str` message |
|
||||||
| **Palette** | the dashboard's colours, in order | a `list` message |
|
| **Palette** | the dashboard's colours, in order | a `list` message |
|
||||||
| **Background** | the URL of an image | a `str` message |
|
| **Background** | the URL of an image | a `str` message |
|
||||||
@@ -130,11 +130,14 @@ locking a panel down remotely.
|
|||||||
|
|
||||||
### Look
|
### Look
|
||||||
|
|
||||||
**Material** lays flat, tonal cards on a plain ground. **Glass** floats
|
**Fluksio** is the app's own design — `--card` surfaces separated by a hairline
|
||||||
translucent tiles over a soft, slowly moving one. They are two complete sets of
|
and a low shadow, pill controls, one slate-blue accent — and is what a dashboard
|
||||||
components, not two stylesheets — but they are the same dashboard: every widget,
|
wears until you choose otherwise. **Material** lays flat, tonal cards on a plain
|
||||||
every control and every keystroke behaves identically, so switching look never
|
ground. **Glass** floats translucent tiles over a soft, slowly moving one.
|
||||||
changes what a panel can do.
|
|
||||||
|
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
|
### Palette
|
||||||
|
|
||||||
|
|||||||
@@ -1421,10 +1421,12 @@ export function DashboardPanel({
|
|||||||
onChange={(setting) => setSetting("look", setting)}
|
onChange={(setting) => setSetting("look", setting)}
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
How this dashboard is drawn. Glass floats translucent tiles over a
|
How this dashboard is drawn. Fluksio is the app's own design, and
|
||||||
soft moving ground; Material lays flat tonal cards on a plain one.
|
what a dashboard wears until you choose otherwise; Material lays
|
||||||
The widgets are the same either way — a look changes what they
|
flat tonal cards on a plain ground; Glass floats translucent tiles
|
||||||
look like and nothing about what they do.
|
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { isLight, parsePalette, rolesOf } from "./ui/core/theme"
|
|||||||
export const SETTING_DTYPES = {
|
export const SETTING_DTYPES = {
|
||||||
theme: "str",
|
theme: "str",
|
||||||
locked: "bool",
|
locked: "bool",
|
||||||
/** `glass` or `material`; anything else is material. */
|
/** `fluksio`, `material` or `glass`; anything else is `fluksio`. */
|
||||||
look: "str",
|
look: "str",
|
||||||
/** Hex colours, roles by position — see `ui/core/theme.ts`. */
|
/** Hex colours, roles by position — see `ui/core/theme.ts`. */
|
||||||
palette: "list",
|
palette: "list",
|
||||||
@@ -88,17 +88,18 @@ export function useSetting(
|
|||||||
return live?.value ?? setting.value
|
return live?.value ?? setting.value
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a dashboard is drawn as. Anything unrecorded is material. */
|
/** What a dashboard is drawn as. Anything unrecorded wears the app's own. */
|
||||||
export type Look = "glass" | "material"
|
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 = [
|
export const LOOK_CHOICES = [
|
||||||
|
["fluksio", "Fluksio"],
|
||||||
["material", "Material"],
|
["material", "Material"],
|
||||||
["glass", "Glass"],
|
["glass", "Glass"],
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const lookOf = (value: unknown): Look =>
|
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. */
|
/** Which of the two component sets draws this dashboard. */
|
||||||
export const useDashboardLook = (
|
export const useDashboardLook = (
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ type LookMotion = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const LOOK: Record<Look, LookMotion> = {
|
export const LOOK: Record<Look, LookMotion> = {
|
||||||
|
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: {
|
material: {
|
||||||
spring: { type: "spring", stiffness: 550, damping: 38, mass: 1 },
|
spring: { type: "spring", stiffness: 550, damping: 38, mass: 1 },
|
||||||
enter: {
|
enter: {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fx-button w-full"
|
||||||
|
data-variant={variant}
|
||||||
|
aria-pressed={pressed}
|
||||||
|
aria-label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">{children}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Switch(props: SwitchProps) {
|
||||||
|
const { buttonProps } = useSwitch(props)
|
||||||
|
return (
|
||||||
|
<button {...buttonProps} className="fx-switch">
|
||||||
|
<motion.span
|
||||||
|
layout
|
||||||
|
transition={LOOK.fluksio.spring}
|
||||||
|
className="fx-switch-thumb"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Slider(props: SliderProps) {
|
||||||
|
const { fraction, inputProps, marks } = useSliderDrag(props)
|
||||||
|
const orientation = props.orientation ?? "horizontal"
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"dui-slider fx-slider",
|
||||||
|
orientation === "horizontal" && "w-full",
|
||||||
|
)}
|
||||||
|
data-orientation={orientation}
|
||||||
|
style={{ "--dui-fraction": fraction } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<div className="dui-slider-body">
|
||||||
|
<span className="dui-slider-rail" aria-hidden>
|
||||||
|
<span className="dui-slider-fill" />
|
||||||
|
</span>
|
||||||
|
{/* The control itself, laid transparent over what is drawn: it keeps
|
||||||
|
the keyboard, the pointer and every `aria-`, and owes the look
|
||||||
|
nothing. */}
|
||||||
|
<input {...inputProps} className="dui-slider-input" />
|
||||||
|
<span className="dui-slider-thumb" aria-hidden />
|
||||||
|
</div>
|
||||||
|
{marks.length > 0 ? (
|
||||||
|
// Decoration: the input itself announces min, max and where it stands.
|
||||||
|
<div aria-hidden className="dui-ticks">
|
||||||
|
{marks.map((mark) => (
|
||||||
|
<span
|
||||||
|
key={mark.percent}
|
||||||
|
className="absolute top-0"
|
||||||
|
// Shifted by its own share of itself: the first label sits flush
|
||||||
|
// left and the last flush right, so neither hangs off the tile.
|
||||||
|
style={{
|
||||||
|
left: `${mark.percent}%`,
|
||||||
|
transform: `translateX(-${mark.percent}%)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mark.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Segmented(props: SegmentedProps) {
|
||||||
|
const { chosen, thumbId, groupProps, itemProps } = useSegmented(props)
|
||||||
|
const vertical = props.orientation === "vertical"
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...groupProps}
|
||||||
|
data-testid={props.testId}
|
||||||
|
className="fx-segmented"
|
||||||
|
style={
|
||||||
|
vertical
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
gridTemplateColumns: `repeat(${props.options.length}, minmax(0, 1fr))`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.options.map(([value, label], index) => (
|
||||||
|
<button key={value} {...itemProps(index)} className="fx-segment">
|
||||||
|
{index === chosen ? (
|
||||||
|
<motion.span
|
||||||
|
aria-hidden
|
||||||
|
layoutId={thumbId}
|
||||||
|
transition={LOOK.fluksio.spring}
|
||||||
|
className="fx-segment-thumb"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="fx-segment-label">{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
label,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
}: SelectProps) {
|
||||||
|
// The menu is portalled to `body`, which is outside the canvas — so it
|
||||||
|
// re-states the dashboard's own colours rather than borrowing the app's.
|
||||||
|
const { style } = useLook()
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Root
|
||||||
|
value={value}
|
||||||
|
onValueChange={(selected) => onChange(asOriginal(selected, options))}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
aria-label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
className="fx-field justify-between"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
<SelectPrimitive.Value placeholder="Choose" />
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
position="popper"
|
||||||
|
sideOffset={4}
|
||||||
|
style={style}
|
||||||
|
className="fx-menu z-50 max-h-64 min-w-[var(--radix-select-trigger-width)] overflow-y-auto p-1 data-[state=open]:animate-in data-[state=open]:zoom-in-95 data-[state=open]:fade-in-0"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
key={text(option.value)}
|
||||||
|
value={text(option.value)}
|
||||||
|
className="fx-menu-item flex cursor-pointer select-none items-center justify-between gap-2 px-3 py-2 text-sm outline-none"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText>
|
||||||
|
{option.label ?? text(option.value)}
|
||||||
|
</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="size-4" aria-hidden />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
</SelectPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({
|
||||||
|
value,
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
onCommit,
|
||||||
|
}: InputProps) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className="fx-field"
|
||||||
|
value={value}
|
||||||
|
type={type}
|
||||||
|
aria-label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
onBlur={onCommit}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") onCommit()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* Fluksio: the readings.
|
||||||
|
*
|
||||||
|
* Plain fills on recessed tracks, and every number written out beside the
|
||||||
|
* picture it is drawn as — an angle or a length nobody can measure is not a
|
||||||
|
* reading. Data colours come from the dashboard; the surfaces around them do
|
||||||
|
* not compete for attention with the readings on them.
|
||||||
|
*/
|
||||||
|
import { motion, useTransform } from "motion/react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { arcPath, GAUGE_START, GAUGE_SWEEP, GAUGE_TRACK } from "../core/arc"
|
||||||
|
import { cssOf } from "../core/color"
|
||||||
|
import { format } from "../core/config"
|
||||||
|
import type {
|
||||||
|
BarProps,
|
||||||
|
ColorDiskProps,
|
||||||
|
GaugeProps,
|
||||||
|
ReadoutProps,
|
||||||
|
} from "../core/contract"
|
||||||
|
import { TESTID } from "../core/contract"
|
||||||
|
import { useColorDisk } from "../core/disc"
|
||||||
|
import { useAnimatedFraction, useAnimatedNumber } from "../core/values"
|
||||||
|
import { Slider } from "./Controls"
|
||||||
|
|
||||||
|
export function Readout({
|
||||||
|
value,
|
||||||
|
precision,
|
||||||
|
unit,
|
||||||
|
size = "hero",
|
||||||
|
}: ReadoutProps) {
|
||||||
|
const { label, numeric } = useAnimatedNumber(value, precision)
|
||||||
|
return (
|
||||||
|
<span data-testid="readout" className="flex min-w-0 items-baseline gap-1.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 truncate",
|
||||||
|
size === "hero" ? "dui-hero" : "tabular-nums",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{numeric ? (
|
||||||
|
<motion.span>{label}</motion.span>
|
||||||
|
) : (
|
||||||
|
format(value, precision)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{unit ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 text-muted-foreground",
|
||||||
|
size === "hero" && "dui-hero-unit",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{unit}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Gauge({ value, min, max, precision, unit, label }: GaugeProps) {
|
||||||
|
const fraction = useAnimatedFraction(
|
||||||
|
value === null
|
||||||
|
? 0
|
||||||
|
: Math.min(1, Math.max(0, (value - min) / (max - min || 1))),
|
||||||
|
)
|
||||||
|
const { label: reading, numeric } = useAnimatedNumber(value, precision)
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 78"
|
||||||
|
className="h-full max-h-full w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label={`${label}: ${format(value, precision)}${unit ?? ""} of ${max}`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d={GAUGE_TRACK}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--muted)"
|
||||||
|
strokeWidth={9}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{/* The same full arc as the track, revealed rather than re-pathed: `d`
|
||||||
|
is not animatable, so a reading that redrew the arc could only
|
||||||
|
jump. `pathLength` normalises it, which makes the reveal the
|
||||||
|
fraction itself. */}
|
||||||
|
<motion.path
|
||||||
|
d={arcPath(GAUGE_START, GAUGE_START + GAUGE_SWEEP)}
|
||||||
|
style={{ pathLength: fraction }}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--primary)"
|
||||||
|
strokeWidth={9}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={50}
|
||||||
|
y={54}
|
||||||
|
// User units of the viewBox, not the text scale: the readout has to
|
||||||
|
// stay proportional to the dial at whatever size the tile is.
|
||||||
|
fontSize={13}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="fill-foreground tabular-nums"
|
||||||
|
>
|
||||||
|
{numeric ? (
|
||||||
|
<motion.tspan>{reading}</motion.tspan>
|
||||||
|
) : (
|
||||||
|
format(value, precision)
|
||||||
|
)}
|
||||||
|
{unit ?? ""}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ row }: { row: BarProps["rows"][number] }) {
|
||||||
|
const fraction = useAnimatedFraction(row.fraction)
|
||||||
|
const width = useTransform(fraction, (at) => `${at * 100}%`)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={TESTID.barRow}
|
||||||
|
role="img"
|
||||||
|
aria-label={`${row.label} ${format(row.value, row.precision)}${row.unit ?? ""}`}
|
||||||
|
className="dui-bar-row"
|
||||||
|
style={{ "--dui-fill": row.color } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<span className="dui-bar-label">{row.label}</span>
|
||||||
|
<span className="dui-bar-track fx-track">
|
||||||
|
<motion.span
|
||||||
|
data-testid={TESTID.barFill}
|
||||||
|
className="dui-bar-fill fx-fill"
|
||||||
|
style={{ width }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{/* 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. */}
|
||||||
|
<span className="dui-bar-value">
|
||||||
|
{format(row.value, row.precision)}
|
||||||
|
{row.unit ?? ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="dui-bar">
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
// Two rows can read the same message under different labels; position
|
||||||
|
// is the identity, as it is for chart series.
|
||||||
|
<Row key={`row-${index}`} row={row} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorDisk({
|
||||||
|
name,
|
||||||
|
hsv,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
onCommit,
|
||||||
|
}: ColorDiskProps) {
|
||||||
|
const { discProps, thumb, shade } = useColorDisk({
|
||||||
|
name,
|
||||||
|
hsv,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
onCommit,
|
||||||
|
})
|
||||||
|
const [hue, saturation, brightness] = hsv
|
||||||
|
return (
|
||||||
|
<div className="dui-color">
|
||||||
|
<div
|
||||||
|
{...discProps}
|
||||||
|
data-testid={TESTID.disc}
|
||||||
|
className="dui-disc"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--dui-shade": shade,
|
||||||
|
"--dui-sx": thumb.sx,
|
||||||
|
"--dui-sy": thumb.sy,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="dui-disc-shade" aria-hidden />
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-testid={TESTID.swatch}
|
||||||
|
className="dui-disc-thumb fx-disc-thumb"
|
||||||
|
style={{ background: cssOf(hsv) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
orientation="vertical"
|
||||||
|
value={brightness}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
unit="%"
|
||||||
|
label={`${name} brightness`}
|
||||||
|
disabled={disabled}
|
||||||
|
onCommit={(next) => {
|
||||||
|
onChange([hue, saturation, next])
|
||||||
|
onCommit()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
data-testid="canvas-ground"
|
||||||
|
className="pointer-events-none absolute inset-0 bg-cover bg-center"
|
||||||
|
style={{ backgroundImage: `url(${JSON.stringify(image)})` }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Issue({ issue }: { issue: string }) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
className="block size-2 shrink-0 rounded-full bg-destructive"
|
||||||
|
aria-label={issue}
|
||||||
|
data-testid={TESTID.issue}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs break-words">{issue}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
<div
|
||||||
|
data-testid={TESTID.frame}
|
||||||
|
data-selected={selected ? "" : undefined}
|
||||||
|
className="dui-frame fx-frame"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{title ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"dui-frame-head fx-frame-title",
|
||||||
|
grip &&
|
||||||
|
`${TESTID.grip} -m-1 cursor-grab p-1 active:cursor-grabbing`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="dui-frame-title">{title}</span>
|
||||||
|
{issue ? <Issue issue={issue} /> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={cn("dui-frame-body", scroll && "dui-frame-scroll")}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{/* 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 ? (
|
||||||
|
<div className="dui-frame-corner">
|
||||||
|
<Issue issue={issue} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* And the editor still needs something to drag it by. */}
|
||||||
|
{!title && grip ? (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
TESTID.grip,
|
||||||
|
"absolute left-1/2 top-1 z-[2] h-1 w-8 -translate-x-1/2 cursor-grab rounded-full bg-current opacity-20 active:cursor-grabbing",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<nav
|
||||||
|
aria-label="Dashboards on this panel"
|
||||||
|
data-testid={TESTID.rail}
|
||||||
|
className={cn(
|
||||||
|
"fx-rail pointer-events-auto absolute left-3 top-1/2 z-10 flex max-h-[calc(100%-1.5rem)] w-12 -translate-y-1/2 flex-col items-center gap-1 p-1",
|
||||||
|
// As tall as what it carries, centred in the column it reserves: a
|
||||||
|
// rail of two stretched to the height of the panel is mostly empty
|
||||||
|
// pill. More dashboards than the panel is tall still scroll, but no
|
||||||
|
// bar is ever drawn — a wall panel is swiped, and there is no room.
|
||||||
|
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const Glyph = ICONS[entry.icon ?? ""]
|
||||||
|
return (
|
||||||
|
<Tooltip key={entry.name}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Link
|
||||||
|
{...entry.link}
|
||||||
|
aria-label={entry.label}
|
||||||
|
aria-current={entry.active ? "page" : undefined}
|
||||||
|
data-testid={`panel-rail-${entry.name}`}
|
||||||
|
className="fx-rail-item relative flex size-10 shrink-0 items-center justify-center text-xs font-medium"
|
||||||
|
>
|
||||||
|
{entry.active ? (
|
||||||
|
<motion.span
|
||||||
|
aria-hidden
|
||||||
|
layoutId="fx-rail-active"
|
||||||
|
transition={LOOK.fluksio.spring}
|
||||||
|
className="absolute inset-0 rounded-full bg-accent"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="relative z-[1]">
|
||||||
|
{Glyph ? <Glyph className="size-5" /> : initials(entry.label)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{entry.label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Notice({ children }: NoticeProps) {
|
||||||
|
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={TESTID.locked}
|
||||||
|
className="fx-notice pointer-events-none absolute bottom-0 right-0 flex items-center gap-1.5 px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<Lock className="size-4" aria-hidden />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* The dashboard's components, in whichever look this dashboard wears.
|
* 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
|
* 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
|
* under `core/` and is shared, so there is nothing for a set to disagree
|
||||||
* about beyond markup and motion.
|
* about beyond markup and motion.
|
||||||
*/
|
*/
|
||||||
@@ -11,10 +11,11 @@ import "./core/core.css"
|
|||||||
import type { Look } from "../settings"
|
import type { Look } from "../settings"
|
||||||
import type { ComponentSet } from "./core/contract"
|
import type { ComponentSet } from "./core/contract"
|
||||||
import { useLook } from "./core/look"
|
import { useLook } from "./core/look"
|
||||||
|
import { fluksio } from "./fluksio"
|
||||||
import { glass } from "./glass"
|
import { glass } from "./glass"
|
||||||
import { material } from "./material"
|
import { material } from "./material"
|
||||||
|
|
||||||
const SETS: Record<Look, ComponentSet> = { glass, material }
|
const SETS: Record<Look, ComponentSet> = { fluksio, glass, material }
|
||||||
|
|
||||||
/** The components this dashboard is drawn with. */
|
/** The components this dashboard is drawn with. */
|
||||||
export const useUi = (): ComponentSet => SETS[useLook().look]
|
export const useUi = (): ComponentSet => SETS[useLook().look]
|
||||||
|
|||||||
@@ -626,14 +626,14 @@ async function setLook(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both looks, in both themes. A look is mostly colour and depth, so the four
|
// Every look, in both themes. A look is mostly colour and depth, so the
|
||||||
// screenshots are the assertion that neither collapses — and the checks around
|
// screenshots are the assertion that none of them collapses — and the checks
|
||||||
// them are that a look changes how the panel is drawn and nothing else.
|
// around them are that a look changes how the panel is drawn and nothing else.
|
||||||
for (const scheme of ["light", "dark"] as const) {
|
for (const scheme of ["light", "dark"] as const) {
|
||||||
test.describe(`${scheme} theme`, () => {
|
test.describe(`${scheme} theme`, () => {
|
||||||
test.use({ colorScheme: scheme })
|
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 }) => {
|
test(`the panel reads in ${scheme} ${look}`, async ({ page }) => {
|
||||||
await openPanel(page)
|
await openPanel(page)
|
||||||
await setLook(page, dashboardName, { look: { value: look } })
|
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`,
|
path: `screenshots/widgets/${scheme}-palette.png`,
|
||||||
})
|
})
|
||||||
await setLook(page, dashboardName, {
|
await setLook(page, dashboardName, {
|
||||||
look: { value: "material" },
|
look: { value: "fluksio" },
|
||||||
palette: { value: [] },
|
palette: { value: [] },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user