9 Commits

Author SHA1 Message Date
cf38ae6fe2 Room page: colour swatches send their colour, not white
oklchToRgb read the colour back from the browser's CSS engine and expected
rgb(...). Current Chromium (the kiosk's 153) resolves an oklch colour to
oklch(...), so the match failed and every swatch fell back to [255, 255, 255]:
tapping a colour set the light to white. It now does the OKLab maths itself.

Also log a Home Assistant call that fails in the proxy: httpx only logs a
request once it has a response, so a call that timed out left no trace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:24:02 +02:00
de94bd4118 Room page: taps stay visible, swatches match, an unreachable blind says so
- A tapped light or blind state now holds for 4s against polled state: a poll
  that starts just after the tap can still return the device's old state
  (a Hue light reports about a second after HA answers), which flipped the
  card back and looked like the tap had failed.
- Swatches match what a light reports, not only what it was sent: a Hue
  light hands rgb_color back at full intensity, so the exact comparison never
  showed a swatch as selected.
- The Rollo card says "Nicht erreichbar" and disables its buttons while HA has
  the cover as unavailable, instead of showing "Offen"; up/down show the
  blind as moving at once, so they are not pressed again and again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:23:55 +02:00
13d14c00aa Clock: bubbles on the cards' side while a countdown is showing
With countdown cards the watch moves left and covers roughly 6-49% of the
width, where the plain field's bubbles would rise behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:48:02 +02:00
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
17db811bba Clock: show it at once and render it cheaply; Guide is KEY_GUIDE
The remote's hauppauge.rem names the Guide button KEY_GUIDE, not lircd's
KEY_EPG, so the remote never toggled the clock.

The overlay is now hidden by opacity rather than display: none, so it is
already laid out and painted when Pos1 or Guide shows it. The drop-shadow
filter on the whole watch is gone (it re-rendered and re-blurred the dial on
every second-hand tick), the hands sit on their own layers, and the bubbles
and countdown cards only exist while the clock is on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:59:06 +02:00
b56b41996b Clock: toggle with Pos1 instead of Einfg
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:36:33 +02:00
7b9e072713 Browse: scroll the root shelves again, just without a scrollbar
Hiding the vertical scrollbar with overflow: hidden also stopped the wheel from
scrolling. Keep overflow: auto and hide only the bar, as the shelf rows already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:16:29 +02:00
20 changed files with 309 additions and 60 deletions

View File

@@ -111,7 +111,7 @@ general:
# lircd itself is set up on the Pi). Omit the whole section to run without a remote. # lircd itself is set up on the Pi). Omit the whole section to run without a remote.
# Play/pause/stop/previous/forward/rewind/volume/mute map to normal music control; # Play/pause/stop/previous/forward/rewind/volume/mute map to normal music control;
# number keys 0-9 play whatever the "remote:" section below assigns them; Guide # number keys 0-9 play whatever the "remote:" section below assigns them; Guide
# (KEY_EPG) shows the kiosk's clock. Run with --log-level DEBUG to see the name of a # (KEY_GUIDE) shows the kiosk's clock. Run with --log-level DEBUG to see the name of a
# button that does nothing. # button that does nothing.
lirc: lirc:
host: "musicmouse-pi.local" host: "musicmouse-pi.local"
@@ -136,7 +136,7 @@ general:
# auto_off_minutes: 15 # auto_off_minutes: 15
# timeout: 5.0 # seconds before a hung command is killed # timeout: 5.0 # seconds before a hung command is killed
# The clock overlay (Einfg on the kiosk keyboard, Guide on the remote). Optional; these # The clock overlay (Pos1 on the kiosk keyboard, Guide on the remote). Optional; these
# are the defaults. On school days a bus countdown shows for bus_lead_minutes before # are the defaults. On school days a bus countdown shows for bus_lead_minutes before
# bus_time. School days skip weekends and the school and public holidays of # bus_time. School days skip weekends and the school and public holidays of
# `subdivision`, fetched daily from openholidaysapi.org and cached under # `subdivision`, fetched daily from openholidaysapi.org and cached under

View File

@@ -47,8 +47,10 @@ _TRANSPORT: dict[str, Callable[[], IntentEvent]] = {
"KEY_FORWARD": lambda: NextTrackRequested(source="lirc"), "KEY_FORWARD": lambda: NextTrackRequested(source="lirc"),
# Does nothing unless general.screen is configured - see ScreenService. # Does nothing unless general.screen is configured - see ScreenService.
"KEY_POWER": lambda: ToggleScreenRequested(source="lirc"), "KEY_POWER": lambda: ToggleScreenRequested(source="lirc"),
# "Guide": what time is it. Shows the kiosk's clock overlay. # "Guide": what time is it. Shows the kiosk's clock overlay. KEY_GUIDE is what
"KEY_EPG": lambda: ToggleClockRequested(source="lirc"), # irserver's hauppauge.rem calls it (the ansible repo); lircd's own Hauppauge
# config says KEY_EPG.
"KEY_GUIDE": lambda: ToggleClockRequested(source="lirc"),
} }
#: Acted on every repeat, for a continuous ramp while held - same feel as the rotary #: Acted on every repeat, for a continuous ramp while held - same feel as the rotary

View File

@@ -376,6 +376,10 @@ def build_router(
method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs
) )
except httpx2.HTTPError as exc: except httpx2.HTTPError as exc:
# httpx logs a request only once it has a response, so without this a call
# that times out - a Homematic command the device never acknowledges can
# keep Home Assistant from answering - leaves no trace at all.
_log.warning("Home Assistant %s %s failed: %r", method, path, exc)
raise HTTPException( raise HTTPException(
status_code=502, detail=f"Home Assistant unreachable: {exc}" status_code=502, detail=f"Home Assistant unreachable: {exc}"
) from exc ) from exc

View File

@@ -328,8 +328,8 @@ async def test_the_guide_key_asks_for_the_clock(
task = await _run_service(sim, _lirc_config(port)) task = await _run_service(sim, _lirc_config(port))
try: try:
await station.wait_connected() await station.wait_connected()
await station.send("b7c0000000000000 00 key_epg hauppauge") await station.send("b7c0000000000000 00 key_guide hauppauge")
await station.send("b7c0000000000000 00 key_epg hauppauge") # still held await station.send("b7c0000000000000 00 key_guide hauppauge") # still held
await sim.bus.drain() await sim.bus.drain()
finally: finally:
task.cancel() task.cancel()

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 full-screen with no Safari chrome, because iOS reads the `apple-mobile-web-app-*` meta
tags in `index.html` rather than the manifest. 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 - **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 `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 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="theme-color" content="#0d2f3c" />
<meta name="description" content="Musik und Hörbücher für die MusicMouse" /> <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="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Musik" /> <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 <!-- 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 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 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

@@ -190,14 +190,13 @@ function BrowseViewImpl({
/> />
<div <div
ref={scroller} ref={scroller}
className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined} className={`no-scrollbar${ANIMATE_VIEW_TRANSITIONS ? " view-enter" : ""}`}
// Right padding reserves room for the tab rail's own glass bar (see // Right padding reserves room for the tab rail's own glass bar (see
// TAB_RAIL_CLEARANCE) so a full-width shelf row never renders under it. The // TAB_RAIL_CLEARANCE) so a full-width shelf row never renders under it. The
// three rows fit the screen, so no vertical scrollbar: each row scrolls // vertical scrollbar is hidden, but the wheel and touch still scroll.
// sideways on its own.
style={{ style={{
flex: 1, flex: 1,
overflow: "hidden", overflow: "auto",
minHeight: 0, minHeight: 0,
padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px`, padding: `14px ${TAB_RAIL_CLEARANCE}px 250px 32px`,
}} }}

View File

@@ -4,9 +4,11 @@
* minus the mascot swimming through the scene (too distracting for a clock you read * minus the mascot swimming through the scene (too distracting for a clock you read
* at a glance). * at a glance).
* *
* Mounted for as long as the app is, and only hidden: Einfg and the remote's Guide key * Mounted for as long as the app is, and only hidden (by opacity, see clock.css): Pos1
* have to show it at once, not after a first render. The second ticker only runs while * and the remote's Guide key have to show it at once, not after a first render and
* it is on screen. Any click closes it. */ * paint. What moves - the second ticker, the bubbles, a ringing countdown card - only
* exists while it is on screen, so a hidden clock costs nothing per frame. Any click
* closes it. */
import { memo } from "react"; import { memo } from "react";
@@ -17,17 +19,33 @@ import { SKIN } from "../lib/skins";
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme"; import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
import { Drift, type DriftField } from "./Drift"; import { Drift, type DriftField } from "./Drift";
/** Beside the watch only, never behind it: a bubble rising behind it would drag the
* watch into every frame the Pi redraws. On a 1080p kiosk the watch covers roughly
* 28-72% of the width when it is alone... */
const BUBBLES: DriftField = [ const BUBBLES: DriftField = [
[5, 14, 0.5, 9, 0], [5, 14, 0.5, 9, 0],
[12, 22, 0.5, 12, -3], [12, 22, 0.5, 12, -3],
[17, 16, 0.5, 11, -2],
[22, 10, 0.5, 8, -6], [22, 10, 0.5, 8, -6],
[48, 16, 0.5, 11, -2],
[63, 12, 0.5, 10, -7],
[78, 24, 0.5, 14, -5], [78, 24, 0.5, 14, -5],
[83, 12, 0.5, 10, -7],
[88, 12, 0.5, 9, -1], [88, 12, 0.5, 9, -1],
[95, 18, 0.5, 13, -9], [95, 18, 0.5, 13, -9],
]; ];
/** ...and 6-49% when countdown cards share the screen, so then they all rise on the
* cards' side. The cards are cheap to redraw (no backdrop blur under `?pi=1`). */
const BUBBLES_WITH_CARDS: DriftField = [
[52, 14, 0.5, 9, 0],
[57, 22, 0.5, 12, -3],
[64, 10, 0.5, 8, -6],
[70, 16, 0.5, 11, -2],
[77, 24, 0.5, 14, -5],
[84, 12, 0.5, 10, -7],
[90, 12, 0.5, 9, -1],
[96, 18, 0.5, 13, -9],
];
/** The four colours every ring of the dial cycles through, starting at 12. */ /** The four colours every ring of the dial cycles through, starting at 12. */
const HUES = ["205", "175", "245", "pink"] as const; const HUES = ["205", "175", "245", "pink"] as const;
@@ -53,10 +71,14 @@ export function ClockOverlay({ visible, clock, onClose }: Props) {
hidden={!visible} hidden={!visible}
onClick={onClose} onClick={onClose}
role="dialog" role="dialog"
aria-hidden={!visible}
aria-label="Uhr" aria-label="Uhr"
> >
{SHOW_DECORATIVE_ANIMATIONS && ( {visible && SHOW_DECORATIVE_ANIMATIONS && (
<Drift field={BUBBLES} bubbleColor={(alpha) => `oklch(90% 0.02 var(--hue) / ${alpha})`} /> <Drift
field={countdowns.length > 0 ? BUBBLES_WITH_CARDS : BUBBLES}
bubbleColor={(alpha) => `oklch(90% 0.02 var(--hue) / ${alpha})`}
/>
)} )}
{clock && clock.school_day !== null && <SchoolDayBadge clock={clock} />} {clock && clock.school_day !== null && <SchoolDayBadge clock={clock} />}
@@ -66,11 +88,15 @@ export function ClockOverlay({ visible, clock, onClose }: Props) {
<div className="clock-face"> <div className="clock-face">
<Dial /> <Dial />
<div className="clock-hand clock-hand-hour" style={{ transform: `rotate(${angles.hour}deg)` }}> <div className="clock-hand clock-hand-hour" style={{ transform: `rotate(${angles.hour}deg)` }}>
<div>
<div /> <div />
</div> </div>
</div>
<div className="clock-hand clock-hand-minute" style={{ transform: `rotate(${angles.minute}deg)` }}> <div className="clock-hand clock-hand-minute" style={{ transform: `rotate(${angles.minute}deg)` }}>
<div>
<div /> <div />
</div> </div>
</div>
<div className="clock-hand clock-hand-second" style={{ transform: `rotate(${angles.second}deg)` }}> <div className="clock-hand clock-hand-second" style={{ transform: `rotate(${angles.second}deg)` }}>
<div /> <div />
</div> </div>
@@ -79,7 +105,7 @@ export function ClockOverlay({ visible, clock, onClose }: Props) {
</div> </div>
</div> </div>
{countdowns.length > 0 && ( {visible && countdowns.length > 0 && (
<div className="clock-countdowns"> <div className="clock-countdowns">
{countdowns.map((countdown) => ( {countdowns.map((countdown) => (
<CountdownCard key={countdown.kind} countdown={countdown} /> <CountdownCard key={countdown.kind} countdown={countdown} />

View File

@@ -5,6 +5,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import type { HaDevice, HaEntityState } from "../api/types"; import type { HaDevice, HaEntityState } from "../api/types";
import { sameLightColor } from "../lib/lightColor";
import { oklchToRgb } from "../lib/oklch"; import { oklchToRgb } from "../lib/oklch";
import { SKIN } from "../lib/skins"; import { SKIN } from "../lib/skins";
@@ -23,10 +24,6 @@ interface Props {
onManualChange: () => void; onManualChange: () => void;
} }
function sameRgb(a: [number, number, number], b: [number, number, number]): boolean {
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
}
export function LightCard({ device, state, callService, optimistic, onManualChange }: Props) { export function LightCard({ device, state, callService, optimistic, onManualChange }: Props) {
const swatchRgb = useMemo( const swatchRgb = useMemo(
() => SWATCHES.map((swatch) => ({ ...swatch, rgb: oklchToRgb(swatch.oklch) })), () => SWATCHES.map((swatch) => ({ ...swatch, rgb: oklchToRgb(swatch.oklch) })),
@@ -116,7 +113,7 @@ export function LightCard({ device, state, callService, optimistic, onManualChan
{isColor && ( {isColor && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginTop: 14 }}> <div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginTop: 14 }}>
{swatchRgb.map((swatch) => { {swatchRgb.map((swatch) => {
const selected = on && sameRgb(rgb, swatch.rgb); const selected = on && sameLightColor(rgb, swatch.rgb);
return ( return (
<button <button
key={swatch.name} key={swatch.name}

View File

@@ -27,9 +27,18 @@ export function ShutterCard({ device, state, callService, optimistic, onManualCh
const supportsPosition = position != null || (supportedFeatures & SUPPORT_SET_POSITION) !== 0; const supportsPosition = position != null || (supportedFeatures & SUPPORT_SET_POSITION) !== 0;
const closedPercent = closedPercentFromPosition(position ?? 0); const closedPercent = closedPercentFromPosition(position ?? 0);
// A Homematic blind that did not acknowledge a command goes "unavailable" until it is
// heard from again. Showing "Offen" then (no position reads as fully open) invites
// tapping away at buttons that cannot reach it.
const unreachable = state?.state === "unavailable";
const moving = state?.state === "opening" ? "up" : state?.state === "closing" ? "down" : null; const moving = state?.state === "opening" ? "up" : state?.state === "closing" ? "down" : null;
const statusLabel = const statusLabel = unreachable
moving === "down" ? "Fährt runter …" : moving === "up" ? "Fährt hoch …" : closedLabelFor(closedPercent); ? "Nicht erreichbar"
: moving === "down"
? "Fährt runter …"
: moving === "up"
? "Fährt hoch …"
: closedLabelFor(closedPercent);
const setPosition = (target: number) => { const setPosition = (target: number) => {
onManualChange(); onManualChange();
@@ -42,12 +51,16 @@ export function ShutterCard({ device, state, callService, optimistic, onManualCh
}); });
}; };
// Shown as moving at once: the blind can take a while to report, and a button that
// seems to do nothing gets pressed again - which a radio blind copes with badly.
const open = () => { const open = () => {
onManualChange(); onManualChange();
optimistic(device.entity_id, { state: "opening" });
void callService("cover", "open_cover", { entity_id: device.entity_id }); void callService("cover", "open_cover", { entity_id: device.entity_id });
}; };
const close = () => { const close = () => {
onManualChange(); onManualChange();
optimistic(device.entity_id, { state: "closing" });
void callService("cover", "close_cover", { entity_id: device.entity_id }); void callService("cover", "close_cover", { entity_id: device.entity_id });
}; };
const stop = () => { const stop = () => {
@@ -111,7 +124,20 @@ export function ShutterCard({ device, state, callService, optimistic, onManualCh
/> />
</div> </div>
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 10 }}> <fieldset
disabled={unreachable}
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 10,
margin: 0,
padding: 0,
border: "none",
minWidth: 0,
opacity: unreachable ? 0.45 : 1,
}}
>
{supportsPosition && ( {supportsPosition && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{SHUTTER_PRESETS.map((preset) => { {SHUTTER_PRESETS.map((preset) => {
@@ -185,7 +211,7 @@ export function ShutterCard({ device, state, callService, optimistic, onManualCh
▼ ▼
</button> </button>
</div> </div>
</div> </fieldset>
</div> </div>
</div> </div>
); );

View File

@@ -12,6 +12,12 @@ import type { HaConfig, HaEntityState } from "../api/types";
const POLL_INTERVAL_MS = 2500; const POLL_INTERVAL_MS = 2500;
/** How long a tap's optimistic state wins over polled state. A device reports back
* after HA has already answered the service call - a Hue light about a second later -
* so a poll that *starts* just after the tap can still return the old colour and flip
* the card back, which reads as "the tap did not work". */
const OPTIMISTIC_HOLD_MS = 4000;
export interface HomeAssistant { export interface HomeAssistant {
states: Record<string, HaEntityState>; states: Record<string, HaEntityState>;
loading: boolean; loading: boolean;
@@ -52,9 +58,10 @@ export function useHomeAssistant(config: HaConfig | null): HomeAssistant {
setStates((previous) => { setStates((previous) => {
const next = { ...previous }; const next = { ...previous };
for (const [entityId, state] of Object.entries(fresh)) { for (const [entityId, state] of Object.entries(fresh)) {
// Only apply this result if no optimistic patch landed after the request // Only apply this result if it went out well after the last optimistic
// for it went out - otherwise it's stale and would undo a newer change. // patch - otherwise it may predate the device catching up and would undo a
if (startedAt >= (optimisticAt.current[entityId] ?? 0)) { // newer change. See OPTIMISTIC_HOLD_MS.
if (startedAt >= (optimisticAt.current[entityId] ?? -Infinity) + OPTIMISTIC_HOLD_MS) {
next[entityId] = state; next[entityId] = state;
} }
} }

View File

@@ -522,14 +522,14 @@ describe("Del", () => {
}); });
}); });
describe("Einfg", () => { describe("Pos1", () => {
it("toggles the clock, on every page", () => { it("toggles the clock, on every page", () => {
for (const page of ["music", "room", "typing", "parents"] as const) { for (const page of ["music", "room", "typing", "parents"] as const) {
expect(press("Insert", { ...initialUiState, page })).toContainEqual({ expect(press("Home", { ...initialUiState, page })).toContainEqual({
type: "ui", type: "ui",
patch: { showClock: true }, patch: { showClock: true },
}); });
expect(press("Insert", { ...initialUiState, page, showClock: true })).toContainEqual({ expect(press("Home", { ...initialUiState, page, showClock: true })).toContainEqual({
type: "ui", type: "ui",
patch: { showClock: false }, patch: { showClock: false },
}); });

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { sameLightColor } from "../lightColor";
// What the Hue "Fluter" on musicdolphin reported back for each dolphin swatch it was sent.
const SENT_AND_REPORTED = [
[[240, 220, 177], [255, 233, 188]],
[[239, 204, 54], [255, 218, 60]],
[[241, 77, 76], [255, 85, 83]],
[[0, 171, 237], [0, 185, 255]],
[[66, 203, 128], [85, 255, 162]],
[[177, 106, 224], [203, 124, 255]],
] as const;
describe("sameLightColor", () => {
it("recognises each swatch in what a Hue light reports back", () => {
for (const [sent, reported] of SENT_AND_REPORTED) {
expect(sameLightColor(reported, sent)).toBe(true);
}
});
it("tells the swatches apart", () => {
for (const [, reported] of SENT_AND_REPORTED) {
const matches = SENT_AND_REPORTED.filter(([sent]) => sameLightColor(reported, sent));
expect(matches).toHaveLength(1);
}
});
it("does not take black for every colour", () => {
expect(sameLightColor([0, 0, 0], [241, 77, 76])).toBe(false);
});
});

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { DINOSAUR } from "../skins/dinosaur";
import { DOLPHIN } from "../skins/dolphin";
import { oklchToRgb } from "../oklch";
describe("oklchToRgb", () => {
it("converts the dolphin swatches", () => {
// Cross-checked against an independent OKLab implementation.
expect(DOLPHIN.lightSwatches.map((swatch) => oklchToRgb(swatch.oklch))).toEqual([
[240, 220, 177],
[239, 204, 54],
[241, 77, 76],
[0, 171, 237],
[66, 203, 128],
[177, 106, 224],
]);
});
it("gives every swatch of every skin its own colour", () => {
for (const skin of [DOLPHIN, DINOSAUR]) {
const colours = skin.lightSwatches.map((swatch) => oklchToRgb(swatch.oklch).join());
expect(new Set(colours).size).toBe(colours.length);
}
});
it("knows black and white", () => {
expect(oklchToRgb("oklch(0% 0 0)")).toEqual([0, 0, 0]);
expect(oklchToRgb("oklch(100% 0 0)")).toEqual([255, 255, 255]);
expect(oklchToRgb("oklch(1 0 0)")).toEqual([255, 255, 255]);
});
});

View File

@@ -269,9 +269,9 @@ export function handleKey(
// another room. // another room.
if (key === "Delete" && !event.ctrlKey && !event.metaKey) return [{ type: "screen" }]; if (key === "Delete" && !event.ctrlKey && !event.metaKey) return [{ type: "screen" }];
// Einfg, right next to it: the clock, over whatever is on screen - the typing tab // Pos1, in the same block: the clock, over whatever is on screen - the typing tab
// included, like Del, since no lesson drills it either. // included, like Del, since no lesson drills it either.
if (key === "Insert" && !event.ctrlKey && !event.metaKey) { if (key === "Home" && !event.ctrlKey && !event.metaKey) {
return [{ type: "pop", freq: 520 }, { type: "ui", patch: { showClock: !state.showClock } }]; return [{ type: "pop", freq: 520 }, { type: "ui", patch: { showClock: !state.showClock } }];
} }

24
web/src/lib/lightColor.ts Normal file
View File

@@ -0,0 +1,24 @@
/** Whether a light is showing one of the room page's colour swatches.
*
* Home Assistant does not hand back the `rgb_color` it was sent. A light that works in
* xy (Hue, for one) reports its colour at full intensity - brightness is a separate
* attribute - and rounded through its own colour space: coral sent as [241, 77, 76]
* comes back as [255, 85, 83]. So both sides are scaled to full intensity first and
* then compared with some slack. */
export type Rgb = readonly [number, number, number];
/** Per channel, after scaling. The six dolphin swatches came back within 4. */
const TOLERANCE = 10;
function atFullIntensity(rgb: Rgb): Rgb {
const max = Math.max(...rgb);
if (max === 0) return rgb;
return [(rgb[0] * 255) / max, (rgb[1] * 255) / max, (rgb[2] * 255) / max];
}
export function sameLightColor(reported: Rgb, swatch: Rgb): boolean {
const a = atFullIntensity(reported);
const b = atFullIntensity(swatch);
return [0, 1, 2].every((i) => Math.abs(a[i]! - b[i]!) <= TOLERANCE);
}

View File

@@ -1,15 +1,32 @@
/** Home Assistant's `light.turn_on` wants `rgb_color: [r, g, b]`, not oklch. Rather than /** Home Assistant's `light.turn_on` wants `rgb_color: [r, g, b]`, not oklch.
* hand-transcribing the oklch->sRGB math, this leans on the browser's own CSS engine: *
* set the string as an element's color and read back what the browser resolved it to. * Computed here rather than read back from the browser's CSS engine, which is what this
* Only ever called a handful of times (once per fixed swatch), never per render. */ * used to do: current Chromium resolves an oklch colour to `oklch(...)`, not `rgb(...)`
* (CSS Color 4), so reading it back failed on the kiosk and every swatch became white.
* The maths is Björn Ottosson's OKLab -> linear sRGB, then the sRGB transfer curve;
* colours outside sRGB are clipped per channel. */
export function oklchToRgb(oklch: string): [number, number, number] { export function oklchToRgb(oklch: string): [number, number, number] {
const el = document.createElement("div"); const match = /oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)\s*\)/.exec(oklch);
el.style.color = oklch; if (!match) throw new Error(`not an oklch(L C H) colour: ${oklch}`);
document.body.appendChild(el); const lightness = Number(match[1]) / (match[2] ? 100 : 1);
const resolved = getComputedStyle(el).color; const chroma = Number(match[3]);
document.body.removeChild(el); const hue = (Number(match[4]) * Math.PI) / 180;
const match = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(resolved); const a = chroma * Math.cos(hue);
if (!match) return [255, 255, 255]; const b = chroma * Math.sin(hue);
return [Number(match[1]), Number(match[2]), Number(match[3])]; const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
const linear = [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
] as const;
const [red, green, blue] = linear.map((channel) => {
const clipped = Math.min(1, Math.max(0, channel));
const encoded = clipped <= 0.0031308 ? 12.92 * clipped : 1.055 * clipped ** (1 / 2.4) - 0.055;
return Math.round(encoded * 255);
});
return [red!, green!, blue!];
} }

View File

@@ -273,11 +273,17 @@ button {
focused. */ focused. */
padding: 14px 10px 14px; padding: 14px 10px 14px;
cursor: grab; cursor: grab;
}
/* Scrolls by wheel, touch and keyboard, but draws no scrollbar. */
.shelf-row,
.no-scrollbar {
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* old Edge */ -ms-overflow-style: none; /* old Edge */
} }
.shelf-row::-webkit-scrollbar { .shelf-row::-webkit-scrollbar,
.no-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari */ display: none; /* Chrome, Safari */
} }

View File

@@ -21,11 +21,17 @@
); );
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
will-change: opacity;
} }
/* `display: flex` above would otherwise beat the `hidden` attribute. */ /* 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] { .clock-overlay[hidden] {
display: none; display: flex;
opacity: 0;
pointer-events: none;
} }
/* Upright screens: the countdowns go under the watch instead of beside it. */ /* Upright screens: the countdowns go under the watch instead of beside it. */
@@ -62,7 +68,6 @@
flex: none; flex: none;
width: 78vmin; width: 78vmin;
height: 96vmin; height: 96vmin;
filter: drop-shadow(0 3vmin 5vmin oklch(15% 0.05 210 / 0.5));
} }
.clock-strap { .clock-strap {
@@ -72,6 +77,7 @@
box-sizing: border-box; box-sizing: border-box;
border: 0.55vmin solid var(--clock-ink); border: 0.55vmin solid var(--clock-ink);
--strap: oklch(70% 0.16 340); --strap: oklch(70% 0.16 340);
box-shadow: 0 3vmin 5vmin oklch(15% 0.05 210 / 0.35);
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
var(--strap) 0%, var(--strap) 0%,
@@ -144,6 +150,10 @@
box-sizing: border-box; box-sizing: border-box;
border-radius: 50%; border-radius: 50%;
border: 0.6vmin solid var(--clock-ink); 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: background:
radial-gradient( radial-gradient(
circle closest-side, circle closest-side,
@@ -347,10 +357,17 @@
left: 50%; left: 50%;
bottom: 50%; bottom: 50%;
transform-origin: 50% 100%; 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;
} }
.clock-hand-hour, /* The outline and shadow, on the hand's inner box rather than the hand: a filter on the
.clock-hand-minute { 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)) 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 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)); drop-shadow(0 1vmin 1vmin oklch(15% 0.05 210 / 0.3));
@@ -369,8 +386,18 @@
margin-left: -2.7vmin; margin-left: -2.7vmin;
} }
.clock-hand-hour > div { .clock-hand-hour > div,
.clock-hand-minute > div {
bottom: -3.5vmin; 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); background: oklch(92% 0.06 345);
clip-path: polygon(50% 0%, 100% 22%, 66% 22%, 66% 100%, 34% 100%, 34% 22%, 0% 22%); clip-path: polygon(50% 0%, 100% 22%, 66% 22%, 66% 100%, 34% 100%, 34% 22%, 0% 22%);
} }
@@ -381,8 +408,7 @@
margin-left: -2.5vmin; margin-left: -2.5vmin;
} }
.clock-hand-minute > div { .clock-hand-minute > div > div {
bottom: -3.5vmin;
background: oklch(92% 0.05 200); background: oklch(92% 0.05 200);
clip-path: polygon(50% 0%, 100% 14%, 64% 14%, 64% 100%, 36% 100%, 36% 14%, 0% 14%); clip-path: polygon(50% 0%, 100% 14%, 64% 14%, 64% 100%, 36% 100%, 36% 14%, 0% 14%);
} }