Clock: a "Delphin Uhr" overlay with the school bus and a parents' timer

Einfg on the kiosk keyboard, or Guide (KEY_EPG) on the IR remote, shows a big
analog watch over whatever page is open, built from the Delphin Uhr mockup
without its swimming dolphin. The overlay stays mounted and is only hidden, so it
appears at once. Beside the watch, only while they run:

- The bus: on school days, a silent countdown to `general.clock.bus_time` (07:25)
  that starts `bus_lead_minutes` (60) before. School days skip weekends and the
  school and public holidays of `general.clock.subdivision` (DE-BY), fetched daily
  from openholidaysapi.org and cached under the library cache. A badge on the
  clock says whether there is school today, and why not.
- The dolphin timer, set in parent mode (5/10/15/30 minutes, or until a time).
  It lives in the backend, so a parent's phone can start it for the kiosk. When it
  runs out the kiosk opens the clock at 00:00 and a soft bell chimes on the
  device; it clears after a minute, or at the next key, tap or remote press.

Parent mode is now a way in rather than a page: `?parentMode=1` adds a 🔧 button
next to the ⌨ one, or an "Eltern" tab in the phone's dock, so the URL can stay
bookmarked. The timer comes first there, the device settings below it.

Unmapped IR buttons are logged at debug level, to find out what irserver calls a
new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 19:45:45 +02:00
parent 30065bb9e2
commit a55cc4f67f
40 changed files with 2723 additions and 185 deletions

533
web/src/styles/clock.css Normal file
View File

@@ -0,0 +1,533 @@
/* 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;
}
/* `display: flex` above would otherwise beat the `hidden` attribute. */
.clock-overlay[hidden] {
display: 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 + env(safe-area-inset-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;
filter: drop-shadow(0 3vmin 5vmin oklch(15% 0.05 210 / 0.5));
}
.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);
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);
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%;
}
.clock-hand-hour,
.clock-hand-minute {
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 {
bottom: -3.5vmin;
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 {
bottom: -3.5vmin;
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);
}
}