feat(opgaver): tal en opgave ind — en rund knap i bunden på telefonen

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
<input type="text"> 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 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-10-08 06:50:56 +02:00
co-authored by Claude Opus 5
parent 807aa83e58
commit 664614b924
8 changed files with 460 additions and 20 deletions
+29
View File
@@ -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"
></button>
{/if}
<!-- Tal en opgave ind. Kun på telefonen, og kun hvor der er opgaver — se
komponenten.
UBETINGET monteret, og det er ikke en forglemmelse: feltet inde i den
skal kunne få fokus i samme hug som klikket, ellers åbner iOS ikke
tastaturet. Et `{#if}` her ville tage feltet ud af DOM'en og gøre det
umuligt. Menuen dækker den i stedet, fordi den har højere z-index. -->
<DictateSheet />
</div>
<CommandPalette bind:open={paletteOpen} />