Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857<script lang="ts"> import { onDestroy } from 'svelte'; import { browse, createSession, knownHosts, models, profiles, recentWorkspaces, sendMessage, uuidv7, type Directory, type Model, type Profile, type RecentWorkspace, type Session } from './api'; import { boards } from './boards.svelte'; import { effortTone } from './context'; import EffortBar from './EffortBar.svelte'; import { bareHost, folderRequest, hostRows, parseQuery, splitHost, upQuery } from './folderQuery'; import { homePath, rank } from './format'; import { nextModelEffort } from './modelEffort'; import { keepFocus } from './popover'; import { sessions } from './sessions.svelte';
// Starting a session as three cards, one question each: where, which // model, what to do. The trail along the top says what was picked and // leads back to each card; Esc steps back. A dialog on the canvas, the // whole screen on a phone. let { onclose }: { onclose: () => void } = $props();
// `inside` is a listed folder you can open; the listed folder itself has it false. type FolderRow = { key: string; name: string; note: string; location: string | null; complete: string; inside?: boolean }; type ModelRow = { key: string; profile: string; model: string; efforts: string[] | null };
const QUESTIONS = ['where to?', 'which model?', 'what should we do?']; const USED = 3;
let dialog = $state<HTMLDialogElement>(); let step = $state(0); let stepAtKey = 0;
let recent = $state.raw<RecentWorkspace[]>([]); let hosts = $state.raw<string[]>([]); let listings = $state.raw<Record<string, Directory | Error | null>>({}); let query = $state(''); let hi = $state(0); let folder = $state<string | null>(null);
let all = $state.raw<Profile[]>([]); let fallback = $state<string | null>(null); let catalog = $state.raw<Record<string, Model[]>>({}); let filter = $state(''); let highlighted = $state<string | null>(null); let picked = $state<ModelRow | null>(null); let effort = $state<string | null>(null);
let text = $state(''); let starting = $state(false); let problem = $state(''); let made: Session | null = null; const sendId = uuidv7();
onDestroy(keepFocus());
$effect(() => { dialog?.showModal(); recentWorkspaces().then((found) => (recent = found), () => {}); knownHosts().then((found) => (hosts = found.map((host) => host.target)), () => {}); profiles() .then((found) => { all = found.profiles; fallback = found.fallback ?? found.profiles[0]?.id ?? null; for (const p of found.profiles) models(p.id).then( (items) => (catalog = { ...catalog, [p.id]: items }), () => {}, ); }) .catch((error) => (problem = String((error as Error).message ?? error))); });
// where
const parsed = $derived(parseQuery(query)); const listed = $derived(parsed.kind === 'listing' ? folderRequest(parsed.dir) : null); const listing = $derived(listed ? listings[listed] : undefined);
$effect(() => { const location = listed; if (!location || location in listings) return; listings = { ...listings, [location]: null }; browse(location).then( (found) => (listings = { ...listings, [location]: found }), (error) => (listings = { ...listings, [location]: error instanceof Error ? error : new Error(String(error)) }), ); });
/** A location as it reads in the field: a local one under ~, a remote one led by its host. */ function typedOf(location: string): string { const [host, path] = splitHost(location); return host ? `${host}:${homePath(path)}` : homePath(location); }
function nameOf(location: string): string { const [, path] = splitHost(location); return path.replace(/\/+$/, '').split('/').pop() || path; }
// Remote hosts sessions worked on count as known even when the daemon lists none. const known = $derived([...new Set([...hosts, ...recent.flatMap((entry) => splitHost(entry.location)[0] ?? [])])]);
const folderRows = $derived.by((): FolderRow[] => { if (parsed.kind === 'listing') { if (!listing || listing instanceof Error) return []; const hidden = parsed.segment.startsWith('.'); const items = listing.items.filter((item) => hidden || !item.hidden); const here = parsed.segment ? [] : [{ key: `here:${listing.directory}`, name: nameOf(listing.directory), note: 'this folder', location: listing.directory, complete: parsed.dir, inside: false }]; const children = rank(items, parsed.segment, (item) => item.name).map((item) => ({ key: item.location, name: item.name, note: item.vcs ?? '', location: item.location, complete: parsed.dir + item.name, inside: true, })); return [...here, ...children]; } const folders = parsed.kind === 'recent' ? rank(recent, parsed.segment, (entry) => nameOf(entry.location)) : []; return [ ...folders.map((entry) => { const [host, path] = splitHost(entry.location); return { key: entry.location, name: nameOf(entry.location), note: host ? `on ${bareHost(host)}` : homePath(path.replace(/\/[^/]*\/?$/, '')) || '/', location: entry.location, complete: typedOf(entry.location), }; }), ...hostRows(parsed, known).map((host) => ({ key: `host:${host}`, name: bareHost(host), note: 'a host', location: null, complete: `${host}:` })), ]; });
const ghost = $derived.by(() => { const row = folderRows[hi]; if (!query || !row || !row.complete.toLowerCase().startsWith(query.toLowerCase())) return ''; return row.complete.slice(query.length); });
function setQuery(next: string) { query = next; hi = 0; }
function choose(row: FolderRow | undefined) { if (!row) return; if (!row.location) return setQuery(row.complete); folder = row.location; step = 1; highlighted = picked?.key ?? null; filter = ''; }
const moveOf = (event: KeyboardEvent): number | undefined => ({ ArrowDown: 1, ArrowUp: -1 })[event.key] ?? (event.ctrlKey ? { n: 1, p: -1 }[event.key] : undefined); const caretAtEnd = (field: HTMLInputElement) => field.selectionStart === field.value.length && field.selectionEnd === field.value.length;
function whereKey(event: KeyboardEvent) { const move = moveOf(event); if (move) { event.preventDefault(); if (folderRows.length) hi = (hi + move + folderRows.length) % folderRows.length; } else if (event.key === 'Enter' && !event.isComposing) { event.preventDefault(); choose(folderRows[hi]); } else if (event.key === 'Tab' && !event.shiftKey) { const row = folderRows[hi]; if (!row || row.inside === false) return; event.preventDefault(); setQuery(row.location ? `${row.complete}/` : row.complete); } else if (event.key === 'Backspace' && query.endsWith('/') && query.length > 2 && caretAtEnd(event.currentTarget as HTMLInputElement)) { event.preventDefault(); setQuery(upQuery(query)); } }
// which model
const profileOf = (id: string) => all.find((p) => p.id === id);
function rowOf(profile: string, model: string): ModelRow { const entry = catalog[profile]?.find((m) => m.id === model); return { key: `${profile}/${model}`, profile, model, efforts: entry ? entry.efforts.map((e) => e.id) : null }; }
// Each profile's catalogue, or its configured model until that arrives. const everyModel = $derived( all.flatMap((p) => (catalog[p.id]?.length ? catalog[p.id].map((m) => rowOf(p.id, m.id)) : p.model ? [rowOf(p.id, p.model)] : [])), );
const preset = $derived(fallback ? profileOf(fallback) : undefined);
// The models of the latest sessions, then the default /model set. const lead = $derived.by(() => { const keys = new Set<string>(); const rows: ModelRow[] = []; const add = (profile: string | null | undefined, model: string | null | undefined) => { const key = `${profile}/${model}`; if (!profile || !model || keys.has(key) || !profileOf(profile)) return; keys.add(key); rows.push(rowOf(profile, model)); }; for (const session of [...sessions.list].sort((a, b) => (b.activity_at ?? '').localeCompare(a.activity_at ?? ''))) { if (rows.length >= USED) break; add(session.provider_profile, session.model); } add(preset?.id, preset?.model); return rows; }); const defaultKey = $derived(preset?.model ? `${preset.id}/${preset.model}` : null);
const modelRows = $derived( filter.trim() ? rank(everyModel, filter, (row) => [row.model, row.profile]) : [...lead, ...everyModel.filter((row) => !lead.some((first) => first.key === row.key))], ); const mhi = $derived(Math.max(0, modelRows.findIndex((row) => row.key === (highlighted ?? (filter.trim() ? null : defaultKey))))); const current = $derived(modelRows[mhi]);
function effortOf(row: ModelRow | undefined): string | null { if (!row) return null; const preset = profileOf(row.profile)?.effort ?? null; return nextModelEffort(row.efforts, effort ?? preset, preset, true); } const shownEffort = $derived(effortOf(current));
function pickModel(row: ModelRow | undefined) { if (!row) return; picked = row; effort = effortOf(row); step = 2; }
function modelKey(event: KeyboardEvent) { const move = moveOf(event); const efforts = current?.efforts ?? []; if (move) { event.preventDefault(); if (modelRows.length) highlighted = modelRows[(mhi + move + modelRows.length) % modelRows.length].key; } else if ((event.key === 'ArrowLeft' || event.key === 'ArrowRight') && !filter && efforts.length) { event.preventDefault(); const at = efforts.indexOf(shownEffort ?? ''); const next = (at < 0 ? efforts.length - 1 : at) + (event.key === 'ArrowRight' ? 1 : -1); effort = efforts[Math.max(0, Math.min(efforts.length - 1, next))]; } else if (event.key === 'Enter' && !event.isComposing) { event.preventDefault(); pickModel(current); } }
// the trail and the face
const pickedEffort = $derived(picked ? effortOf(picked) : null);
const FACES: Record<string, string> = { none: '(-ω-) zz', low: '(-ω-) zz', medium: '(・ω・)', high: '(•̀ω•́ )✧', xhigh: '(ノ゚Д゚)ノ !!' }; const face = $derived.by(() => { if (step === 2) return text.trim() ? 'φ(・ω・ )' : '(`・ω・´)ゞ'; if (step === 1) return FACES[effortTone(shownEffort) ?? ''] ?? '(・ω・)?'; if (query && !folderRows.length && listing !== null) return '(・_・;)'; if (parsed.kind === 'listing') return splitHost(parsed.dir)[0] ? '( ・ω・)ノ~' : '( •̀ω•́ )ゞ'; return query ? '(・ω・ )?' : '( ˘ω˘ )'; });
function goto(card: number) { step = card; setQuery(''); filter = ''; highlighted = picked?.key ?? null; }
function back() { if (starting) return; if (step > 0) step--; else if (query) setQuery(''); else onclose(); }
async function start() { if (!folder || !picked || starting) return; starting = true; problem = ''; try { made ??= await createSession(folder, { provider_profile: picked.profile, model: picked.model, effort: pickedEffort }); if (text.trim()) await sendMessage(made.id, sendId, text); await sessions.refresh(); onclose(); boards.openSession(made); } catch (error) { problem = String((error as Error).message ?? error); starting = false; } }
function messageKey(event: KeyboardEvent) { if (event.key !== 'Enter' || event.shiftKey || event.isComposing || stepAtKey !== 2 || step !== 2) return; if (event.target instanceof HTMLButtonElement) return; event.preventDefault(); start(); }
// The list scrolls under a fade while more rows wait below it; the // highlighted row is kept in view as the keys move it. let list = $state<HTMLElement>(); let more = $state(false); const fade = () => list && (more = list.scrollTop + list.clientHeight < list.scrollHeight - 1);
$effect(() => { void [hi, mhi, folderRows, modelRows]; list?.querySelector('.hi')?.scrollIntoView({ block: 'nearest' }); fade(); });
function focusOnMount(field: HTMLInputElement | HTMLTextAreaElement) { field.focus(); field.setSelectionRange(field.value.length, field.value.length); }
const startName = $derived(recent[0] ? nameOf(recent[0].location) : 'a folder'); const folderHost = $derived(folder ? splitHost(folder)[0] : null);</script>
<dialog bind:this={dialog} onclose={onclose} oncancel={(event) => (event.preventDefault(), back())} onclick={(event) => event.target === dialog && onclose()} onkeydowncapture={() => (stepAtKey = step)} onkeydown={messageKey} aria-label="new session"> <div class="sheet"> <div class="head"> <div class="trail"> <span class="bit where"> {#if folder} <span class="fixed">in</span> <button type="button" class="pick" onclick={() => goto(0)}> <span class="u">{nameOf(folder)}{folderHost ? ` on ${bareHost(folderHost)}` : ''}</span> </button> {:else} <span class="now">in …</span> {/if} </span> {#if step >= 1 || picked} <span class="bit using"> {#if picked} <span class="fixed">using</span> <button type="button" class="pick" onclick={() => goto(1)}> <span class="u">{picked.model}</span>{#if pickedEffort}<span class="u fixed">:{pickedEffort}</span>{/if} </button> {:else} <span class="now">using …</span> {/if} </span> {/if} {#if step >= 2} <span class="bit fixed end"> ,<button type="button" class="start" onclick={start}><span class="u">start!</span><kbd>↵</kbd></button> </span> {/if} </div> <span class="face" aria-hidden="true">{face}</span> </div>
{#snippet question(card: number)} <span class="q">{QUESTIONS[card]}</span> {/snippet}
{#if step === 0} <label class="ask"> {@render question(0)} <span class="field"> <span class="ghost" aria-hidden="true">{query}<i>{ghost}</i></span> <input value={query} oninput={(event) => setQuery(event.currentTarget.value)} onkeydown={whereKey} placeholder="{startName}, or a path" spellcheck="false" autocomplete="off" {@attach focusOnMount} /> {#if parsed.kind !== 'listing'} <button type="button" class="aside" onpointerdown={(event) => event.preventDefault()} onclick={() => setQuery('~/')}><svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M2.5 4.5a1 1 0 0 1 1-1h3l1.5 1.5h4.5a1 1 0 0 1 1 1v5.5a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" /></svg>browse</button> {:else if listing && !(listing instanceof Error) && listing.parent} <button type="button" class="aside" onpointerdown={(event) => event.preventDefault()} onclick={() => setQuery(`${typedOf(listing.parent!)}/`)} aria-label="up a folder" title="up a folder (⌫)"><svg viewBox="0 0 16 16" width="15" height="13" aria-hidden="true"><path d="M5.5 3.5h7.5a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5.5L1.75 8z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" /><path d="M7.5 6l4 4M11.5 6l-4 4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" /></svg>up</button> {/if} </span> </label> <div class="rows" class:more bind:this={list} onscroll={fade}> {#each folderRows as row, i (row.key)} <div class="row" class:hi={i === hi} role="presentation" onpointermove={() => (hi = i)}> <button type="button" class="pickrow" onpointerdown={(event) => event.preventDefault()} onclick={() => choose(row)}> <span class="name">{row.name}</span> {#if row.inside === false} <span class="here">{row.note}</span> {:else} <span class="note">{row.note}</span> {/if} </button> {#if row.inside} <button type="button" class="into" onpointerdown={(event) => event.preventDefault()} onclick={() => setQuery(`${row.complete}/`)} aria-label="open {row.name}" title="open (tab)">›</button> {:else if row.inside === false} <span class="into"></span> {/if} </div> {:else} <p class="empty" class:problem={listing instanceof Error}> {listing instanceof Error ? listing.message : listing === null ? 'looking…' : query ? 'nothing here' : 'no folders yet: type a path'} </p> {/each} </div> {:else if step === 1} <label class="ask"> {@render question(1)} <span class="field"> <input bind:value={filter} oninput={() => (highlighted = null)} onkeydown={modelKey} placeholder="{preset?.model ?? 'a model'}, or another" spellcheck="false" autocomplete="off" {@attach focusOnMount} /> </span> </label> <div class="rows" class:more bind:this={list} onscroll={fade}> {#each modelRows as row, i (row.key)} <button type="button" class="row" class:hi={i === mhi} onpointermove={() => (highlighted = row.key)} onclick={() => pickModel(row)}> <span class="name">{row.model}</span> <span class="note">{row.profile}</span> </button> {:else} <p class="empty">{all.length ? 'no models match' : 'loading models…'}</p> {/each} </div> <div class="effort" class:bare={!current?.efforts?.length}> {#if current?.efforts?.length} <EffortBar efforts={current.efforts} value={shownEffort} onpick={(next) => (effort = next)} /> {:else} <span class="none">{current?.efforts ? 'no effort levels' : ' '}</span> {/if} </div> {:else} <label class="ask grow"> {@render question(2)} <textarea bind:value={text} placeholder="or leave it empty and just start" {@attach focusOnMount}></textarea> </label> {/if} {#if problem}<p class="problem">{problem}</p>{/if}
{#if starting} <div class="done"> <b>ヽ(・∀・)ノ</b> <span>see you in {folder ? nameOf(folder) : ''}</span> </div> {/if} </div></dialog>
<style> dialog { width: min(460px, calc(100vw - 24px)); height: min(360px, calc(100dvh - 48px)); padding: 0; overflow: hidden; color: var(--text); background: var(--surface); border: 0; border-radius: 14px; box-shadow: var(--shadow-raised); } dialog::backdrop { background: var(--scrim); } .sheet { position: relative; display: flex; flex-direction: column; height: 100%; padding: 0 16px 16px; font-size: calc(13.5px * var(--ui-scale)); } .head { display: flex; flex: none; align-items: center; gap: 10px; height: 42px; margin-bottom: 6px; } .face { flex: none; margin-left: auto; padding: 0 2px 0 12px; color: var(--accent); font: 500 calc(13.5px * var(--ui-scale)) var(--face); white-space: nowrap; }
.trail { display: flex; flex: 1; align-items: baseline; min-width: 0; color: var(--faint); font-size: calc(12.5px * var(--ui-scale)); line-height: 20px; white-space: nowrap; } .bit { display: flex; align-items: baseline; gap: 0.28em; min-width: 0; overflow: hidden; animation: arrive 0.3s ease; } .bit + .bit { margin-left: 0.28em; } .fixed { flex: none; } .bit + .end { margin-left: 0; } @keyframes arrive { from { opacity: 0; } } .now { color: var(--accent); } .trail button { min-width: 0; margin: 0; padding: 0 1px; color: var(--text); font: inherit; background: none; border: 0; cursor: pointer; } .pick { display: inline-flex; align-items: baseline; overflow: hidden; } .where { flex-shrink: 0; max-width: 40%; } .using { flex-shrink: 4; } .u { min-width: 0; overflow: hidden; padding-bottom: 1px; text-overflow: ellipsis; background: linear-gradient(color-mix(in oklab, currentColor 28%, transparent), color-mix(in oklab, currentColor 28%, transparent)) 0 100% / 100% 1.5px no-repeat; } .u.fixed { overflow: visible; } .trail button:hover .u { background-image: linear-gradient(color-mix(in oklab, currentColor 60%, transparent), color-mix(in oklab, currentColor 60%, transparent)); } .trail .start { display: inline-flex; padding: 1px 4px; color: var(--accent); font-weight: 600; border-radius: 6px; transition: background-color 120ms ease; } .trail .start:hover { background: color-mix(in oklab, var(--accent) 12%, transparent); } kbd { display: inline-grid; place-items: center; min-width: 18px; height: 18px; align-self: center; margin-left: 5px; padding: 0 4px; color: var(--muted); font: 600 calc(11px * var(--ui-scale)) var(--sans); background: var(--well); border-radius: 5px; box-shadow: inset 0 -1px 0 color-mix(in oklab, var(--text) 12%, transparent); }
/* The question as a heading, and the field under it holding what you type. */ .ask { display: flex; flex: none; flex-direction: column; margin-bottom: 8px; } .ask.grow { flex: 1; min-height: 0; } .q { margin: 2px 0 10px; color: var(--text); font-size: calc(16px * var(--ui-scale)); font-weight: 650; }
.field { position: relative; display: block; height: calc(38px * var(--ui-scale)); background: var(--field); border-radius: 12px; transition: box-shadow 120ms ease; } .field:focus-within, textarea:focus { box-shadow: 0 0 0 2px var(--focus-ring); } .field input, .ghost { position: absolute; inset: 0; padding: 0 12px; font: calc(15px * var(--ui-scale)) var(--sans); } .field input { width: 100%; padding-right: 104px; color: inherit; background: none; border: 0; outline: none; } .ghost { display: flex; align-items: center; overflow: hidden; color: transparent; white-space: pre; pointer-events: none; } .ghost i { color: var(--faint); font-style: normal; } textarea { flex: 1; padding: 10px 12px; color: inherit; font: calc(14.5px * var(--ui-scale)) / 1.55 var(--sans); background: var(--field); border: 0; border-radius: 12px; outline: none; resize: none; transition: box-shadow 120ms ease; } input::placeholder, textarea::placeholder { color: var(--faint); }
.rows { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scroll-padding-bottom: 28px; }
.rows.more { mask-image: linear-gradient(to bottom, #000 calc(100% - 28px), transparent); } .row { display: flex; align-items: center; height: calc(31px * var(--ui-scale)); border-radius: 10px; }
.pickrow, button.row { display: flex; align-items: center; gap: 10px; min-width: 0; padding: 0 12px; color: var(--text); font: inherit; text-align: left; background: none; border: 0; cursor: pointer; }
button.row { width: 100%; }
.pickrow { flex: 1; height: 100%; }
.into, .aside { flex: none; color: var(--muted); font: inherit; background: none; border: 0; cursor: pointer; transition: background-color 120ms ease, color 120ms ease; }
.into { width: 28px; height: 24px; margin-right: 4px; font-size: calc(16px * var(--ui-scale)); border-radius: 7px; visibility: hidden; }
.row.hi .into { visibility: visible; }
.into:hover, .aside:hover { color: var(--text); background: var(--hover); }
.aside { position: absolute; top: 50%; right: 6px; display: flex; align-items: center; gap: 5px; padding: 3px 9px; color: var(--text); font-size: calc(12.5px * var(--ui-scale)); font-weight: 500; background: color-mix(in oklab, var(--text) 7%, transparent); border-radius: 8px; translate: 0 -50%; }
.aside svg { color: var(--muted); } .row.hi { background: var(--hover); } .name { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .note { flex: none; max-width: 50%; margin-left: auto; overflow: hidden; color: var(--faint); font-size: calc(12.5px * var(--ui-scale)); white-space: nowrap; text-overflow: ellipsis; } .here { flex: none; padding: 1px 8px; color: color-mix(in oklab, var(--text) 75%, var(--muted)); font-size: calc(12px * var(--ui-scale)); background: color-mix(in oklab, var(--text) 11%, transparent); border-radius: 999px; } .empty { margin: 0; padding: 6px 12px; color: var(--faint); } .problem { margin: 0; color: var(--danger); font-size: calc(12.5px * var(--ui-scale)); } .sheet > .problem { position: absolute; right: 28px; bottom: 24px; left: 28px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.effort { display: flex; flex: none; margin-top: 6px; padding: 2.5px; background: var(--field); border-radius: 10px; } .effort.bare { background: none; } .none { flex: 1; padding: 3.5px; color: var(--faint); font-size: calc(11px * var(--ui-scale)); text-align: center; }
.done { position: absolute; inset: 0; display: grid; place-items: center; align-content: center; gap: 6px; color: var(--muted); background: var(--surface); animation: arrive 0.3s ease; } .done b { color: var(--accent); font: 500 calc(22px * var(--ui-scale)) var(--face); }
@media (max-width: 760px) { dialog { width: 100vw; max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0; border-radius: 0; } .sheet { padding: 0 14px 14px; } }</style>