From 664614b924b2615448aced0a2382358c63411ed7 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Thu, 8 Oct 2026 06:50:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(opgaver):=20tal=20en=20opgave=20ind=20?= =?UTF-8?q?=E2=80=94=20en=20rund=20knap=20i=20bunden=20p=C3=A5=20telefonen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En stor rund knap nederst til højre, inden for tommelfingerens rækkevidde. Den åbner en skuffe med quick-add-feltet og sætter markøren i det, så tastaturet kommer frem — og så trykker man mikrofonen på tastaturet. VI LAVER INGEN TALEGENKENDELSE, og det er et valg frem for en mangel. iOS' egen diktering kører på enheden på nyere telefoner. En talegenkendelse bygget ind her ville sende lyden fra et selvhostet program videre til Apple eller Google — en datavej, der skulle forklares på privatlivssiden, for at spare ét tryk. Af de tre AI-udbydere, instansen understøtter, har anthropic i øvrigt slet ingen lyd-API, så serverside-transskription ville også dele brugerne op efter hvem de har en nøgle hos. Teksten lander i det felt, der i forvejen viser, hvad parseren forstod, så "ring til tandlægen i dag kl 14 p1" bliver læst mens man ser på det, og intet oprettes før man siger ja. Derfor hedder komponenten diktat og ikke tale: den gør ikke selv noget ved lyden, den stiller feltet frem. ## Det ene, funktionen hænger på Safari på iOS åbner kun tastaturet, hvis focus() kaldes INDE i den brugerhandling, der bad om det. QuickAdds `autofocus` sker i en $effect, altså efter klikket er returneret — det giver feltet en markør og intet tastatur, og der er ingen fejl nogen steder. Så skuffen er ALTID monteret og bare skubbet ud af syne med transform og opacity. Ikke display:none, ikke visibility:hidden, ikke inert — alle tre gør feltet ufokuserbart. Prøven påstår direkte, at feltet findes FØR klikket, fordi det er den eneste del af iOS-kravet, en browser kan måles på; plantet som `{#if open}` går den rød med Expected: 1, Received: 0. ## To kollisioner, hvor jeg kun så den ene Skuffen lægger et felt mere på siden, og det rammer to ting: - `data-quickadd`, som skriv-hvor-som-helst-genvejen slår op med querySelector — altså FØRSTE match. Set på forhånd og løst med en `marked`-prop. - Det tilgængelige navn. Det så jeg IKKE, og otte prøver gik røde på `getByLabel('Ny opgave') resolved to 2 elements`. Det er heller ikke kun et prøveproblem: to felter med samme navn læses op to gange af en skærmlæser, uden at man kan høre forskel. De to ER forskellige ting — det ene er kassen på siden, det andet er det, knappen åbner — så de har hver sit navn nu. ## Og en fejl, skærmbilledet fandt Fremhævningen bag teksten lå på en linje, inputtet ikke havde — en bleg blok under sætningen. Spejlet stod på `white-space: pre-wrap`, men et er enkeltlinje per specifikation og kan ikke ombryde, uanset hvad reglen siger. Komponentens egen kommentar forudser det ordret: "same wrapping. Any difference shows up as highlighting that drifts out of alignment as the line grows." Sjældent synligt på et bredt felt, konstant på 390px med en dikteret sætning. Spejlet står nu på `pre` og rulles med inputtets scrollLeft. Knappen tegnes ikke over 820px — der er feltet fremme hele tiden og et tastatur at skrive på — og ikke i urd-ansigtet, hvor der ingen opgaver er. Co-Authored-By: Claude Opus 5 --- web/e2e/smoke.spec.js | 93 ++++++++ web/src/lib/components/DictateSheet.svelte | 234 +++++++++++++++++++++ web/src/lib/components/QuickAdd.svelte | 79 ++++++- web/src/lib/locales/da.js | 5 + web/src/lib/locales/en.js | 2 + web/src/lib/stores.svelte.js | 21 +- web/src/routes/+layout.svelte | 29 +++ web/src/routes/urd/+page.svelte | 17 +- 8 files changed, 460 insertions(+), 20 deletions(-) create mode 100644 web/src/lib/components/DictateSheet.svelte diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index a967fbc..10166d7 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -5620,3 +5620,96 @@ test('the verdande door is unaffected by the urd one', async ({ page }) => { expect(trouble).toEqual([]); }); + +/** + * Tal en opgave ind. + * + * The button does not do speech recognition and is not meant to: it puts the + * quick-add field in front of the thumb so the keyboard's own dictation can be + * used. So what there is to test is the field — that it is reachable, that it ends + * up with the caret in it, and that what lands there becomes a task. + * + * What a browser cannot be asked: whether iOS opened its keyboard. That happens + * only when focus() is called inside the user gesture, and no test can see the + * difference. What a test CAN see is the precondition — that the field is already + * in the document before the button is pressed — and that is asserted below, + * because it is exactly what a well-meaning `{#if open}` would take away. + */ +const PHONE = { width: 390, height: 844 }; + +test('den runde knap sætter markøren i feltet, så tastaturet kan diktere', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.setViewportSize(PHONE); + await page.goto('/'); + + const knap = page.getByRole('button', { name: 'Tal en opgave ind' }); + await expect(knap).toBeVisible(); + + // Feltet findes FØR klikket. Det er forudsætningen for at fokus kan sættes + // synkront, og den eneste del af iOS-kravet en prøve kan se. + // + // `toHaveCount` og ikke `count()`: den første venter, den anden er et + // øjebliksbillede taget med det samme — og en SPA har ikke tegnet endnu, når + // `goto` vender tilbage, så den ville tælle nul uanset hvad koden gjorde. + await expect(page.locator('.sheet input[type="text"]')).toHaveCount(1); + + await knap.click(); + + // Markøren er i skuffens felt — ikke i sidens eget. + const fokusIskuffen = await page.evaluate( + () => !!document.activeElement?.closest('.sheet') + ); + expect(fokusIskuffen, 'markøren skal stå i skuffens felt efter trykket').toBe(true); + + // Og det, der dikteres, bliver læst af den samme parser som alt andet. + const felt = page.locator('.sheet input[type="text"]'); + await felt.fill('ring til tandlægen i dag p1'); + await felt.press('Enter'); + + // "i dag", af samme grund som de andre prøver her siger det: dette er + // I dag-visningen, og en opgave uden dato eller med "i morgen" er korrekt + // fraværende fra den — hvilket ville ligne, at tilføjelsen var fejlet. + await expect(page.getByText('ring til tandlægen').first()).toBeVisible(); + // Og skuffen lukker sig selv, så man ikke står med tastaturet oppe bagefter. + await expect(page.locator('.sheet.open')).toHaveCount(0); + expect(trouble).toEqual([]); +}); + +test('skuffens felt stjæler ikke skriv-hvor-som-helst-genvejen', async ({ page }) => { + // Genvejen finder feltet med querySelector('[data-quickadd]'), som returnerer + // FØRSTE match i dokumentet. Skuffens felt må derfor ikke bære mærket — ellers + // begynder et tastetryk at skrive i en kasse, ingen kan se. + await page.setViewportSize(PHONE); + await page.goto('/'); + + await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible(); + await expect(page.locator('[data-quickadd]'), 'kun ét mærket felt').toHaveCount(1); + const mærketErIskuffen = await page.evaluate( + () => !!document.querySelector('[data-quickadd]')?.closest('.sheet') + ); + expect(mærketErIskuffen, 'mærket skal sidde på sidens felt, ikke skuffens').toBe(false); + + await page.keyboard.press('k'); + await expect(page.locator('[data-quickadd]')).toHaveValue('k'); +}); + +test('knappen findes ikke på en skrivebordsskærm', async ({ page }) => { + // Der er feltet fremme hele tiden og et tastatur at skrive på. En rund knap i + // bunden er til tommelfingeren. + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto('/'); + await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeHidden(); +}); + +test('knappen findes ikke i urd-ansigtet', async ({ page }) => { + // Der er ingen opgaver at lægge noget i. Målt gennem døren frem for ved at + // skrue på en variabel, så det er den vej en bruger faktisk kommer. + await page.setViewportSize(PHONE); + await page.goto('/urd'); + await expect(page.getByRole('navigation', { name: /Hovedmenu|Main menu/ })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toHaveCount(0); + + // Kontrollen: samme telefon, den anden dør, og så er den der. + await page.goto('/'); + await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeVisible(); +}); diff --git a/web/src/lib/components/DictateSheet.svelte b/web/src/lib/components/DictateSheet.svelte new file mode 100644 index 0000000..333a473 --- /dev/null +++ b/web/src/lib/components/DictateSheet.svelte @@ -0,0 +1,234 @@ + + + + + +{#if !app.showsUrd} + +{/if} + + + + + + diff --git a/web/src/lib/components/QuickAdd.svelte b/web/src/lib/components/QuickAdd.svelte index 7366b99..0131628 100644 --- a/web/src/lib/components/QuickAdd.svelte +++ b/web/src/lib/components/QuickAdd.svelte @@ -16,14 +16,53 @@ import { app } from '$lib/stores.svelte.js'; import { t } from '$lib/i18n.svelte.js'; import { readList } from '$lib/pastelist.js'; - let { projectId = undefined, onadded = undefined, autofocus = false } = $props(); + let { + projectId = undefined, + onadded = undefined, + autofocus = false, + /** + * Whether this box carries the `data-quickadd` marker. + * + * The layout's type-anywhere shortcut finds the field with + * `document.querySelector('[data-quickadd]')`, which returns the FIRST match + * in the document. So a second box on the page — the dictate sheet's — has + * to stay unmarked, or the shortcut starts typing into a box nobody can see. + */ + marked = true, + /** + * The field's accessible name. + * + * A prop because the dictate sheet puts a second one of these on the page, + * and two fields answering to "Ny opgave" is a screen reader reading the + * same name twice with no way to tell which is which. They are different + * affordances — one is the box on the page, the other is the thing the + * round button opens — so they say different names. + */ + label = undefined + } = $props(); + + let fieldLabel = $derived(label ?? t('task.new')); let text = $state(''); let spans = $state([]); let submitting = $state(false); let input; + let mirror; let focused = $state(false); + /** + * Keeps the tint under the right characters when the line is longer than the + * field. + * + * The input scrolls horizontally and the mirror has to scroll with it. Done on + * both `scroll` and `input` because typing at the end of a long line moves the + * caret — and therefore the scroll — without ever firing a scroll event in some + * browsers. + */ + function syncScroll() { + if (mirror && input) mirror.scrollLeft = input.scrollLeft; + } + // Kept beside the parser's own vocabulary on purpose: a legend that promises // something internal/quickadd cannot read is worse than no legend. These four // are what Parse recognises as marks; dates it reads as prose, which is why the @@ -72,6 +111,20 @@ if (autofocus && input) input.focus(); }); + /** + * Focus the field from outside, synchronously. + * + * This exists because of iOS and nothing else. Safari opens the on-screen + * keyboard only when `focus()` is called INSIDE the user gesture that asked for + * it — so the `autofocus` effect above, which runs after the click handler has + * returned, gives the field a caret and no keyboard. For the dictate button that + * is the whole feature failing quietly: the sheet opens, nothing to dictate + * into, and there is no error anywhere. + */ + export function focusField() { + input?.focus(); + } + /** Splits the raw text into highlighted and plain runs, by byte offset. */ let segments = $derived.by(() => { if (!spans.length) return [{ text, kind: null }]; @@ -197,7 +250,7 @@ -