Merge branch 'main' into markoer-i-titlen

Begge grene føjede prøver til i enden af smoke.spec.js, og begge beholdes:
fire markør-prøver og fire fra tal-en-opgave-ind. Den fælles `/**` ovenfor
dækkede kun den første, så den anden blok har fået sin egen.

Suiten på den løste fletning: 129 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-10-08 10:06:42 +02:00
co-authored by Claude Opus 5
8 changed files with 469 additions and 20 deletions
+243
View File
@@ -0,0 +1,243 @@
<script>
/**
* Tal en opgave ind.
*
* En stor rund knap i bunden af skærmen, 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, og det er en datavej, der skal forklares på privatlivssiden for at
* spare ét tryk. Teksten lander i det felt, der i forvejen viser, hvad parseren
* forstod — så "ring til tandlægen i morgen kl 10 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.
*/
import QuickAdd from './QuickAdd.svelte';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
let open = $state(false);
let box;
let sheet;
/**
* Åbner og sætter fokus I SAMME HUG som klikket.
*
* Rækkefølgen her er hele funktionen. Safari på iOS åbner kun tastaturet, hvis
* `focus()` kaldes inde i den brugerhandling, der bad om det — og Sveltes
* opdateringer er asynkrone, så feltet kan ikke være betinget tegnet og få
* fokus i samme omgang. Derfor er skuffen ALTID monteret og bare skubbet ud af
* syne med `transform` og `opacity`: elementet findes, så det kan fokuseres nu
* frem for efter næste opdatering.
*
* `display: none` og `visibility: hidden` ville begge gøre feltet ufokuserbart,
* og `inert` ligeså. Det er grunden til, at den lukkede tilstand er gennemsigtig
* og uden pointer-events frem for skjult.
*/
function start() {
// `inert` fjernes med hånden her, og det er ikke sjusk. Attributten sættes
// reaktivt af `inert={!open}` nedenfor, så den er der stadig i det øjeblik,
// klikket kører — Sveltes opdateringer er asynkrone. Og et inert element kan
// ikke fokuseres, så `focusField()` ville ramme ingenting, og iOS ville ikke
// åbne tastaturet.
//
// Den findes, fordi alternativet er værre: uden den kan skuffens knapper
// tabbes til, mens den er usynlig, på hver eneste side.
sheet?.removeAttribute('inert');
open = true;
box?.focusField();
}
function close() {
open = false;
// Tastaturet bliver stående, hvis feltet beholder fokus.
if (sheet?.contains(document.activeElement)) document.activeElement.blur();
}
function onkeydown(event) {
if (event.key === 'Escape' && open) {
event.preventDefault();
close();
}
}
</script>
<svelte:window {onkeydown} />
<!-- Knappen tegnes ikke i urd-ansigtet: der er ingen opgaver at lægge noget i.
Og den er skjult over 820px i CSS frem for her, fordi en skærm, der skifter
bredde, ikke skal kunne tabe et felt, der har fokus. -->
{#if !app.showsUrd}
<button class="fab" class:away={open} onclick={start} aria-label={t('task.speak')}>
<!-- En mikrofon, tegnet i samme streger som mærkerne: kapslen, bøjlen under
den og foden. Ingen tekst i knappen — den er rund og tommelfingerstor,
og navnet står i aria-label. -->
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="11" rx="3" />
<path d="M5.5 11a6.5 6.5 0 0 0 13 0" />
<path d="M12 17.5V21" />
</svg>
</button>
{/if}
<div
class="scrim"
class:open
aria-hidden="true"
onclick={close}
role="presentation"
></div>
<div
class="sheet"
class:open
bind:this={sheet}
role="dialog"
aria-modal="false"
aria-label={t('task.speak')}
aria-hidden={!open}
inert={!open}
>
<div class="grab" aria-hidden="true"></div>
<p class="hint">{t('task.speakHint')}</p>
<!-- `marked={false}`: den her må ikke være den, type-anywhere-genvejen finder.
Se kommentaren ved `marked` i QuickAdd. -->
<QuickAdd bind:this={box} marked={false} label={t('task.speak')} onadded={close} />
<button class="done" onclick={close}>{t('detail.close')}</button>
</div>
<style>
.fab {
position: fixed;
right: max(var(--s4), env(safe-area-inset-right));
/* Over hjemmeindikatoren på en iPhone, ikke under den. */
bottom: calc(var(--s4) + env(safe-area-inset-bottom));
z-index: 40;
width: 64px;
height: 64px;
border: none;
border-radius: var(--radius-full);
background: var(--accent);
color: var(--accent-ink);
box-shadow: var(--shadow-lg);
display: grid;
place-items: center;
cursor: pointer;
transition:
transform var(--fast) var(--ease-out),
opacity var(--fast) var(--ease-out);
}
.fab svg {
width: 28px;
height: 28px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.fab:active {
transform: scale(0.94);
}
/* Væk, mens skuffen er åben: den ville ellers stå oven på feltet. */
.fab.away {
opacity: 0;
pointer-events: none;
transform: translateY(8px);
}
.scrim {
position: fixed;
inset: 0;
z-index: 44;
background: rgb(0 0 0 / 0.4);
opacity: 0;
pointer-events: none;
transition: opacity var(--medium) var(--ease-out);
}
.scrim.open {
opacity: 1;
pointer-events: auto;
}
.sheet {
position: fixed;
inset: auto 0 0 0;
z-index: 45;
padding: var(--s3) max(var(--s3), env(safe-area-inset-left))
calc(var(--s3) + env(safe-area-inset-bottom)) max(var(--s3), env(safe-area-inset-right));
background: var(--surface-raised);
border-top: 1px solid var(--line);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
box-shadow: var(--shadow-lg);
display: grid;
gap: var(--s2);
/* Lukket: gennemsigtig og skubbet ned, IKKE display:none — feltet skal kunne
fokuseres i samme hug som klikket. Se `start()`. */
opacity: 0;
transform: translateY(100%);
pointer-events: none;
transition:
transform var(--medium) var(--ease-out),
opacity var(--medium) var(--ease-out);
}
.sheet.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.grab {
width: 36px;
height: 4px;
border-radius: var(--radius-full);
background: var(--line-strong);
justify-self: center;
}
.hint {
margin: 0;
color: var(--ink-muted);
font-size: 0.85rem;
text-align: center;
}
.done {
justify-self: center;
background: none;
border: none;
color: var(--ink-muted);
padding: var(--s2);
cursor: pointer;
}
/* En skrivebordsskærm har feltet fremme hele tiden og et tastatur at skrive
på. Knappen er til tommelfingeren. */
@media (min-width: 821px) {
.fab,
.scrim,
.sheet {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.fab,
.scrim,
.sheet {
transition: none;
}
}
</style>
+73 -6
View File
@@ -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 @@
<!-- The mirror sits behind the input and must match it exactly: same font,
same padding, same wrapping. Any difference shows up as highlighting
that drifts out of alignment as the line grows. -->
<div class="mirror" aria-hidden="true">
<div class="mirror" aria-hidden="true" bind:this={mirror}>
{#each segments as segment}
{#if segment.kind}
<mark data-kind={segment.kind}>{segment.text}</mark>
@@ -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 <input type="text"> 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
+5
View File
@@ -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',
+2
View File
@@ -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',
+19 -2
View File
@@ -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