Rework the dashboard into two looks over one behaviour
A dashboard is a wall panel somebody hangs in their own hallway, so it now wears what they choose: a look, and a palette of their own colours. Two complete component sets live under `Dashboard/ui/` — `glass` (translucent panes over a slowly moving ground) and `material` (Material 3 tonal cards) — behind one prop contract. Every control's state, keyboard and `aria-` live in `ui/core` and are shared, so the two sets are the same dashboard drawn twice rather than two products: a set only decides what a control looks like while doing it. Four settings join the channel, each drivable by a flow like any other: `look`, `palette`, `background` and `touch`. A palette is an ordered list of hex colours — background, surface, primary, accent, text, then more chart colours — pasted from a coolors.co link or typed, written onto the canvas as the token variables everything already reads. Trailing roles are derived, so three colours are a whole dashboard, and derived text is held to AA rather than trusted (`theme.check.ts` measures it). A palette also decides light or dark, since its first colour is the ground. Widgets are measured against their own tile with container queries rather than against the viewport, animate through `motion`, and can be drawn without their title. The three reworks: - a bar draws a row per reading, up to eight, each in the dashboard's own data colours and each able to carry its own scale — replacing readings nested in one fill, which could only ever share one colour and stop at three. Documents written the old way are read as rows. - a chart's range picker moved to a column down its right-hand edge, which gives the plot back a whole row of a short tile. - the colour wheel became a disc: hue is the angle and saturation the distance from the middle, so a colour is one gesture rather than three, with brightness on a slider beside it. `index.css` and `lib/motion.ts` are untouched — the dashboard overrides token *values* on its canvas, never the blocks the two repos share.
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
/*
|
||||
* Liquid glass.
|
||||
*
|
||||
* Every surface is the same recipe: a thin wash of white, the ground behind it
|
||||
* blurred, a hairline that catches the light, and a highlight falling from the
|
||||
* top edge. It only reads as glass when there is something behind it to
|
||||
* refract, which is why this look brings its own ground — two soft drifting
|
||||
* blobs of the dashboard's own colours, or the image it was given.
|
||||
*
|
||||
* Dark is the natural direction for it. In light the same wash would be white
|
||||
* on white, so the mixture goes the other way: more opaque, brighter edges,
|
||||
* and a shadow soft enough not to read as a border.
|
||||
*
|
||||
* Paint only; the measurements are in `../core/core.css`.
|
||||
*/
|
||||
|
||||
[data-look="glass"] {
|
||||
--gl-bg: rgb(255 255 255 / 0.1);
|
||||
--gl-bg-strong: rgb(255 255 255 / 0.18);
|
||||
--gl-border: rgb(255 255 255 / 0.2);
|
||||
--gl-highlight: rgb(255 255 255 / 0.22);
|
||||
--gl-inset: inset 0 1px 1px rgb(255 255 255 / 0.1);
|
||||
--gl-shadow: 0 8px 32px rgb(0 0 0 / 0.37);
|
||||
--gl-blur: 24px;
|
||||
--gl-glow: 0 0 22px color-mix(in srgb, var(--primary) 45%, transparent);
|
||||
--gl-radius: 1.25rem;
|
||||
--gl-track: rgb(255 255 255 / 0.16);
|
||||
--gl-blob-opacity: 0.5;
|
||||
}
|
||||
|
||||
[data-look="glass"].light {
|
||||
--gl-bg: rgb(255 255 255 / 0.5);
|
||||
--gl-bg-strong: rgb(255 255 255 / 0.7);
|
||||
--gl-border: rgb(255 255 255 / 0.75);
|
||||
--gl-highlight: rgb(255 255 255 / 0.6);
|
||||
--gl-shadow: 0 8px 32px rgb(0 0 0 / 0.12);
|
||||
--gl-track: rgb(0 0 0 / 0.1);
|
||||
/* Diluted by the white it mixes into, so it takes more of it to show. */
|
||||
--gl-blob-opacity: 0.6;
|
||||
}
|
||||
|
||||
/* --- the ground ---------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* Glass has nothing to be glass against on a flat white page: a translucent
|
||||
* white tile over a white ground is a shadow and nothing else. So the look
|
||||
* brings a ground of its own — a wash of the dashboard's primary, deepest at
|
||||
* the top where a room's light would come from — and the tiles then sit
|
||||
* *brighter* than what is behind them, which is what reads as a pane.
|
||||
*/
|
||||
.gl-ground {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
120% 90% at 50% 0%,
|
||||
color-mix(in srgb, var(--primary) 14%, var(--background)),
|
||||
var(--background) 70%
|
||||
);
|
||||
}
|
||||
|
||||
.gl-blob {
|
||||
position: absolute;
|
||||
width: 55%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
/* A gradient that falls off rather than a blurred circle: `filter: blur()`
|
||||
on something this size costs a full-screen pass every frame, and a panel
|
||||
on the wall is often the slowest machine in the house. */
|
||||
background: radial-gradient(
|
||||
circle closest-side,
|
||||
var(--gl-blob, var(--primary)),
|
||||
transparent 100%
|
||||
);
|
||||
opacity: var(--gl-blob-opacity, 0.55);
|
||||
}
|
||||
|
||||
.gl-blob:nth-child(1) {
|
||||
left: -15%;
|
||||
top: -25%;
|
||||
--gl-blob: var(--primary);
|
||||
}
|
||||
|
||||
.gl-blob:nth-child(2) {
|
||||
right: -20%;
|
||||
top: 5%;
|
||||
--gl-blob: var(--accent);
|
||||
}
|
||||
|
||||
.gl-blob:nth-child(3) {
|
||||
left: 25%;
|
||||
bottom: -35%;
|
||||
opacity: calc(var(--gl-blob-opacity, 0.55) * 0.65);
|
||||
--gl-blob: var(--primary);
|
||||
}
|
||||
|
||||
/* --- surfaces ------------------------------------------------------------ */
|
||||
|
||||
.gl-surface {
|
||||
position: relative;
|
||||
border: 1px solid var(--gl-border);
|
||||
border-radius: var(--gl-radius);
|
||||
background: var(--gl-bg);
|
||||
box-shadow: var(--gl-shadow), var(--gl-inset);
|
||||
backdrop-filter: blur(var(--gl-blur));
|
||||
-webkit-backdrop-filter: blur(var(--gl-blur));
|
||||
}
|
||||
|
||||
/*
|
||||
* The light on the pane: a bright line along the top edge where it catches,
|
||||
* and a short wash falling from it. Carrying the wash the whole way down reads
|
||||
* as a gradient fill rather than as glass — the edge is what does the work.
|
||||
*/
|
||||
.gl-surface::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to bottom, var(--gl-highlight), transparent 30%);
|
||||
opacity: 0.55;
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.28);
|
||||
}
|
||||
|
||||
.gl-frame > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gl-frame[data-selected] {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.gl-frame-title {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.gl-rail {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.gl-notice {
|
||||
border-radius: 9999px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* --- the glow a press leaves --------------------------------------------- */
|
||||
|
||||
.gl-pressable {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.gl-pressable > .gl-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
background: radial-gradient(
|
||||
circle at var(--press-x, 50%) var(--press-y, 50%),
|
||||
rgb(255 255 255 / 0.35),
|
||||
transparent 60%
|
||||
);
|
||||
transition: opacity var(--duration-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.gl-pressable:hover > .gl-glow,
|
||||
.gl-pressable:focus-visible > .gl-glow {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.gl-pressable:active > .gl-glow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-touch] .gl-pressable:hover > .gl-glow {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.gl-pressable > :not(.gl-glow) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* --- controls ------------------------------------------------------------ */
|
||||
|
||||
.gl-button {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
min-height: var(--dui-control);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--gl-border);
|
||||
border-radius: 9999px;
|
||||
padding-inline: 1.25rem;
|
||||
font-size: var(--dui-text);
|
||||
font-weight: 500;
|
||||
background: var(--gl-bg);
|
||||
color: var(--foreground);
|
||||
box-shadow: var(--gl-inset);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.gl-button[data-variant="filled"],
|
||||
.gl-button[aria-pressed="true"] {
|
||||
background: color-mix(in srgb, var(--primary) 78%, transparent);
|
||||
border-color: color-mix(in srgb, var(--primary) 55%, white);
|
||||
color: var(--primary-foreground);
|
||||
box-shadow: var(--gl-glow), var(--gl-inset);
|
||||
}
|
||||
|
||||
.gl-button[data-variant="text"] {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.gl-button:disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gl-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: calc(var(--dui-control) * 1.75);
|
||||
height: var(--dui-control);
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
border: 1px solid var(--gl-border);
|
||||
border-radius: 9999px;
|
||||
padding: 0.1875rem;
|
||||
background: var(--gl-track);
|
||||
box-shadow: var(--gl-inset);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.gl-switch[data-state="on"] {
|
||||
justify-content: flex-end;
|
||||
background: color-mix(in srgb, var(--primary) 70%, transparent);
|
||||
box-shadow: var(--gl-glow), var(--gl-inset);
|
||||
}
|
||||
|
||||
.gl-switch-thumb {
|
||||
display: block;
|
||||
height: calc(var(--dui-control) - 0.5rem);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
.gl-switch:disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gl-slider {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gl-slider input {
|
||||
width: 100%;
|
||||
height: var(--dui-control);
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gl-slider input::-webkit-slider-runnable-track {
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
box-shadow: var(--gl-inset);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
color-mix(in srgb, var(--primary) 85%, white)
|
||||
calc(var(--dui-fraction, 0) * 100%),
|
||||
var(--gl-track) calc(var(--dui-fraction, 0) * 100%)
|
||||
);
|
||||
}
|
||||
|
||||
.gl-slider input[data-orientation="vertical"]::-webkit-slider-runnable-track {
|
||||
width: 0.5rem;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
color-mix(in srgb, var(--primary) 85%, white)
|
||||
calc(var(--dui-fraction, 0) * 100%),
|
||||
var(--gl-track) calc(var(--dui-fraction, 0) * 100%)
|
||||
);
|
||||
}
|
||||
|
||||
.gl-slider input::-moz-range-track {
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--gl-track);
|
||||
}
|
||||
|
||||
.gl-slider input::-moz-range-progress {
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--primary) 85%, white);
|
||||
}
|
||||
|
||||
.gl-slider input::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: var(--dui-thumb);
|
||||
height: var(--dui-thumb);
|
||||
margin-top: calc((0.5rem - var(--dui-thumb)) / 2);
|
||||
border: 1px solid var(--gl-border);
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
.gl-slider input::-moz-range-thumb {
|
||||
width: var(--dui-thumb);
|
||||
height: var(--dui-thumb);
|
||||
border: 1px solid var(--gl-border);
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.gl-slider input:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.gl-ticks {
|
||||
position: relative;
|
||||
height: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.gl-segmented {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
border: 1px solid var(--gl-border);
|
||||
border-radius: 9999px;
|
||||
padding: 0.25rem;
|
||||
background: var(--gl-bg);
|
||||
box-shadow: var(--gl-inset);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.gl-segmented[data-orientation="vertical"] {
|
||||
height: 100%;
|
||||
grid-auto-flow: row;
|
||||
grid-auto-rows: minmax(0, 1fr);
|
||||
border-radius: 1.25rem;
|
||||
}
|
||||
|
||||
.gl-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);
|
||||
}
|
||||
|
||||
.gl-segment[data-active] {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.gl-segment-thumb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: 9999px;
|
||||
background: var(--gl-bg-strong);
|
||||
box-shadow: var(--gl-inset);
|
||||
}
|
||||
|
||||
.gl-segment-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gl-field {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: var(--dui-control);
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--gl-border);
|
||||
border-radius: 0.875rem;
|
||||
background: var(--gl-bg);
|
||||
padding-inline: 0.875rem;
|
||||
font-size: var(--dui-text);
|
||||
color: var(--foreground);
|
||||
text-align: left;
|
||||
box-shadow: var(--gl-inset);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.gl-field::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.gl-field:disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gl-menu {
|
||||
border: 1px solid var(--gl-border);
|
||||
border-radius: 0.875rem;
|
||||
background: color-mix(in srgb, var(--popover) 75%, transparent);
|
||||
color: var(--popover-foreground);
|
||||
box-shadow: var(--gl-shadow), var(--gl-inset);
|
||||
backdrop-filter: blur(var(--gl-blur));
|
||||
-webkit-backdrop-filter: blur(var(--gl-blur));
|
||||
}
|
||||
|
||||
/* --- readings ------------------------------------------------------------ */
|
||||
|
||||
.gl-track {
|
||||
border-radius: 9999px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dui-fill, var(--primary)) 22%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: var(--gl-inset);
|
||||
}
|
||||
|
||||
.gl-fill {
|
||||
border-radius: 9999px;
|
||||
background: var(--dui-fill, var(--primary));
|
||||
box-shadow: 0 0 14px
|
||||
color-mix(in srgb, var(--dui-fill, var(--primary)) 55%, transparent);
|
||||
}
|
||||
|
||||
.gl-disc-thumb {
|
||||
border: 2px solid rgb(255 255 255 / 0.85);
|
||||
box-shadow: 0 2px 10px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
/* --- focus --------------------------------------------------------------- */
|
||||
|
||||
[data-look="glass"]
|
||||
:is(.gl-button, .gl-switch, .gl-segment, .gl-field, .dui-disc):focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
Reference in New Issue
Block a user