From 83a26c94765f48f6ef65c0660d5a24b36ae17153 Mon Sep 17 00:00:00 2001 From: franzz Date: Mon, 21 Sep 2026 14:44:28 +0200 Subject: [PATCH] Follow Apple design guide lines --- static/app.js | 62 ++++++++++++++- static/panel.css | 185 ++++++++++++++++++++++++++++--------------- templates/index.html | 14 ++-- 3 files changed, 192 insertions(+), 69 deletions(-) diff --git a/static/app.js b/static/app.js index df269aa..4da2949 100644 --- a/static/app.js +++ b/static/app.js @@ -8,12 +8,17 @@ const BASE = document.documentElement.dataset.base || '/'; const POLL_MS = 5000; // How long a dropped cursor waits for the player to report it got there. const SEEK_HOLD_MS = 10000; +// How far an arrow key moves the progress cursor. +const SEEK_KEY_MS = 10000; // And how long a pressed play or pause on the AVR waits for the same. The // Zidoo acknowledges the key press at once but takes a moment to act on it. const PLAY_HOLD_MS = 6000; // Motion turned down in the OS means cards change places without sliding. const CALM = matchMedia('(prefers-reduced-motion: reduce)'); +// Which way each arrow key moves a slider. +const ARROWS = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }; + const el = (sel, root = document) => root.querySelector(sel); const els = (sel, root = document) => Array.from(root.querySelectorAll(sel)); @@ -170,6 +175,10 @@ function paintVolume(room) { room.level.textContent = known ? room.volume : '—'; // The fill and the knob riding it both size themselves off this. room.meter.style.setProperty('--level', known ? room.volume : 0); + // The knob is a slider to VoiceOver, which reads these rather than its digits. + if (known) room.level.setAttribute('aria-valuenow', room.volume); + else room.level.removeAttribute('aria-valuenow'); + room.level.setAttribute('aria-disabled', String(!room.available)); room.steps.forEach((button) => { button.disabled = !room.available; }); } @@ -282,6 +291,16 @@ function paintProgress(refs, known, position, duration) { if (known) label.style.setProperty('--half', `${label.offsetWidth / 2}px`); }); el('[data-role="progress-total"]', progress).textContent = known ? clock(total) : ''; + + const cursor = el('[data-role="progress-cursor"]', progress); + const seekable = progress.classList.contains('seekable'); + cursor.tabIndex = seekable ? 0 : -1; + cursor.setAttribute('aria-readonly', String(!seekable)); + if (known) { + cursor.setAttribute('aria-valuemax', total); + cursor.setAttribute('aria-valuenow', elapsed); + cursor.setAttribute('aria-valuetext', `${clock(elapsed)} of ${clock(total)}`); + } } function tickProgress(refs) { @@ -339,6 +358,19 @@ function seekable(refs) { }; line.addEventListener('pointerup', stop); line.addEventListener('pointercancel', stop); + + // The arrow keys, and VoiceOver's swipe up and down on a slider, move the + // cursor a step at a time, each one its own seek. + el('[data-role="progress-cursor"]', progress).addEventListener('keydown', (event) => { + const direction = ARROWS[event.key]; + if (!direction || seeking(refs) || !refs.shown || !progress.classList.contains('seekable')) return; + event.preventDefault(); + const { duration_ms: duration } = refs.shown; + const ms = refs.shownMs + direction * SEEK_KEY_MS; + refs.seek = { ms: Math.round(Math.max(0, Math.min(duration, ms))), stage: 'drag', song: refs.shown.song }; + renderProgress(refs.shown, refs); + sendSeek(refs); + }); } /* A seek that fails snaps the cursor back; one that works holds it where it @@ -452,6 +484,11 @@ function holdable(node, action) { let timer = null; let delay = 420; + // A press by pointer acts on pointerdown and preventDefault()s, so the + // click behind it is not meant to act again. One with no pointer press + // just before it -- Enter or Space on a focused button, VoiceOver's + // double-tap -- is the only way in for those, and acts once. + let pressed = false; const stop = () => { clearTimeout(timer); timer = null; delay = 420; }; const tick = () => { action(); @@ -462,10 +499,15 @@ function holdable(node, action) { node.addEventListener('pointerdown', (event) => { if (event.button > 0 || node.disabled) return; event.preventDefault(); // also suppresses the click that would double-fire + pressed = true; action(); timer = setTimeout(tick, delay); }); ['pointerup', 'pointercancel', 'pointerleave'].forEach((type) => node.addEventListener(type, stop)); + ['pointerup', 'pointercancel'].forEach((type) => node.addEventListener(type, () => setTimeout(() => { pressed = false; }, 500))); + node.addEventListener('click', () => { + if (!pressed && !node.disabled) action(); + }); } function nextLevel(current, direction) { @@ -552,6 +594,14 @@ function draggable(room) { }; knob.addEventListener('pointerup', stop); knob.addEventListener('pointercancel', stop); + + // The arrow keys, and VoiceOver's swipe up and down on a slider. + knob.addEventListener('keydown', (event) => { + const direction = ARROWS[event.key]; + if (!direction) return; + event.preventDefault(); + nudge(room.key, direction); + }); } async function setGrouped(key, joined) { @@ -733,9 +783,19 @@ function openSheet() { ui.options.appendChild(option); }); ui.sheet.hidden = false; + // Into the sheet, on the input already selected, so a keyboard or VoiceOver + // starts there rather than on the page behind it. + (el('[aria-current="true"]', ui.options) || ui.options.firstElementChild).focus(); } -function closeSheet() { ui.sheet.hidden = true; } +function closeSheet() { + ui.sheet.hidden = true; + ui.inputButton.focus(); +} + +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && sheetOpen()) closeSheet(); +}); async function chooseInput(source) { closeSheet(); diff --git a/static/panel.css b/static/panel.css index b98ce83..ca40179 100644 --- a/static/panel.css +++ b/static/panel.css @@ -1,34 +1,80 @@ /* Base Theme */ :root { - color-scheme: dark; - --accent: #5b8def; - --bg: #0a0d14; - --card: #151a25; - --edge: #232b3b; - --ink: #eef2f9; - --ink-bright: #fff; - --ink-bright-muted: rgba(255, 255, 255, .8); - --live: #3ddc97; - --media: #1a3333; - --muted: #8d98ad; + color-scheme: light dark; + + /* Light is the base, and follows the iOS grouped style: a gray page, white + cards on it. Every text pair here clears 4.5:1. */ + --accent: #356fd9; /* white on it is 4.7:1, so it can carry text */ + --bg: #e9eaf0; + --card: #fff; + --edge: #d1d1d6; + --ink: #1c1c1e; + --ink-bright: #fff; /* what sits on the accent, in either theme */ + --lift: 0 1px 2px var(--shadow), 0 2px 8px -2px var(--shadow); /* soft and low, so a button rises without an edge */ + --muted: #58585d; + --outline: #8a8f9c; /* a border that has to be seen, 3:1 */ + --playing: #c4eed7; /* the green a playing card fades in from */ --radius-lg: 16px; - --radius-xl: 22px; - --raised: #29334a; - --scrim: rgba(4, 6, 11, .6); - --shadow: rgba(0, 0, 0, .45); - --shade: rgba(255, 255, 255, .05); + --radius-xl: 28px; + --raised: #e3e5ec; + --scrim: rgba(0, 0, 0, .35); + --shadow: rgba(0, 0, 0, .18); + --shade: rgba(0, 0, 0, .06); --space-xs: 4px; --space-sm: 8px; --space-md: 12px; - --space-lg: 24px; - --toast-bad-bg: #2b1f24; - --toast-bad-edge: #52303a; - --toast-bad-ink: #ffd9d4; - --toast-ok-bg: #172a21; - --toast-ok-edge: #2a5540; - --toast-ok-ink: #c9f7df; - --warn: #ff7a6b; + --space-lg: 16px; + --space-xl: 24px; + /* Rems off the root, written as px / 17 (see html below) so they scale + with Dynamic Type; md is the body size. */ + --text-2xs: calc(11rem / 17); + --text-xs: calc(13rem / 17); + --text-sm: calc(15rem / 17); + --text-md: calc(17rem / 17); + --text-lg: calc(20rem / 17); + --text-xl: calc(24rem / 17); + --toast-bad-bg: #fde8e6; + --toast-bad-edge: #f3b8b2; + --toast-bad-ink: #7a1a12; + --toast-ok-bg: #e4f6ec; + --toast-ok-edge: #a8dcc0; + --toast-ok-ink: #14532d; + --track: #b6bbc8; /* the empty part of the volume meter */ + --warn: #c4231b; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0a0d14; + --card: #151a25; + --edge: #232b3b; + --ink: #eef2f9; + --muted: #98a3b8; + --outline: #5f6b87; + --playing: #1f4a42; + --raised: #29334a; + --scrim: rgba(4, 6, 11, .6); + --shadow: rgba(0, 0, 0, .45); + --shade: rgba(255, 255, 255, .05); + --toast-bad-bg: #2b1f24; + --toast-bad-edge: #52303a; + --toast-bad-ink: #ffd9d4; + --toast-ok-bg: #172a21; + --toast-ok-edge: #2a5540; + --toast-ok-ink: #c9f7df; + --track: #3a4660; + --warn: #ff7a6b; + } +} + +/* Increase Contrast in iOS' Accessibility settings. */ +@media (prefers-contrast: more) { + :root { + --muted: var(--ink); + --edge: var(--outline); + --track: var(--outline); + } } * { box-sizing: border-box; } @@ -39,13 +85,18 @@ [hidden] { display: none !important; } -html { background: var(--bg); } +/* Dynamic Type: on iOS the root takes the size the user chose in Settings + (17px at the default), and the --text-* sizes above are rems off it. */ +html { background: var(--bg); font-size: 17px; } +@supports (font: -apple-system-body) { + html { font: -apple-system-body; } +} body { margin: 0; background: var(--bg); color: var(--ink); - font: 16px/1.4 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif; + font: 1rem/1.4 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif; -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; @@ -60,24 +111,28 @@ button { background: none; cursor: pointer; touch-action: manipulation; - box-shadow: 1px 1px 1px 0px var(--shadow); + box-shadow: var(--lift); } button:active { transform: scale(.96); } +button[aria-pressed="true"], button[aria-current="true"] { box-shadow: none; } + +/* For a keyboard, including the one on an iPad, and VoiceOver's cursor. */ +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } svg { width: 22px; height: 22px; fill: currentColor; } .app { max-width: 520px; margin: 0 auto; - padding: max(var(--space-md), env(safe-area-inset-top)) max(var(--space-md), env(safe-area-inset-right)) - max(var(--space-lg), env(safe-area-inset-bottom)) max(var(--space-md), env(safe-area-inset-left)); + padding: max(var(--space-lg), env(safe-area-inset-top)) max(var(--space-lg), env(safe-area-inset-right)) + max(var(--space-xl), env(safe-area-inset-bottom)) max(var(--space-lg), env(safe-area-inset-left)); display: flex; flex-direction: column; gap: var(--space-md); } -.wide { width: 100%; height: 50px; border-radius: var(--radius-lg); font-size: 15px; } -.ghost { background: transparent; border: 1px solid var(--edge); color: var(--muted); } +.wide { width: 100%; min-height: 50px; border-radius: var(--radius-lg); font-size: var(--text-sm); } +.ghost { background: transparent; border: 1px solid var(--outline); color: var(--muted); box-shadow: none; } /* nothing to lift */ @keyframes spin { to { transform: rotate(360deg); } } @keyframes rise { from { transform: translateY(14%); opacity: .4; } } @@ -86,6 +141,10 @@ svg { width: 22px; height: 22px; fill: currentColor; } header { order: -2; display: flex; align-items: center; justify-content: center; } header .logo { height: 44px; width: auto; display: block; margin: var(--space-xs); } +/* The mark is drawn white, for a dark page. */ +@media (prefers-color-scheme: light) { + header .logo { filter: invert(1); } +} /* Card */ @@ -100,7 +159,6 @@ header .logo { height: 44px; width: auto; display: block; margin: var(--space-xs .card { background: var(--card); - border: 1px solid var(--edge); border-radius: var(--radius-xl); padding: var(--space-md); display: flex; @@ -109,16 +167,14 @@ header .logo { height: 44px; width: auto; display: block; margin: var(--space-xs } .card.offline { opacity: .5; } .card.room.playing, .card.avr.playing { - background: linear-gradient(135deg, - color-mix(in srgb, var(--live) 25%, var(--card)) 0%, - var(--card) 70%); + background: linear-gradient(135deg, var(--playing) 0%, var(--card) 70%); } .card-head { display: flex; align-items: center; justify-content: center; gap: var(--space-md); } .card-head h2 { display: flex; align-items: center; gap: var(--space-md); margin: var(--space-sm); min-width: 0; - font-size: 20px; font-weight: 600; + font-size: var(--text-lg); font-weight: 600; } .card-head h2 .room-icon { flex: 0 0 auto; height: 20px; width: auto; fill: currentColor; } .card-head h2 .room-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -147,8 +203,8 @@ header .logo { height: 44px; width: auto; display: block; margin: var(--space-xs .cover.poster { width: 50px; height: 75px; border-radius: 9px; } .track { display: flex; flex-direction: column; min-width: 0; } .track span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.song { font-size: 17px; font-weight: 550; } -.artist { font-size: 15px; color: var(--muted); } +.song { font-size: var(--text-md); font-weight: 600; } +.artist { font-size: var(--text-sm); color: var(--muted); } .progress { display: flex; align-items: center; gap: var(--space-md); } .progress-line { position: relative; flex: 1; height: 3px; border-radius: 2px; background: var(--muted); } .progress-fill { @@ -168,7 +224,7 @@ header .logo { height: 44px; width: auto; display: block; margin: var(--space-xs .progress-time, .progress-total { display: none; - font-size: 12px; line-height: 1; color: var(--muted); + font-size: var(--text-xs); line-height: 1; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; } .progress-time { @@ -179,13 +235,16 @@ header .logo { height: 44px; width: auto; display: block; margin: var(--space-xs } .progress-time.elapsed { bottom: 9px; } .progress-time.remaining { top: 9px; } -.progress.known { padding: var(--space-md) 0; } /* room for the labels above and below the cursor */ +/* Room for the labels above and below the cursor: what they need at 13px, + plus whatever a larger Dynamic Type size adds to them. */ +.progress.known { padding: calc(var(--space-md) + max(0px, var(--text-xs) - 13px)) 0; } .progress.known .progress-cursor, .progress.known .progress-time, .progress.known .progress-total { display: block; } .progress.seekable .progress-cursor, .progress.seekable .progress-time { cursor: grab; touch-action: pan-y; } -.progress.seekable .progress-cursor::before { content: ''; position: absolute; inset: calc(var(--space-md) * -1); } +/* The cursor is 7px; this grows what a finger can grab to the 44pt Apple asks for. */ +.progress.seekable .progress-cursor::before { content: ''; position: absolute; inset: -19px; } .progress.dragging .progress-cursor, .progress.dragging .progress-time { transition: none; cursor: grabbing; } .progress.dragging .progress-cursor { background: var(--ink); transform: translateY(-50%) scale(1.6); } @@ -205,7 +264,7 @@ header .logo { height: 44px; width: auto; display: block; margin: var(--space-xs .control-btn svg { width: 20px; height: 20px; } .control-btn:disabled { opacity: .5; } -.meter { position: relative; flex: 1; height: 8px; border-radius: 4px; background: var(--bg); } +.meter { position: relative; flex: 1; height: 8px; border-radius: 4px; background: var(--track); } .meter i { display: block; height: 100%; width: calc(var(--level, 0) * 1%); border-radius: 999px; background: var(--accent); @@ -220,8 +279,10 @@ header .logo { height: 44px; width: auto; display: block; margin: var(--space-xs display: grid; place-items: center; border-radius: 999px; background: var(--accent); color: var(--ink-bright); - box-shadow: 0 0 0 3px var(--card); - font-size: 14px; font-weight: 640; font-variant-numeric: tabular-nums; + box-shadow: 0 1px 4px var(--shadow); + /* Left in px: the number has to fit a pill whose size the meter's maths + (see the left: above) knows. */ + font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums; transition: left .12s ease-out; cursor: grab; touch-action: none; /* the drag is ours, not a page scroll */ @@ -248,13 +309,13 @@ header .logo { height: 44px; width: auto; display: block; margin: var(--space-xs .sources { display: flex; gap: var(--space-md); } .sources-btn { flex: 1; min-width: 0; - height: 50px; border-radius: var(--radius-lg); + min-height: 50px; border-radius: var(--radius-lg); background: var(--raised); display: flex; align-items: center; justify-content: center; gap: var(--space-sm); - font-size: 15px; font-weight: 550; + font-size: var(--text-sm); font-weight: 600; } .sources-btn:disabled { opacity: .5; } -.sources-btn svg { flex: 0 0 auto; color: var(--ink-bright); } +.sources-btn svg { flex: 0 0 auto; } .sources-btn span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sources-btn .spinner { display: none; } button.sources-btn.loading .icon { display: none; } @@ -277,9 +338,9 @@ button.sources-btn.loading .icon { display: none; } background: var(--raised); text-align: left; } -.input-select .eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); } +.input-select .eyebrow { font-size: var(--text-2xs); text-transform: uppercase; letter-spacing: .08em; color: var(--muted); } .input-select [data-role="input-icon"] { flex: 0 0 auto; height: 18px; width: 18px; fill: currentColor; color: var(--muted); } -.input-select .value { flex: 1; font-size: 19px; font-weight: 600; } +.input-select .value { flex: 1; font-size: var(--text-lg); font-weight: 600; } .input-select .chevron { color: var(--muted); } .sheet-wrap { position: fixed; inset: 0; z-index: 10; display: flex; flex-direction: column; justify-content: flex-end; } @@ -289,46 +350,46 @@ button.sources-btn.loading .icon { display: none; } position: relative; background: var(--card); border: 1px solid var(--edge); - border-radius: 28px 28px 0 0; + border-radius: var(--radius-xl) var(--radius-xl) 0 0; padding: var(--space-md) var(--space-md) calc(var(--space-md) + env(safe-area-inset-bottom)); max-height: 82vh; display: flex; flex-direction: column; gap: var(--space-md); animation: rise .22s cubic-bezier(.22, .68, .3, 1); } -.grabber { width: 38px; height: 4px; border-radius: 999px; background: var(--edge); margin: var(--space-xs) auto var(--space-xs); } -.sheet h3 { margin: 0 var(--space-xs); font-size: 14px; font-weight: 600; color: var(--muted); } +.grabber { width: 38px; height: 4px; border-radius: 999px; background: var(--outline); margin: var(--space-xs) auto var(--space-xs); } +.sheet h3 { margin: 0 var(--space-xs); font-size: var(--text-xs); font-weight: 600; color: var(--muted); } .options { display: flex; flex-direction: column; gap: var(--space-sm); overflow-y: auto; -webkit-overflow-scrolling: touch; } .option { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); min-height: 58px; padding: 0 var(--space-md); border-radius: var(--radius-lg); background: var(--raised); - font-size: 17px; text-align: left; + font-size: var(--text-md); text-align: left; } .option[aria-current="true"] { background: var(--accent); color: var(--ink-bright); } .option-label { display: flex; align-items: center; gap: var(--space-md); min-width: 0; overflow: hidden; } .option-label svg { flex: 0 0 auto; height: 18px; width: 18px; fill: currentColor; color: var(--muted); } .option-label span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.option[aria-current="true"] .option-label svg { color: var(--ink-bright-muted); } -.option .code { font-size: 12px; color: var(--muted); flex: 0 0 auto; } -.option[aria-current="true"] .code { color: var(--ink-bright-muted); } +.option[aria-current="true"] .option-label svg { color: var(--ink-bright); } +.option .code { font-size: var(--text-xs); color: var(--muted); flex: 0 0 auto; } +.option[aria-current="true"] .code { color: var(--ink-bright); } /* AVR - Grouped Room */ .joined { display: flex; flex-direction: column; gap: var(--space-md); } -.joined .card.room { background: none; border-color: var(--ink); border-radius: 18px; padding: var(--space-md); gap: var(--space-md); } -.joined .card-head h2 { font-size: 15px; font-weight: 550; color: var(--muted); } +.joined .card.room { background: none; border-color: var(--ink); border-radius: var(--radius-lg); padding: var(--space-md); gap: var(--space-md); } +.joined .card-head h2 { font-size: var(--text-sm); font-weight: 600; color: var(--muted); } .joined .cover { width: 52px; height: 52px; border-radius: 10px; } -.joined .song { font-size: 18px; } +.joined .song { font-size: var(--text-md); } .joined .step { height: 54px; } -.joined .toggle[aria-pressed="true"] { background: var(--raised); color: inherit; } +.joined .toggle[aria-pressed="true"] { background: var(--raised); color: inherit; box-shadow: var(--lift); } /* an action here, not a state */ .joined .toggle .cinema { display: none; } .joined .toggle .leave { display: block; } /* Footer */ -footer { margin: var(--space-xs) var(--space-xs) 0; min-height: 18px; font-size: 12px; color: var(--muted); text-align: center; } +footer { margin: var(--space-xs) var(--space-xs) 0; min-height: 18px; font-size: var(--text-xs); color: var(--muted); text-align: center; } footer.bad { color: var(--warn); } /* Toast */ @@ -339,6 +400,6 @@ footer.bad { color: var(--warn); } z-index: 20; max-width: 90vw; padding: var(--space-md) var(--space-md); border-radius: 14px; background: var(--toast-bad-bg); border: 1px solid var(--toast-bad-edge); color: var(--toast-bad-ink); - font-size: 14px; box-shadow: 0 10px 30px var(--shadow); + font-size: var(--text-sm); box-shadow: 0 10px 30px var(--shadow); } .toast.ok { background: var(--toast-ok-bg); border-color: var(--toast-ok-edge); color: var(--toast-ok-ink); } \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index e0c6e71..64e277a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,15 +3,17 @@ - + {{ app_name }} - + - + + + @@ -75,7 +77,7 @@
- +
@@ -87,7 +89,7 @@ -
—
+
—
@@ -145,7 +147,7 @@
- +