diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js
index 8c2fd90..5715c2d 100644
--- a/web/e2e/smoke.spec.js
+++ b/web/e2e/smoke.spec.js
@@ -5793,3 +5793,96 @@ test('en note oprettet fra et projekt åbner også med markøren i overskriften'
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..58ce0aa
--- /dev/null
+++ b/web/src/lib/components/DictateSheet.svelte
@@ -0,0 +1,243 @@
+
+
+
+
+
+{#if !app.showsUrd}
+
+{/if}
+
+
+
+
+
+
{t('task.speakHint')}
+
+
+
+
+
+
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 @@
-
+
{#each segments as segment}
{#if segment.kind}
{segment.text}
@@ -211,12 +264,14 @@
bind:this={input}
bind:value={text}
{onkeydown}
+ onscroll={syncScroll}
+ oninput={syncScroll}
onfocus={() => (focused = true)}
onblur={() => (focused = false)}
type="text"
- data-quickadd
+ data-quickadd={marked ? '' : undefined}
placeholder={t('task.placeholder')}
- aria-label={t('task.new')}
+ aria-label={fieldLabel}
autocomplete="off"
spellcheck="false"
{onpaste}
@@ -385,8 +440,16 @@
letter-spacing: normal;
padding: 0;
border: 0;
- white-space: pre-wrap;
- overflow-wrap: anywhere;
+ /* `pre`, not `pre-wrap`, and that is the whole alignment.
+
+ An is single-line by specification and cannot wrap
+ whatever CSS says. So a wrapping mirror behind a non-wrapping input does
+ not drift a little — it puts the tint on a second line that the input does
+ not have, and the highlight ends up under empty space. Rare on a wide
+ desktop field, constant in the dictate sheet on a 390px phone, where a
+ spoken sentence overflows immediately. */
+ white-space: pre;
+ overflow-wrap: normal;
}
.mirror {
@@ -394,6 +457,10 @@
inset: 0;
pointer-events: none;
color: transparent;
+ /* Scrolled in step with the input by hand — see `syncScroll`. The overflow
+ has to be hidden rather than visible, or the long tail of a line paints
+ out past the field's edge. */
+ overflow: hidden;
}
/* The tint is behind the glyphs, not on them: colouring the text itself would
diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js
index e50671f..a364a38 100644
--- a/web/src/lib/locales/da.js
+++ b/web/src/lib/locales/da.js
@@ -267,6 +267,11 @@ export const da = {
'task.pastedAdd': 'Opret {n} opgaver',
'task.pastedCancel': 'Fortryd',
'task.new': 'Ny opgave',
+ // Diktat, ikke tale: programmet laver ingen talegenkendelse, det stiller
+ // feltet frem, så tastaturets egen mikrofon kan bruges. Hjælpelinjen siger
+ // det, fordi en knap med en mikrofon på lover noget andet.
+ 'task.speak': 'Tal en opgave ind',
+ 'task.speakHint': 'Tryk mikrofonen på tastaturet, og sig hvad der skal gøres.',
'task.syntaxProject': 'projekt',
'task.syntaxSection': 'sektion',
'task.syntaxLabel': 'etiket',
diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js
index f727494..370664a 100644
--- a/web/src/lib/locales/en.js
+++ b/web/src/lib/locales/en.js
@@ -264,6 +264,8 @@ export const en = {
'task.pastedAdd': 'Add {n} tasks',
'task.pastedCancel': 'Cancel',
'task.new': 'New task',
+ 'task.speak': 'Speak a task',
+ 'task.speakHint': 'Tap the microphone on your keyboard and say what needs doing.',
'task.syntaxProject': 'project',
'task.syntaxSection': 'section',
'task.syntaxLabel': 'label',
diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js
index cc79a31..b4330cd 100644
--- a/web/src/lib/stores.svelte.js
+++ b/web/src/lib/stores.svelte.js
@@ -86,9 +86,26 @@ class AppState {
urdFace = $state(false);
enterUrd() {
- this.urdFace = true;
+ this.setDoor('urd');
+ }
+
+ leaveUrd() {
+ this.setDoor('verdande');
+ }
+
+ /**
+ * Hvilken dør dette vindue sidst kom ind ad.
+ *
+ * Symmetrisk med vilje: `/` er også en dør. Uden den halvdel bliver en fane,
+ * der ÉN gang har set /urd, ved med at hedde urd — også på opgavesiderne, også
+ * efter et link til forsiden. I den installerede app sker det aldrig, for dens
+ * start_url er /urd og den anden apps er /; i en browserfane sker det hele
+ * tiden.
+ */
+ setDoor(door) {
+ this.urdFace = door === 'urd';
try {
- sessionStorage.setItem('verdande:door', 'urd');
+ sessionStorage.setItem('verdande:door', door);
} catch (e) {
// Privat browsing kan nægte. Ansigtet holder så vinduet ud alligevel,
// fordi det står i $state ovenfor — det er kun genindlæsningen, der
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index 02dde93..1cc6ce8 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -6,6 +6,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Sidebar from '$lib/components/Sidebar.svelte';
+ import DictateSheet from '$lib/components/DictateSheet.svelte';
import CommandPalette from '$lib/components/CommandPalette.svelte';
import SignIn from '$lib/components/SignIn.svelte';
import NoConnection from '$lib/components/NoConnection.svelte';
@@ -57,6 +58,25 @@
app.load();
});
+ /**
+ * Hvilken dør dette vindue er inde ad.
+ *
+ * To døre, og reglen er én sætning: den, du sidst kom ind ad, bestemmer
+ * ansigtet. `/urd` sætter urd, `/` sætter verdande, og alt derimellem rører
+ * det ikke — så et klik videre til en note eller en opgave beholder det navn,
+ * vinduet åbnede med.
+ *
+ * Symmetrien er det, der gør det forudsigeligt. Uden `/`-halvdelen ville en
+ * fane, der én gang havde set /urd, blive ved med at hedde urd, også på
+ * opgavesiderne. I de installerede apps sker det aldrig — deres start_url er
+ * hver sin dør — men en browserfane springer mellem dem hele tiden.
+ */
+ $effect(() => {
+ const here = $page.url.pathname;
+ if (here === '/urd') app.enterUrd();
+ else if (here === '/') app.leaveUrd();
+ });
+
/**
* Opgavernes sider findes ikke i noter-udgaven.
*
@@ -295,6 +315,15 @@
tabindex="-1"
>
{/if}
+
+
+
diff --git a/web/src/routes/urd/+page.svelte b/web/src/routes/urd/+page.svelte
index 265668b..5bf3289 100644
--- a/web/src/routes/urd/+page.svelte
+++ b/web/src/routes/urd/+page.svelte
@@ -7,25 +7,18 @@
* hvad appen gør, når den så er startet: husker hvilken dør den kom ind ad, og
* går videre til noterne.
*
- * `sessionStorage` og ikke `localStorage`, og det er hele grunden til at de to
- * apps kan være to apps: de deler origin, så de deler localStorage, cookies og
- * service worker. sessionStorage er per vindue — og en installeret PWA er sit
- * eget vindue — så urd-appen har sit ansigt og verdande-appen sit, uden at
- * træde på hinanden. Det overlever også en genindlæsning, hvilket det skal:
- * efter et par klik står adressen på /noter, og en F5 dér går ikke gennem /urd
- * igen.
+ * Ansigtet sættes ikke her, men i layoutet, hvor begge døre står ved siden af
+ * hinanden — `/urd` sætter urd og `/` sætter verdande. Delt op, fordi en regel
+ * med to halvdele, der ligger i hver sin fil, er en regel, hvor kun den ene
+ * halvdel bliver vedligeholdt.
*
* `replaceState`, så telefonens tilbage-gestus ikke fører tilbage til en side,
* der kun omdirigerer.
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
- import { app } from '$lib/stores.svelte.js';
- onMount(() => {
- app.enterUrd();
- goto('/noter', { replaceState: true });
- });
+ onMount(() => goto('/noter', { replaceState: true }));