Files
musicmouse/web/src/styles/clock.css
Martin Bauer 701e90153c Clock: hand outlines drawn once, bubbles beside the watch
The hands' drop-shadow outline sat on their own compositor layers, so the GPU
recomputed it every frame a bubble moved. It is now on an inner box, painted
once into the hand's layer. The bubbles rise beside the watch, never behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:45:31 +02:00

560 lines
12 KiB
CSS

/* The clock overlay (ClockOverlay.tsx), ported from `claude-design/Delphin Uhr.dc.html`.
Sized in vmin like the design, so the same watch fills a 1080p kiosk and a phone held
upright. */
.clock-overlay {
--clock-ink: oklch(28% 0.04 220);
--clock-pink: oklch(70% 0.16 340);
position: absolute;
inset: 0;
z-index: 8;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
gap: 6vmin;
background: linear-gradient(
180deg,
oklch(55% 0.07 var(--hue)) 0%,
oklch(38% 0.06 var(--hue)) 45%,
oklch(20% 0.045 var(--hue)) 100%
);
cursor: pointer;
user-select: none;
will-change: opacity;
}
/* Hidden by opacity, not `display: none`: the watch stays styled, laid out and
rasterized, so Pos1 or the remote's Guide key shows it in the next frame instead of
after the Pi has painted every gradient of the dial from scratch. `display: flex`
above would otherwise beat the `hidden` attribute anyway. */
.clock-overlay[hidden] {
display: flex;
opacity: 0;
pointer-events: none;
}
/* Upright screens: the countdowns go under the watch instead of beside it. */
@media (orientation: portrait) {
.clock-overlay {
flex-direction: column;
gap: 4vmin;
}
}
.clock-badge {
position: absolute;
top: calc(18px + var(--safe-top));
left: 50%;
transform: translateX(-50%);
padding: 1.2vmin 3vmin;
border-radius: 999px;
font-size: max(16px, 3vmin);
font-weight: 900;
white-space: nowrap;
color: var(--clock-ink);
background: oklch(97% 0.01 var(--hue) / 0.75);
box-shadow: 0 1vmin 3vmin oklch(15% 0.05 var(--hue) / 0.3);
}
.clock-badge[data-school] {
background: oklch(92% 0.08 85 / 0.92);
}
/* ------------------------------------------------------------------- the watch */
.clock-watch {
position: relative;
flex: none;
width: 78vmin;
height: 96vmin;
}
.clock-strap {
position: absolute;
width: 15vmin;
height: 24vmin;
box-sizing: border-box;
border: 0.55vmin solid var(--clock-ink);
--strap: oklch(70% 0.16 340);
box-shadow: 0 3vmin 5vmin oklch(15% 0.05 210 / 0.35);
background: linear-gradient(
90deg,
var(--strap) 0%,
var(--strap) 70%,
oklch(from var(--strap) calc(l - 0.08) c h) 100%
);
}
.clock-strap[data-pos^="top"] {
top: 0;
border-radius: 2.4vmin 2.4vmin 0.8vmin 0.8vmin;
transform-origin: 50% 100%;
}
.clock-strap[data-pos^="bottom"] {
bottom: 0;
border-radius: 0.8vmin 0.8vmin 2.4vmin 2.4vmin;
transform-origin: 50% 0%;
}
.clock-strap[data-pos="top-left"] {
left: 16vmin;
transform: perspective(40vmin) rotateX(10deg) rotate(-8deg);
}
.clock-strap[data-pos="top-right"] {
right: 16vmin;
--strap: oklch(66% 0.12 205);
transform: perspective(40vmin) rotateX(10deg) rotate(8deg);
}
.clock-strap[data-pos="bottom-left"] {
left: 16vmin;
--strap: oklch(64% 0.11 175);
transform: perspective(40vmin) rotateX(-10deg) rotate(8deg);
}
.clock-strap[data-pos="bottom-right"] {
right: 16vmin;
--strap: oklch(58% 0.12 245);
transform: perspective(40vmin) rotateX(-10deg) rotate(-8deg);
}
.clock-button {
position: absolute;
left: 50%;
top: 50%;
width: 6vmin;
height: 10vmin;
margin: -5vmin 0 0 -3vmin;
box-sizing: border-box;
border-radius: 1.6vmin;
border: 0.55vmin solid var(--clock-ink);
}
.clock-button[data-hue="pink"] {
background: var(--clock-pink);
}
.clock-button[data-hue="205"] {
background: oklch(66% 0.12 205);
}
.clock-face {
position: absolute;
left: 0;
top: 9vmin;
width: 78vmin;
height: 78vmin;
box-sizing: border-box;
border-radius: 50%;
border: 0.6vmin solid var(--clock-ink);
/* The watch's shadow. It used to be a drop-shadow filter on the whole watch, which
made the Pi re-render and re-blur the entire dial on every tick of the second
hand. */
box-shadow: 0 3vmin 5vmin oklch(15% 0.05 210 / 0.5);
background:
radial-gradient(
circle closest-side,
transparent 88.6%,
var(--clock-ink) 88.6%,
var(--clock-ink) 89.6%,
transparent 89.6%
),
conic-gradient(
from 210deg,
oklch(94% 0.008 210),
oklch(81% 0.015 210),
oklch(96% 0.006 210),
oklch(79% 0.015 210),
oklch(94% 0.008 210)
);
}
.clock-face > * {
position: absolute;
}
/* The bezel's 48 dots: big coloured ones on the 5-minute marks, small grey between. */
.clock-dot {
left: 50%;
top: 50%;
width: 1.1vmin;
height: 1.1vmin;
border-radius: 50%;
background: oklch(62% 0.02 220);
box-shadow: inset 0 0 0 0.2vmin oklch(30% 0.03 220 / 0.35);
}
.clock-dot[data-big] {
width: 1.9vmin;
height: 1.9vmin;
}
.clock-dot[data-hue="205"] {
background: oklch(64% 0.12 205);
}
.clock-dot[data-hue="175"] {
background: oklch(64% 0.12 175);
}
.clock-dot[data-hue="245"] {
background: oklch(64% 0.12 245);
}
.clock-dot[data-hue="pink"] {
background: var(--clock-pink);
}
/* Every ring below is centred on the face; only the diameter differs. */
.clock-face-inner,
.clock-sections,
.clock-sections-gaps,
.clock-sections-rim,
.clock-center,
.clock-minute-track,
.clock-minute-ticks,
.clock-minute-ticks-major {
left: 50%;
top: 50%;
width: var(--d);
height: var(--d);
margin: calc(var(--d) / -2) 0 0 calc(var(--d) / -2);
box-sizing: border-box;
border-radius: 50%;
}
.clock-face-inner {
--d: 68.4vmin;
background: oklch(99% 0.005 210);
box-shadow: inset 0 0.6vmin 1.4vmin oklch(30% 0.03 220 / 0.18);
}
.clock-sections {
--d: 55.6vmin;
background: conic-gradient(
from -15deg,
oklch(91% 0.06 205) 0deg 30deg,
oklch(91% 0.06 175) 30deg 60deg,
oklch(91% 0.06 245) 60deg 90deg,
oklch(91% 0.07 345) 90deg 120deg,
oklch(91% 0.06 205) 120deg 150deg,
oklch(91% 0.06 175) 150deg 180deg,
oklch(91% 0.06 245) 180deg 210deg,
oklch(91% 0.07 345) 210deg 240deg,
oklch(91% 0.06 205) 240deg 270deg,
oklch(91% 0.06 175) 270deg 300deg,
oklch(91% 0.06 245) 300deg 330deg,
oklch(91% 0.07 345) 330deg 360deg
);
}
.clock-sections-gaps {
--d: 55.6vmin;
background: repeating-conic-gradient(
from 14.6deg,
oklch(99% 0.005 210) 0deg 0.8deg,
transparent 0.8deg 30deg
);
}
.clock-sections-rim {
--d: 55.6vmin;
border: 0.4vmin solid var(--clock-ink);
}
.clock-center {
--d: 20vmin;
background: oklch(99% 0.005 210);
box-shadow: 0 0 0 0.4vmin oklch(28% 0.04 220 / 0.25);
}
.clock-minute-track {
--d: 66.8vmin;
background: conic-gradient(
from -15deg,
oklch(80% 0.1 205) 0deg 30deg,
oklch(80% 0.1 175) 30deg 60deg,
oklch(80% 0.1 245) 60deg 90deg,
oklch(80% 0.13 345) 90deg 120deg,
oklch(80% 0.1 205) 120deg 150deg,
oklch(80% 0.1 175) 150deg 180deg,
oklch(80% 0.1 245) 180deg 210deg,
oklch(80% 0.13 345) 210deg 240deg,
oklch(80% 0.1 205) 240deg 270deg,
oklch(80% 0.1 175) 270deg 300deg,
oklch(80% 0.1 245) 300deg 330deg,
oklch(80% 0.13 345) 330deg 360deg
);
mask-image: radial-gradient(circle closest-side, transparent 82.8%, #000 83.2%);
}
.clock-minute-ticks {
--d: 66.8vmin;
background: repeating-conic-gradient(
from -0.5deg,
var(--clock-ink) 0deg 1deg,
transparent 1deg 6deg
);
mask-image: radial-gradient(circle closest-side, transparent 94.6%, #000 95%);
opacity: 0.7;
}
.clock-minute-ticks-major {
--d: 66.8vmin;
background: repeating-conic-gradient(
from -1.3deg,
var(--clock-ink) 0deg 2.6deg,
transparent 2.6deg 30deg
);
mask-image: radial-gradient(circle closest-side, transparent 93.2%, #000 93.6%);
}
.clock-numbers {
inset: 0;
font-weight: 900;
line-height: 1;
}
.clock-numbers > div {
position: absolute;
left: 50%;
top: 50%;
}
.clock-numbers-minutes {
font-size: 2.3vmin;
color: var(--clock-ink);
}
.clock-numbers-hours {
font-size: 8.2vmin;
letter-spacing: -0.3vmin;
-webkit-text-stroke: 0.45vmin var(--clock-ink);
paint-order: stroke fill;
}
.clock-numbers-hours > [data-hue="205"] {
color: oklch(56% 0.13 205);
}
.clock-numbers-hours > [data-hue="175"] {
color: oklch(56% 0.13 175);
}
.clock-numbers-hours > [data-hue="245"] {
color: oklch(56% 0.13 245);
}
.clock-numbers-hours > [data-hue="pink"] {
color: oklch(60% 0.19 345);
}
/* A hand is a box standing on the centre, rotated about its foot. */
.clock-hand {
left: 50%;
bottom: 50%;
transform-origin: 50% 100%;
/* Each hand on its own layer: turning it moves a finished picture on the GPU,
and the dial underneath is never repainted. */
will-change: transform;
}
/* The outline and shadow, on the hand's inner box rather than the hand: a filter on the
layer itself would be recomputed on the GPU every frame anything on screen moves (the
bubbles), while one inside it is drawn once into the layer's picture. The shape is
one level further in, so the outline goes round the clipped arrow, not its box. */
.clock-hand-hour > div,
.clock-hand-minute > div {
filter: drop-shadow(0.35vmin 0 0 var(--clock-ink)) drop-shadow(-0.35vmin 0 0 var(--clock-ink))
drop-shadow(0 0.35vmin 0 var(--clock-ink)) drop-shadow(0 -0.35vmin 0 var(--clock-ink))
drop-shadow(0 1vmin 1vmin oklch(15% 0.05 210 / 0.3));
}
.clock-hand > div {
position: absolute;
left: 0;
right: 0;
top: 0;
}
.clock-hand-hour {
width: 5.4vmin;
height: 15vmin;
margin-left: -2.7vmin;
}
.clock-hand-hour > div,
.clock-hand-minute > div {
bottom: -3.5vmin;
}
.clock-hand-hour > div > div,
.clock-hand-minute > div > div {
position: absolute;
inset: 0;
}
.clock-hand-hour > div > div {
background: oklch(92% 0.06 345);
clip-path: polygon(50% 0%, 100% 22%, 66% 22%, 66% 100%, 34% 100%, 34% 22%, 0% 22%);
}
.clock-hand-minute {
width: 5vmin;
height: 24vmin;
margin-left: -2.5vmin;
}
.clock-hand-minute > div > div {
background: oklch(92% 0.05 200);
clip-path: polygon(50% 0%, 100% 14%, 64% 14%, 64% 100%, 36% 100%, 36% 14%, 0% 14%);
}
.clock-hand-second {
width: 0.7vmin;
height: 27vmin;
margin-left: -0.35vmin;
}
.clock-hand-second > div {
bottom: -5vmin;
border-radius: 1vmin;
background: oklch(64% 0.19 345);
}
.clock-pin {
left: 50%;
top: 50%;
width: 4.2vmin;
height: 4.2vmin;
margin: -2.1vmin 0 0 -2.1vmin;
border-radius: 50%;
background: oklch(80% 0.1 205);
box-shadow: 0 0 0 0.5vmin var(--clock-ink);
}
.clock-pin-dot {
left: 50%;
top: 50%;
width: 1.4vmin;
height: 1.4vmin;
margin: -0.7vmin 0 0 -0.7vmin;
border-radius: 50%;
background: var(--clock-ink);
}
/* -------------------------------------------------------------- the countdowns */
.clock-countdowns {
position: relative;
flex: 0 1 74vmin;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3vmin;
}
@media (orientation: portrait) {
.clock-countdowns {
flex: none;
width: 88vw;
}
}
.clock-card {
--clock-bar: oklch(66% 0.12 205);
--clock-time: oklch(30% 0.05 210);
display: flex;
align-items: center;
gap: 3.5vmin;
padding: 3vmin 4vmin 3vmin 3vmin;
border-radius: 4.5vmin;
background: oklch(97% 0.01 210 / 0.68);
backdrop-filter: blur(10px);
box-shadow: 0 2vmin 5vmin oklch(15% 0.05 210 / 0.35);
}
[data-blur="off"] .clock-card {
backdrop-filter: none;
background: oklch(97% 0.01 210 / 0.88);
}
.clock-card[data-soon] {
--clock-bar: var(--clock-pink);
--clock-time: oklch(58% 0.19 345);
}
.clock-card[data-ringing] {
animation: clockRing 1.2s ease-in-out infinite;
}
@keyframes clockRing {
0%,
100% {
background: oklch(97% 0.01 210 / 0.68);
transform: scale(1);
}
50% {
background: oklch(88% 0.09 345 / 0.92);
transform: scale(1.03);
}
}
.clock-card-image {
width: 24vmin;
height: 18vmin;
flex: none;
display: flex;
align-items: center;
justify-content: center;
}
.clock-card-image > img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
filter: drop-shadow(0 1vmin 1.5vmin oklch(15% 0.05 210 / 0.3));
}
.clock-card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.6vmin;
}
.clock-card-time {
font-size: 15vmin;
font-weight: 900;
line-height: 0.95;
font-variant-numeric: tabular-nums;
letter-spacing: -0.3vmin;
color: var(--clock-time);
}
.clock-card-bar {
height: 2vmin;
margin-top: 0.8vmin;
border-radius: 999px;
overflow: hidden;
background: oklch(30% 0.04 210 / 0.15);
}
.clock-card-bar > div {
height: 100%;
border-radius: 999px;
background: var(--clock-bar);
transition: width 0.25s linear;
}
@media (prefers-reduced-motion: reduce) {
.clock-card[data-ringing] {
animation: none;
background: oklch(88% 0.09 345 / 0.92);
}
}