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>
664 lines
20 KiB
Svelte
664 lines
20 KiB
Svelte
<script>
|
|
import '../app.css';
|
|
import { app, theme, sidebar } from '$lib/stores.svelte.js';
|
|
import Notifications from '$lib/components/Notifications.svelte';
|
|
import { connectivity } from '$lib/connectivity.svelte.js';
|
|
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';
|
|
import TaskDetail from '$lib/components/TaskDetail.svelte';
|
|
import { t } from '$lib/i18n.svelte.js';
|
|
import { setBadge } from '$lib/badge.js';
|
|
import { open as openInChosenBrowser, stored as storedOpener } from '$lib/linkopen.js';
|
|
import BeaconNotice from '$lib/components/BeaconNotice.svelte';
|
|
|
|
let { children } = $props();
|
|
|
|
let paletteOpen = $state(false);
|
|
let sidebarOpen = $state(false);
|
|
|
|
/**
|
|
* Whether to show the "reconnecting" pill.
|
|
*
|
|
* Held back seven tenths of a second while merely retrying, so a blip that ends
|
|
* on the first retry never flashes a pill nobody needed to see. A retry that has
|
|
* already given up ('lost') is a real error and shown at once — the delay is only
|
|
* for the hopeful case.
|
|
*/
|
|
let showNet = $state(false);
|
|
$effect(() => {
|
|
const s = connectivity.status;
|
|
if (s === 'online') {
|
|
showNet = false;
|
|
return;
|
|
}
|
|
if (s === 'lost') {
|
|
showNet = true;
|
|
return;
|
|
}
|
|
const timer = setTimeout(() => (showNet = true), 700);
|
|
return () => clearTimeout(timer);
|
|
});
|
|
|
|
/**
|
|
* The two pages that arrive by email, and are for somebody who is not signed in.
|
|
*
|
|
* They render on their own rather than inside the shell or behind the sign-in
|
|
* screen. Without this the invite link showed a login form to a person who had
|
|
* no account yet, and the reset link showed one to a person who had forgotten
|
|
* the password it was asking for.
|
|
*/
|
|
let standalone = $derived(['/invite', '/reset'].includes($page.url.pathname));
|
|
|
|
$effect(() => {
|
|
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.
|
|
*
|
|
* Ét sted frem for en vagt i hver rute: listen ER beslutningen, og en vagt pr.
|
|
* fil er syv steder at glemme den ottende. Skrevet som præfikser, fordi
|
|
* `/opgave/<id>` og `/filter/<id>` er sider under dem.
|
|
*
|
|
* Sendt til noterne og ikke til en fejlside: der er ikke sket noget forkert.
|
|
* Adressen hører bare til et andet program end det, der kører her — og nogen,
|
|
* der har gemt et link fra en fuld instans, skal lande et sted, der giver
|
|
* mening frem for i en blindgyde.
|
|
*
|
|
* `replaceState`, så Tilbage ikke fører hen til den side, man lige blev sendt
|
|
* væk fra, og derfra tilbage igen.
|
|
*/
|
|
const TASK_PAGES = [
|
|
'/',
|
|
'/upcoming',
|
|
'/uddelegeret',
|
|
'/kalender',
|
|
'/faerdige',
|
|
'/opgave',
|
|
'/filter',
|
|
'/etiket'
|
|
];
|
|
|
|
$effect(() => {
|
|
if (!app.notesOnly) return;
|
|
const here = $page.url.pathname;
|
|
const taskPage = TASK_PAGES.some((p) => here === p || (p !== '/' && here.startsWith(p + '/')));
|
|
if (taskPage) goto('/noter', { replaceState: true });
|
|
});
|
|
|
|
/**
|
|
* Links ud af programmet, åbnet i den browser, maskinen er sat op til.
|
|
*
|
|
* Ét sted frem for ét pr. komponent. Links står i opgavetitler, i
|
|
* beskrivelser, i noter og i indstillingerne, og en regel, der skulle
|
|
* gentages hvert af de steder, ville mangle det ene sted, ingen tænkte på.
|
|
*
|
|
* Kun udadgående. Programmets egne ruter går gennem SvelteKit og skal blive
|
|
* ved med at gøre det: en fane mere til ens egen notesside er ikke noget,
|
|
* nogen har bedt om.
|
|
*
|
|
* Er der ikke valgt nogen browser — hvilket er udgangspunktet — bliver
|
|
* klikket ikke rørt, og systemet gør, som det plejer.
|
|
*/
|
|
function onLinkClick(event) {
|
|
if (event.defaultPrevented || event.button !== 0) return;
|
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
if (!storedOpener().id) return;
|
|
|
|
const link = event.target?.closest?.('a[href]');
|
|
if (!link || link.target === '_self') return;
|
|
const href = link.getAttribute('href') ?? '';
|
|
if (!/^https?:\/\//i.test(href)) return;
|
|
// Vores egen adresse er ikke et link ud af programmet, uanset hvor den
|
|
// står — en fane mere til den side, man allerede er på, er ingen hjælp.
|
|
if (new URL(href, location.href).origin === location.origin) return;
|
|
|
|
event.preventDefault();
|
|
openInChosenBrowser(href);
|
|
}
|
|
|
|
// Tallet på appens ikon følger klokken. Ét sted, fordi der er ét tal: hentet,
|
|
// skubbet over websocket'en eller læst væk — mærket er det samme svar på alle
|
|
// tre, og en optælling pr. vej ville være tre, der kan blive uenige.
|
|
$effect(() => {
|
|
setBadge(app.unread);
|
|
});
|
|
|
|
/**
|
|
* Global keys.
|
|
*
|
|
* Typing anywhere starts a task, with the letter already in the field. That is
|
|
* the whole gesture: no button to hit first, no shortcut to remember, and the
|
|
* thought you had while looking at a list does not have to survive a journey to
|
|
* find somewhere to put it.
|
|
*
|
|
* It costs the single-letter navigation. "t" for today and "u" for upcoming
|
|
* cannot also be the first letter of "tal med Anders" — one keyboard cannot
|
|
* serve both, and capturing is the thing done fifty times a day. Navigation
|
|
* lives in the palette, which does more and is one keystroke away.
|
|
*
|
|
* Ignored while a field already has focus, while a task is open — the drawer is
|
|
* a place you are, not one you pass through — and for anything held with a
|
|
* modifier, which belongs to the browser and the system.
|
|
*/
|
|
function onkeydown(event) {
|
|
const el = event.target;
|
|
// Only when nothing has focus. The first version asked the narrower question
|
|
// — "is this a text field?" — and so captured a letter typed at a focused
|
|
// select, button or link, which is where that letter belongs. A key pressed
|
|
// at something is meant for it; only a key pressed at the page itself is
|
|
// spare.
|
|
const idle = el === document.body || el === document.documentElement;
|
|
const typing = !idle;
|
|
|
|
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
|
event.preventDefault();
|
|
paletteOpen = true;
|
|
return;
|
|
}
|
|
// Folding the sidebar had a button and nothing else, and a rectangle with a
|
|
// line down it is not a word: somebody who wanted the menu out of the way
|
|
// asked for the feature that was already there. ⌘B is where an editor puts
|
|
// it.
|
|
//
|
|
// Men ikke i en rig editor. Her stod der "works while typing as well — it
|
|
// moves nothing in the field", og det passer for et <input>, hvor ⌘B ikke
|
|
// gør noget. I en contenteditable ER ⌘B fed tekst, og `preventDefault()`
|
|
// nedenfor stoppede browseren i at gøre den — så i noterne foldede
|
|
// sidebjælken sig i stedet for at gøre ordet fedt. Feltet, markøren står i,
|
|
// har førsteret til en tast, det selv bruger.
|
|
const writing = el?.isContentEditable || el?.closest?.('[contenteditable="true"]');
|
|
if (
|
|
(event.metaKey || event.ctrlKey) &&
|
|
(event.key === 'b' || event.key === 'B') &&
|
|
!writing
|
|
) {
|
|
event.preventDefault();
|
|
sidebar.toggle();
|
|
return;
|
|
}
|
|
if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
if (app.detailId) return;
|
|
|
|
// One character, so Tab, Escape, the arrows and the function keys stay
|
|
// themselves. Space is excluded too: a task that begins with a space begins
|
|
// with a mis-hit, and space is what people press to scroll a long list.
|
|
if (event.key.length !== 1 || event.key === ' ') return;
|
|
|
|
// Found by a marker rather than by its label. This used to look the field up
|
|
// by aria-label="Ny opgave", which meant the shortcut quietly did nothing for
|
|
// anybody running the interface in English.
|
|
const field = document.querySelector('[data-quickadd]');
|
|
if (!field) return;
|
|
|
|
event.preventDefault();
|
|
field.focus();
|
|
field.value += event.key;
|
|
// Svelte binds on input, and setting .value in script does not raise one.
|
|
field.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
</script>
|
|
|
|
<svelte:window
|
|
onclick={onLinkClick} {onkeydown} />
|
|
|
|
{#if app.loading}
|
|
<!-- Deliberately blank. A spinner for a request that resolves in 30ms is a
|
|
flash of anxiety, not feedback. -->
|
|
<div class="booting"></div>
|
|
{:else if standalone}
|
|
{@render children()}
|
|
{:else if app.unreachable}
|
|
<!-- Ikke login-skærmen: en total udfald er ikke en udløbet session, og et
|
|
kodeord hjælper ikke mod en manglende forbindelse. -->
|
|
<NoConnection />
|
|
{:else if !app.user}
|
|
<SignIn />
|
|
{:else}
|
|
<div
|
|
class="shell"
|
|
class:sidebar-open={sidebarOpen}
|
|
class:sidebar-collapsed={sidebar.collapsed}
|
|
style="--sidebar-width: {sidebar.width}px"
|
|
>
|
|
<Sidebar onnavigate={() => (sidebarOpen = false)} />
|
|
|
|
<main>
|
|
<header class="topbar">
|
|
<!-- Two controls, not one: on a phone the sidebar is a drawer that
|
|
opens over the page, on a desktop it is a column that folds away.
|
|
Same words, different actions, so each is shown only where it
|
|
applies. -->
|
|
<button
|
|
class="menu"
|
|
onclick={() => (sidebarOpen = !sidebarOpen)}
|
|
aria-label="Vis menu"
|
|
aria-expanded={sidebarOpen}
|
|
>
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M4 7h16M4 12h16M4 17h16" />
|
|
</svg>
|
|
</button>
|
|
|
|
<button
|
|
class="fold"
|
|
onclick={() => sidebar.toggle()}
|
|
aria-label={sidebar.collapsed ? t('nav.showSidebar') : t('nav.hideSidebar')}
|
|
aria-pressed={sidebar.collapsed}
|
|
title={(sidebar.collapsed ? t('nav.showSidebar') : t('nav.hideSidebar')) + ' (⌘B)'}
|
|
>
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<rect x="3" y="4" width="18" height="16" rx="2" />
|
|
<path d="M9 4v16" />
|
|
</svg>
|
|
</button>
|
|
|
|
<button class="search" onclick={() => (paletteOpen = true)}>
|
|
<span>{t('nav.search')}</span>
|
|
<kbd>⌘K</kbd>
|
|
</button>
|
|
|
|
<Notifications />
|
|
|
|
<button
|
|
class="theme"
|
|
onclick={() => theme.toggle()}
|
|
aria-label={t('nav.toggleTheme')}
|
|
>
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z" />
|
|
</svg>
|
|
</button>
|
|
</header>
|
|
|
|
<div class="content">
|
|
<!-- Sagt uopfordret, første gang en administrator er logget ind: hvad
|
|
denne installation melder ind, og hvordan man siger nej.
|
|
Øverst i indholdet og ikke svævende over det — en rude, der
|
|
ligger oven på arbejdet, dækker knapper, og en besked, der er i
|
|
vejen, bliver klikket væk uden at blive læst. Komponenten tegner
|
|
ingenting, når der ikke er noget at fortælle. -->
|
|
<BeaconNotice />
|
|
{@render children()}
|
|
</div>
|
|
</main>
|
|
|
|
{#if sidebarOpen}
|
|
<button
|
|
class="scrim"
|
|
onclick={() => (sidebarOpen = false)}
|
|
aria-label={t('nav.closeMenu')}
|
|
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} />
|
|
|
|
<!-- Mounted here rather than in each view, so a task opens the same way from
|
|
Today, a project, a label or a saved filter. -->
|
|
{#if app.detailTask}
|
|
<TaskDetail task={app.detailTask} onclose={() => app.closeDetail()} />
|
|
{/if}
|
|
|
|
<!-- Toasts are for things that failed after the interface already said they
|
|
had succeeded. Nothing else earns an interruption. -->
|
|
<div class="toasts" role="status" aria-live="polite">
|
|
{#each app.toasts as toast (toast.id)}
|
|
<div class="toast">
|
|
<!-- The message dismisses, the action acts. They were one button before,
|
|
which is fine while the only thing a toast says is "that failed" —
|
|
and wrong the moment it offers to undo something, because then a
|
|
click meaning "yes, put it back" and a click meaning "go away" land
|
|
in the same place. -->
|
|
<button class="body" onclick={() => app.dismissToast(toast.id)}>
|
|
{toast.message}
|
|
</button>
|
|
{#if toast.action}
|
|
<button
|
|
class="action"
|
|
onclick={() => {
|
|
app.dismissToast(toast.id);
|
|
toast.onaction?.();
|
|
}}>{toast.action}</button
|
|
>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- A short drop, said quietly: it appears only if a save is still retrying after
|
|
the better part of a second, and leaves the moment the connection is back — so
|
|
a two-second blip that heals itself is felt as nothing at all. -->
|
|
{#if showNet}
|
|
<div class="netstatus" class:lost={connectivity.status === 'lost'} role="status">
|
|
<span class="net-dot" aria-hidden="true"></span>
|
|
{connectivity.status === 'lost' ? t('net.offline') : t('net.reconnecting')}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<style>
|
|
.booting {
|
|
height: 100dvh;
|
|
background: var(--ground);
|
|
}
|
|
|
|
.shell {
|
|
display: flex;
|
|
height: 100dvh;
|
|
overflow: hidden;
|
|
}
|
|
|
|
main {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
background: var(--ground);
|
|
}
|
|
|
|
.topbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--s3);
|
|
padding: var(--s3) var(--s4);
|
|
border-bottom: 1px solid var(--line);
|
|
flex: none;
|
|
/* Clears the notch on a phone in landscape. */
|
|
padding-left: max(var(--s4), env(safe-area-inset-left));
|
|
padding-right: max(var(--s4), env(safe-area-inset-right));
|
|
}
|
|
|
|
.menu {
|
|
display: none;
|
|
width: 32px;
|
|
height: 32px;
|
|
place-items: center;
|
|
border-radius: var(--radius);
|
|
color: var(--ink-muted);
|
|
}
|
|
|
|
.fold {
|
|
display: grid;
|
|
width: 32px;
|
|
height: 32px;
|
|
place-items: center;
|
|
border-radius: var(--radius);
|
|
color: var(--ink-faint);
|
|
transition: color var(--fast) var(--ease);
|
|
}
|
|
|
|
.fold:hover {
|
|
color: var(--ink);
|
|
}
|
|
|
|
/* Folded away rather than merely narrow: width 0 with the border gone, so
|
|
nothing of it is left to catch the eye. */
|
|
.shell.sidebar-collapsed :global(.sidebar) {
|
|
width: 0;
|
|
padding-left: 0;
|
|
padding-right: 0;
|
|
border-right: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.menu svg,
|
|
.fold svg,
|
|
.theme svg {
|
|
width: 18px;
|
|
height: 18px;
|
|
fill: none;
|
|
stroke: currentColor;
|
|
stroke-width: 1.75;
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
}
|
|
|
|
.search {
|
|
flex: 1;
|
|
max-width: 320px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--s3);
|
|
padding: var(--s2) var(--s3);
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius);
|
|
color: var(--ink-faint);
|
|
font-size: var(--text-sm);
|
|
transition: border-color var(--fast) var(--ease);
|
|
}
|
|
|
|
.search:hover {
|
|
border-color: var(--line-strong);
|
|
}
|
|
|
|
kbd {
|
|
font-family: var(--font);
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-faint);
|
|
background: var(--surface-sunken);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius-sm);
|
|
padding: 1px var(--s1);
|
|
}
|
|
|
|
.theme {
|
|
margin-left: auto;
|
|
width: 32px;
|
|
height: 32px;
|
|
display: grid;
|
|
place-items: center;
|
|
border-radius: var(--radius);
|
|
color: var(--ink-muted);
|
|
transition: color var(--fast) var(--ease);
|
|
}
|
|
|
|
.theme:hover {
|
|
color: var(--ink);
|
|
}
|
|
|
|
.content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
overscroll-behavior: contain;
|
|
}
|
|
|
|
.scrim {
|
|
display: none;
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgb(0 0 0 / 0.4);
|
|
border: 0;
|
|
z-index: 40;
|
|
}
|
|
|
|
.toast .body {
|
|
flex: 1;
|
|
min-width: 0;
|
|
text-align: left;
|
|
color: inherit;
|
|
font: inherit;
|
|
}
|
|
|
|
/* Set apart, because it is the one thing in a toast that does something rather
|
|
than says something. */
|
|
.toast .action {
|
|
flex: none;
|
|
padding: var(--s1) var(--s2);
|
|
margin: calc(var(--s1) * -1) calc(var(--s1) * -1) calc(var(--s1) * -1) 0;
|
|
border-radius: var(--radius-sm);
|
|
font-weight: 560;
|
|
color: var(--accent);
|
|
}
|
|
|
|
.toast .action:hover {
|
|
background: var(--surface);
|
|
}
|
|
|
|
.toasts {
|
|
position: fixed;
|
|
bottom: var(--s4);
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--s2);
|
|
z-index: 60;
|
|
padding-bottom: env(safe-area-inset-bottom);
|
|
}
|
|
|
|
/* The reconnecting pill: at the top, small, centred. Not a toast — a toast is a
|
|
thing that happened and is over, this is a thing still happening. */
|
|
.netstatus {
|
|
position: fixed;
|
|
top: calc(var(--s3) + env(safe-area-inset-top));
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
z-index: 60;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--s2);
|
|
padding: var(--s1) var(--s3);
|
|
border-radius: var(--radius-full);
|
|
background: var(--surface-raised);
|
|
border: 1px solid var(--line-strong);
|
|
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.14);
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-muted);
|
|
}
|
|
|
|
.net-dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
flex: none;
|
|
border-radius: var(--radius-full);
|
|
background: var(--color-amber);
|
|
animation: net-pulse 1.1s ease-in-out infinite;
|
|
}
|
|
|
|
/* Given up: an amber pulse becomes a still red, the same red a failed toast
|
|
carries, so "still trying" and "gave up" do not look alike. */
|
|
.netstatus.lost {
|
|
color: var(--ink);
|
|
}
|
|
|
|
.netstatus.lost .net-dot {
|
|
background: var(--p1);
|
|
animation: none;
|
|
}
|
|
|
|
@keyframes net-pulse {
|
|
0%,
|
|
100% {
|
|
opacity: 0.35;
|
|
}
|
|
50% {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.net-dot {
|
|
animation: none;
|
|
}
|
|
}
|
|
|
|
.toast {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--s3);
|
|
background: var(--surface-raised);
|
|
border: 1px solid var(--line-strong);
|
|
/* The red edge said "something went wrong", which was true while that was
|
|
all a toast could say. An undo is not a failure — it is the app offering
|
|
to put something back — so the edge follows the message. */
|
|
border-left: 2px solid var(--danger);
|
|
border-radius: var(--radius);
|
|
box-shadow: var(--shadow-lg);
|
|
padding: var(--s3) var(--s4);
|
|
font-size: var(--text-sm);
|
|
color: var(--ink);
|
|
max-width: 420px;
|
|
text-align: left;
|
|
}
|
|
|
|
.toast:has(.action) {
|
|
border-left-color: var(--accent);
|
|
}
|
|
|
|
/* Below the tablet breakpoint the sidebar becomes a drawer. The layout is
|
|
mobile-first in behaviour even though the rules read desktop-first: the
|
|
drawer is the exception, and writing it as one keeps it in a single block. */
|
|
@media (max-width: 820px) {
|
|
.menu {
|
|
display: grid;
|
|
}
|
|
|
|
/* The drawer is the whole answer here; folding a drawer means nothing. */
|
|
.fold {
|
|
display: none;
|
|
}
|
|
|
|
.scrim {
|
|
display: block;
|
|
}
|
|
|
|
.shell :global(.sidebar) {
|
|
position: fixed;
|
|
inset: 0 auto 0 0;
|
|
z-index: 50;
|
|
transform: translateX(-100%);
|
|
transition: transform var(--medium) var(--ease-out);
|
|
}
|
|
|
|
.shell.sidebar-open :global(.sidebar) {
|
|
transform: translateX(0);
|
|
}
|
|
|
|
/* A collapsed desktop sidebar must not follow you to a phone, where the
|
|
drawer would then open to nothing. */
|
|
.shell.sidebar-collapsed :global(.sidebar) {
|
|
width: var(--sidebar-width);
|
|
padding-left: max(var(--s3), env(safe-area-inset-left));
|
|
padding-right: var(--s3);
|
|
border-right: 1px solid var(--line);
|
|
}
|
|
}
|
|
</style>
|