3 Commits

Author SHA1 Message Date
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
e94e39addc web README: installing the parents' app
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:19:54 +02:00
8ddfafebcb PWA: a home-screen install from ?parentMode=1 keeps parent mode
iOS starts an installed web app at the manifest's start_url ("/"), so the
query parameter was dropped. Opened with ?parentMode=1, the page now points at
a second manifest whose start_url keeps it, and names the app "Musik Eltern".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:19:42 +02:00
5 changed files with 82 additions and 11 deletions

View File

@@ -154,8 +154,13 @@ service worker in `public/sw.js`. On iOS, Share → *Zum Home-Bildschirm*; it th
full-screen with no Safari chrome, because iOS reads the `apple-mobile-web-app-*` meta
tags in `index.html` rather than the manifest.
Two things worth knowing:
A few things worth knowing:
- **Install from `?parentMode=1` to get a parents' app.** iOS starts an installed app at
the manifest's `start_url`, not at the address it was added from, so the plain
manifest's `/` would drop the flag. Opened with it, `index.html` swaps in
`public/manifest-parents.webmanifest` (`start_url: /?parentMode=1`, named "Musik
Eltern"). An app installed before this change has to be removed and added again.
- **The service worker only registers over HTTPS or on `localhost`.** At
`http://<mouse>:8080` on the LAN, iOS will still install the icon and run it
standalone - that part is the meta tags - but there is no offline caching. Put the

View File

@@ -15,10 +15,23 @@
<meta name="theme-color" content="#0d2f3c" />
<meta name="description" content="Musik und Hörbücher für die MusicMouse" />
<!-- iOS does not read the manifest for home-screen installs; these are what it uses. -->
<!-- iOS takes start_url from the manifest, but the title, icon and status bar from
these tags. -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Musik" />
<!-- A home-screen install starts at the manifest's start_url, not at the address it
was added from, so `?parentMode=1` would be lost. Opened with it, the page offers
a second manifest whose start_url keeps it - "Add to Home Screen" from a parent's
URL installs the parents' app, under its own name. Inline, so the tags are
switched before anything reads them. -->
<script>
if (new URLSearchParams(location.search).get("parentMode") === "1") {
document.querySelector('link[rel="manifest"]').href = "/manifest-parents.webmanifest";
document.querySelector('meta[name="apple-mobile-web-app-title"]').content = "Musik Eltern";
document.title = "Musik Delfin Eltern";
}
</script>
<!-- An opaque status bar, so the page starts below it - not black-translucent. That
one ran the page under the clock, which put every top-anchored control behind
it unless each remembered its inset, and since iOS 26 it also sizes the app one

View File

@@ -0,0 +1,33 @@
{
"name": "Musik Delfin Eltern",
"short_name": "Musik Eltern",
"description": "Musik und Hörbücher für die MusicMouse",
"start_url": "/?parentMode=1",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0d2f3c",
"theme_color": "#0d2f3c",
"lang": "de",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"id": "/?parentMode=1"
}

View File

@@ -19,13 +19,16 @@ import { SKIN } from "../lib/skins";
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
import { Drift, type DriftField } from "./Drift";
/** Beside the watch only, never behind it: on a 1080p kiosk the watch covers roughly
* 28-72% of the width, and a bubble rising behind it would drag the watch into every
* frame the Pi redraws. */
const BUBBLES: DriftField = [
[5, 14, 0.5, 9, 0],
[12, 22, 0.5, 12, -3],
[17, 16, 0.5, 11, -2],
[22, 10, 0.5, 8, -6],
[48, 16, 0.5, 11, -2],
[63, 12, 0.5, 10, -7],
[78, 24, 0.5, 14, -5],
[83, 12, 0.5, 10, -7],
[88, 12, 0.5, 9, -1],
[95, 18, 0.5, 13, -9],
];
@@ -69,10 +72,14 @@ export function ClockOverlay({ visible, clock, onClose }: Props) {
<div className="clock-face">
<Dial />
<div className="clock-hand clock-hand-hour" style={{ transform: `rotate(${angles.hour}deg)` }}>
<div />
<div>
<div />
</div>
</div>
<div className="clock-hand clock-hand-minute" style={{ transform: `rotate(${angles.minute}deg)` }}>
<div />
<div>
<div />
</div>
</div>
<div className="clock-hand clock-hand-second" style={{ transform: `rotate(${angles.second}deg)` }}>
<div />

View File

@@ -362,8 +362,12 @@
will-change: transform;
}
.clock-hand-hour,
.clock-hand-minute {
/* 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));
@@ -382,8 +386,18 @@
margin-left: -2.7vmin;
}
.clock-hand-hour > div {
.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%);
}
@@ -394,8 +408,7 @@
margin-left: -2.5vmin;
}
.clock-hand-minute > div {
bottom: -3.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%);
}