Files
app/frontend/src/components/Dashboard/ui/glass/glass.css
T
stroblmeandClaude Opus 5 7f78d541ab Let a widget's glow fade out instead of stopping at the tile's edge
`overflow: hidden` on the frame and its body cut a control's 22px
`--gl-glow` dead at the clip edge, and the gauge's own `<svg>` cut its
drop-shadow at the viewBox — the arc's top sits ~3.5 units from it.
`overflow: clip` with a margin wide enough for the blur (1.5rem on the
body, which already sits 1rem inside the frame; 0.5rem is all that leaves
the tile, which is the gutter).

That means the body no longer scrolls by default. `.dui-frame-scroll` was
already wired through from `scrolls()` into every look's Surfaces with no
CSS rule behind it, so the class is what opts markdown, agenda and
notification back into a real scroller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYM38KSb4V4v2T71eifnZv
2026-08-30 16:16:06 +02:00

418 lines
10 KiB
CSS

/*
* 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 ------------------------------------------------------------ */
/* No `position` of its own: this is worn by a tile, a rail and a notice alike,
and each of those is placed by whatever puts it there. Stating it here won
over the utility that placed them — an unlayered rule beats a layered one —
and collapsed the rail into the flow. */
.gl-surface {
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;
/* `clip` rather than `hidden`: the press ripple below is inset 0 and rounds
with the surface regardless, but a segment's `--gl-glow` thumb sits inside
this box too and needs room to fade rather than being cut off at it. */
overflow: clip;
overflow-clip-margin: 1.5rem;
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;
}
/* Lifted one at a time rather than by a blanket rule over every child: that
one restated `position` on things already placed — a sliding pill, a switch's
thumb — and dropped them back into the flow. */
.gl-switch-thumb,
.gl-segment-label,
.gl-lift {
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 .dui-slider-rail {
border-radius: 9999px;
background: var(--gl-track);
box-shadow: var(--gl-inset);
}
.gl-slider .dui-slider-fill {
border-radius: 9999px;
background: color-mix(in srgb, var(--primary) 85%, white);
box-shadow: var(--gl-glow);
}
.gl-slider .dui-slider-thumb {
background: #fff;
border: 1px solid var(--gl-border);
box-shadow: 0 2px 8px rgb(0 0 0 / 0.35);
}
.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(--primary-foreground);
}
.gl-segment-thumb {
position: absolute;
inset: 0;
z-index: 0;
border-radius: 9999px;
background: color-mix(in srgb, var(--primary) 85%, transparent);
box-shadow: var(--gl-glow), var(--gl-inset);
}
.gl-segment-label {
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;
}