commit 937174a6bf6bdf0d1b0dd7f7e02402233fbe01a8 truex <truex@equwal.com> 2026-09-26 23:44:56 -0700 Make each status message readable on a phone The status strip is one line, so the page does not move. On the phone a long event message was cut to "No subtitle li..." next to "Allow notification acces..." and the "Open SubRead Overlay" button. The full text was in the title attribute, and a phone cannot hover. - While an event shows, it takes the place of the player text. The link or the button of the player stays. - A tap on the strip, not on a link or a button in it, shows the full texts of the strip in a panel (#strip-full) over the top of the page. The panel has a fixed position, so the page does not move. The next tap closes it, and else it closes after 6 s, the time of an event. stripTexts (messages.ts) gives the texts of the panel: the title of each part that shows, else its text.
README.md | 10 +++++++++- index.html | 2 ++ src/messages.ts | 23 ++++++++++++++++++++++ src/status.ts | 54 +++++++++++++++++++++++++++++++++++++++++++++++---- src/style.css | 52 +++++++++++++++++++++++++++++++++++++++++++++++-- test/messages.test.ts | 27 ++++++++++++++++++++++++++ 6 files changed, 161 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md index 152e556..50fea5f 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,15 @@ The strip under the top bar shows three things: takes more than 2 minutes, the strip says so, with Retry. "Force OCR is on" shows while it is on for the book. - The last event, for example a lookup or an error. It fades after 6 - seconds. Screen readers read this part. + seconds. While it shows, it takes the place of the player text, but + the button or the link of the player stays. Screen readers read this + part. + +The strip is one line, so the page under it does not move, and a long +text ends with "…". Tap the strip to read the full texts: they show in a +panel over the top of the page. The next tap closes the panel, and else +it closes after 6 seconds. A tap on a button of the strip, for example +Retry, only does what the button does. ### The three follow modes diff --git a/index.html b/index.html index 0f25ae5..63279eb 100644 --- a/index.html +++ b/index.html @@ -63,6 +63,8 @@ <span id="pages-status" hidden></span> <span id="status" aria-live="polite"></span> </div> + <!-- The full texts of the strip after a tap on it. It floats over the page (status.ts). --> + <div id="strip-full" hidden></div> <aside id="panel"> <h1>Scan Subread</h1> diff --git a/src/messages.ts b/src/messages.ts index 8440df3..fcb8023 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -12,6 +12,29 @@ export interface Message { action?: { id: ActionId; text: string }; } +/** A part of the status strip, for the panel of its full texts. */ +export interface StripPart { + /** False while the part is hidden. */ + shown: boolean; + /** The full text of its message: its title attribute, or '' when it has none. */ + title: string; + /** Its text as it shows, with the text of its link or its button. */ + text: string; +} + +/** + * The full texts of the parts of the strip that show, for the panel that a + * tap on the strip opens: the title of each part, else its text. A part + * with no text gives nothing. On the phone the one-line strip cut a long + * message, and a phone cannot hover to read the title. + */ +export function stripTexts(parts: readonly StripPart[]): string[] { + return parts + .filter((part) => part.shown) + .map((part) => part.title || part.text.trim()) + .filter((text) => text !== ''); +} + export const OVERLAY_RELEASES = 'https://github.com/equwal/subread-overlay/releases/latest'; export const SUBREAD_RELEASES = 'https://github.com/equwal/subread-android/releases/latest'; export const DICTIONARY_RELEASES = 'https://github.com/equwal/subread-dictionary/releases/latest'; diff --git a/src/status.ts b/src/status.ts index 0b6fc4f..3dff9ed 100644 --- a/src/status.ts +++ b/src/status.ts @@ -4,9 +4,11 @@ // // The strip is one line of a fixed height, so the page under it never // moves. A long text ends with an ellipsis, and its title attribute holds -// the full text. +// the full text. A phone cannot hover to read the title, so a tap on the +// strip shows the full texts in a panel over the top of the page. While an +// event shows, it takes the place of the player text. -import type { ActionId, Message } from './messages'; +import { stripTexts, type ActionId, type Message } from './messages'; /** Milliseconds that an event message stays before it fades. */ export const EVENT_MS = 6000; @@ -20,11 +22,14 @@ function el(id: string): HTMLElement { return e; } +const strip = el('strip'); const player = el('player-status'); const reading = el('pages-status'); const event = el('status'); +const full = el('strip-full'); let fadeTimer: ReturnType<typeof setTimeout> | undefined; +let fullTimer: ReturnType<typeof setTimeout> | undefined; /** * Writes a message into `target`: the text in its own element, so that the @@ -68,11 +73,44 @@ export function onAction(handler: (id: ActionId) => void): void { runAction = handler; } +/** + * Shows the full texts of the parts of the strip in a panel over the top of + * the page, until the next tap or for EVENT_MS. The page does not move. + */ +function showFull(): void { + const parts = [...strip.children].filter((p): p is HTMLElement => p instanceof HTMLElement); + const texts = stripTexts( + parts.map((p) => ({ shown: !p.hidden, title: p.title, text: p.textContent ?? '' })), + ); + if (texts.length === 0) return; + full.replaceChildren( + ...texts.map((text) => { + const line = document.createElement('p'); + line.textContent = text; + return line; + }), + ); + full.hidden = false; + clearTimeout(fullTimer); + fullTimer = setTimeout(hideFull, EVENT_MS); +} + +function hideFull(): void { + clearTimeout(fullTimer); + full.hidden = true; +} + +// A click on a button that fill made runs its action. The next tap closes +// the panel of the full texts. A tap on the strip, not on a link or a +// button in it, opens the panel. document.addEventListener('click', (e) => { - const button = e.target instanceof Element ? e.target.closest('button[data-action]') : null; + const target = e.target instanceof Element ? e.target : null; + const button = target?.closest('button[data-action]'); if (button instanceof HTMLButtonElement && button.dataset.action) { runAction?.(button.dataset.action as ActionId); } + if (!full.hidden) hideFull(); + else if (target && strip.contains(target) && !target.closest('a, button')) showFull(); }); let lastPlayer = ''; @@ -94,10 +132,17 @@ export function showReading(m: Message | string | null): void { fillPart(reading, typeof m === 'string' ? { text: m } : (m ?? { text: '' })); } -/** Shows an event, for example a lookup or an error. It fades after EVENT_MS. */ +/** + * Shows an event, for example a lookup or an error. It fades after EVENT_MS. + * While it shows, it takes the place of the player text (style.css): on a + * phone the parts do not fit in one line. An open panel of the full texts + * shows the new event too. + */ export function say(m: Message | string): void { fillPart(event, typeof m === 'string' ? { text: m } : m); event.classList.remove('faded'); + strip.classList.add('event'); + if (!full.hidden) showFull(); clearTimeout(fadeTimer); fadeTimer = setTimeout(() => { event.classList.add('faded'); @@ -105,6 +150,7 @@ export function say(m: Message | string): void { event.replaceChildren(); event.removeAttribute('title'); event.classList.remove('faded'); + strip.classList.remove('event'); }, FADE_MS); }, EVENT_MS); } diff --git a/src/style.css b/src/style.css index 929339e..6d73129 100644 --- a/src/style.css +++ b/src/style.css @@ -5,6 +5,8 @@ --highlight-solid: rgb(255, 200, 0); --highlight-weak: rgba(120, 160, 255, 0.35); --topbar: 3.25rem; + /* The height of the status strip. The panel of its full texts starts under it. */ + --strip: 2rem; /* The size of a button of the top bar: 48 CSS px, the Android guideline for a touch target. */ --target: 3rem; --panel-width: 22rem; @@ -157,12 +159,14 @@ body { * One line of a fixed height, so the page under it never moves: a long * message wrapped and pushed the page down, and the page went up again * when the message faded. A long text ends with an ellipsis. Its title - * attribute holds the full text (status.ts). + * attribute holds the full text, and a tap on the strip shows the full + * texts in #strip-full (status.ts). */ #strip { flex: none; box-sizing: border-box; - height: 2rem; + height: var(--strip); + cursor: pointer; display: flex; flex-wrap: nowrap; align-items: stretch; @@ -238,6 +242,46 @@ button.action { opacity: 0; } +/* + * While an event shows, it takes the place of the player text: on a phone + * the parts do not fit in one line. The link or the button of the player + * stays. + */ +#strip.event #player-status .text, +#strip.event #player-status:not(:has(a, button)) { + display: none; +} + +/* + * The full texts of the strip, after a tap on it: a phone cannot hover to + * read the title attribute. The panel floats over the top of the page, so + * the page does not move. The next tap closes it. + */ +#strip-full { + position: fixed; + top: calc(var(--topbar) + var(--strip)); + left: 0; + right: 0; + z-index: 6; + box-sizing: border-box; + max-height: 50vh; + overflow-y: auto; + padding: 0.5rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.4rem; + font-size: 0.85rem; + line-height: 1.35; + background: Canvas; + border-bottom: 1px solid #8884; + box-shadow: 0 4px 12px #0004; +} + +#strip-full p { + margin: 0; + overflow-wrap: anywhere; +} + #panel { position: fixed; top: var(--topbar); @@ -525,6 +569,10 @@ body.zoomed #viewer { min-width: 0; } + #strip-full { + left: var(--panel-width); + } + #panel { grid-column: 1; grid-row: 1 / 4; diff --git a/test/messages.test.ts b/test/messages.test.ts index 44f0762..24bb3eb 100644 --- a/test/messages.test.ts +++ b/test/messages.test.ts @@ -24,6 +24,7 @@ import { readingText, keptResultText, shownInPlayerPart, + stripTexts, subreadErrorText, subreadQuestion, subreadResultText, @@ -32,6 +33,32 @@ import { withForceOcr, } from '../src/messages'; +describe('stripTexts', () => { + it('gives the full text of each part that shows, for the panel of the strip', () => { + // The phone finding: the strip showed "No subtitle li..." next to + // "Allow notification acces..." and the "Open SubRead Overlay" button. + const player = 'Allow notification access in SubRead Overlay.'; + const event = 'No subtitle lines in book.srt.pdf. Choose an .srt or .vtt file.'; + expect( + stripTexts([ + { shown: true, title: player, text: `${player} Open SubRead Overlay` }, + { shown: false, title: '', text: 'Follow paused' }, + { shown: false, title: '', text: '' }, + { shown: true, title: event, text: event }, + ]), + ).toEqual([player, event]); + }); + + it('takes the text of a part without a title, and skips a part without text', () => { + expect( + stripTexts([ + { shown: true, title: '', text: 'Follow paused' }, + { shown: true, title: '', text: ' ' }, + ]), + ).toEqual(['Follow paused']); + }); +}); + describe('waitMessage', () => { const retry = { id: 'retry-reading', text: 'Retry' };