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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722<script lang="ts"> import { onDestroy } from 'svelte'; import { copyText } from './api'; import McpServers from './McpServers.svelte'; import { notifications } from './notifications.svelte'; import { dismissal, keepFocus } from './popover'; import { ACCENTS, DEFAULTS, FONT_FALLBACK, fontStack, prefs, readPalette, SEASONS, type Mode, type Palette, type Theme } from './prefs.svelte'; import { SIZE_MAX, SIZE_MIN, TEXT_DEFAULTS } from './type';
// A pane at the top right, beside the button that opens it. It is not // modal: the board stays usable, and Escape or a click elsewhere closes it. // Colours are edited per mode; the mode on screen is edited by default, so // every change shows at once. let { onclose }: { onclose: () => void } = $props(); let pane = $state<HTMLElement>(); let editing = $state<Mode>(prefs.resolved); let notice = $state(''); onDestroy(keepFocus());
const palette = $derived(prefs.palettes[editing]); // Choosing a theme edits the colours of the mode it shows. $effect(() => { editing = prefs.resolved; }); const themes: { id: Theme; label: string }[] = [ { id: 'system', label: 'system' }, { id: 'light', label: 'light' }, { id: 'dark', label: 'dark' }, ]; type Colour = 'accent' | 'background' | 'foreground' | 'added' | 'danger' | 'you'; const fields: { key: 'accent' | 'background' | 'foreground'; label: string }[] = [ { key: 'accent', label: 'accent' }, { key: 'background', label: 'background' }, { key: 'foreground', label: 'foreground' }, ];
const advanced: { key: 'added' | 'danger'; label: string }[] = [ { key: 'danger', label: 'errors / removals' }, { key: 'added', label: 'additions' }, ];
$effect(() => dismissal((target) => !!pane?.contains(target) || !!target.closest('[data-settings-toggle]'), onclose)); $effect(() => pane?.querySelector<HTMLElement>('[aria-checked="true"]')?.focus());
function set(change: Partial<Palette>) { prefs.palettes[editing] = { ...palette, ...change }; }
/** A typed hex applies once it is whole; anything else waits, and blur shows the current value again. */ function typed(key: Colour, value: string) { if (key === 'you' && !value.trim()) { set({ you: null }); return; } const hex = value.trim().startsWith('#') ? value.trim() : `#${value.trim()}`; if (/^#[0-9a-f]{6}$/i.test(hex)) set({ [key]: hex.toLowerCase() }); }
const fontFields = [ { key: 'ui', label: 'interface', placeholder: 'system', sample: 'albedo is thinking about your board' }, { key: 'code', label: 'code', placeholder: 'system monospace', sample: 'const tie = pick(x, y) // 0O 1lI' }, ] as const;
// Suggestions for the font fields: a few common families, and, where the // browser lets a page ask (Chromium, with permission), the families // actually installed. const COMMON_FONTS = ['Inter', 'SF Pro Text', 'Helvetica Neue', 'Comic Sans MS', 'Iosevka', 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Menlo', 'Berkeley Mono', 'Paper Mono']; let installed = $state<string[]>(COMMON_FONTS);
async function findFonts() { try { const fonts: { family: string }[] = await (window as unknown as { queryLocalFonts: () => Promise<{ family: string }[]> }).queryLocalFonts(); installed = [...new Set([...COMMON_FONTS, ...fonts.map((font) => font.family)])].sort((a, b) => a.localeCompare(b)); say(`found ${installed.length} font families`); } catch { say('the browser did not share its fonts'); } }
function say(text: string) { notice = text; setTimeout(() => notice === text && (notice = ''), 2200); }
async function copyTheme() { await copyText(JSON.stringify({ light: prefs.palettes.light, dark: prefs.palettes.dark }, null, 2)); say('copied both modes (˶ᵔ ᵕ ᵔ˶)'); }
async function pasteTheme() { try { const given = JSON.parse(await navigator.clipboard.readText()); // A whole theme has both modes; a lone palette is for the mode being edited. if (given?.light || given?.dark) { if (given.light) prefs.palettes.light = readPalette(given.light, 'light'); if (given.dark) prefs.palettes.dark = readPalette(given.dark, 'dark'); } else prefs.palettes[editing] = readPalette(given, editing); say('theme pasted ✧'); } catch { say("the clipboard isn't a theme (・_・;)"); } }
/** Arrow keys move within a radio group, as native radios do. */ function arrows<T>(event: KeyboardEvent, options: T[], current: T, choose: (value: T) => void) { const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key]; if (!step) return; event.preventDefault(); choose(options[(options.indexOf(current) + step + options.length) % options.length]); queueMicrotask(() => (event.currentTarget as HTMLElement | null)?.querySelector<HTMLElement>('[aria-checked="true"]')?.focus()); }</script>
<section class="pane" bind:this={pane} aria-label="Settings"> <h2>settings</h2>
<div class="group"> <label class="toggle"> <span> <span class="label">notify me when work finishes</span> <span class="note" id="notification-help">system notifications while this tab is hidden, for sessions on open boards or pinned. agent mail alerts its recipient.</span> </span> <input type="checkbox" role="switch" aria-label="notify me when work finishes" aria-describedby="notification-help notification-status" checked={notifications.enabled && notifications.permission === 'granted'} disabled={!notifications.supported || notifications.requesting} onchange={() => notifications.toggle()} /> </label> <p class="note" id="notification-status" role="status"> {#if !notifications.supported}system notifications are unavailable in this browser. tab counts still work. {:else if notifications.error}{notifications.error} {:else if notifications.requesting}waiting for the browser’s permission… {:else if notifications.permission === 'denied'}notifications are blocked. allow them in this site’s browser settings, then turn this on. {:else}tab counts are always on, and clear when you return.{/if} </p> </div>
<div class="group"> <h3 id="theme-label">theme</h3> <div class="themes" role="radiogroup" aria-labelledby="theme-label" tabindex="-1" onkeydown={(event) => arrows(event, themes.map((theme) => theme.id), prefs.theme, (id) => (prefs.theme = id))}> {#each themes as theme (theme.id)} <button role="radio" aria-checked={prefs.theme === theme.id} tabindex={prefs.theme === theme.id ? 0 : -1} onclick={() => (prefs.theme = theme.id)}> <span class="preview" aria-hidden="true"> {#each theme.id === 'system' ? (['light', 'dark'] as const) : [theme.id as Mode] as mode (mode)} <span class="half" style:--bg={prefs.palettes[mode].background} style:--ink={prefs.palettes[mode].foreground} style:--tint={prefs.palettes[mode].accent} data-mode={mode}> <span class="window"><span class="line"></span><span class="line short"></span></span> </span> {/each} </span> {theme.label} </button> {/each} </div> <p class="note">{prefs.theme === 'system' ? `following your system, which is ${prefs.resolved} right now` : `always ${prefs.theme}`}</p> </div>
<div class="group"> <div class="heading"> <h3 id="colors-label">colours</h3> <div class="modes" role="radiogroup" aria-label="Mode to edit" tabindex="-1" onkeydown={(event) => arrows(event, ['light', 'dark'] as Mode[], editing, (mode) => (editing = mode))}> {#each ['light', 'dark'] as Mode[] as mode (mode)} <button role="radio" aria-checked={editing === mode} tabindex={editing === mode ? 0 : -1} onclick={() => (editing = mode)}>{mode}</button> {/each} </div> </div>
<div class="seasons" aria-label="Seasons"> {#each SEASONS as season (season.id)} <button class:on={palette.background === season[editing].background && palette.foreground === season[editing].foreground} style:--bg={season[editing].background} style:--ink={season[editing].foreground} style:--tint={season[editing].accent} title="madoromi {season.id}, {season.label}" onclick={() => prefs.season(season.id)} > <span class="kanji">{season.kanji}</span>{season.id} </button> {/each} </div>
<div class="presets" aria-label="Accent presets"> {#each ACCENTS as accent (accent.id)} <button class="preset" class:on={palette.accent === accent[editing]} style:--swatch={accent[editing]} title={accent.label} aria-label="accent {accent.label}" onclick={() => set({ accent: accent[editing] })}></button> {/each} </div>
{#each fields as field (field.key)} <label class="color"> <span>{field.label}</span> <span class="pick" style:--swatch={palette[field.key]}> <input type="color" value={palette[field.key]} oninput={(event) => set({ [field.key]: event.currentTarget.value })} aria-label="{field.label} colour" /> </span> <input class="hex" value={palette[field.key]} spellcheck="false" maxlength="7" aria-label="{field.label} hex" oninput={(event) => typed(field.key, event.currentTarget.value)} onblur={(event) => (event.currentTarget.value = palette[field.key])} /> </label> {/each}
<details class="advanced"> <summary>advanced colours</summary> <p class="note">errors and removals share a colour. additions also colour successful activity.</p> {#each advanced as field (field.key)} <label class="color"> <span>{field.label}</span> <span class="pick" style:--swatch={palette[field.key]}> <input type="color" value={palette[field.key]} oninput={(event) => set({ [field.key]: event.currentTarget.value })} aria-label="{field.label} colour" /> </span> <input class="hex" value={palette[field.key]} spellcheck="false" maxlength="7" aria-label="{field.label} hex" oninput={(event) => typed(field.key, event.currentTarget.value)} onblur={(event) => (event.currentTarget.value = palette[field.key])} /> </label> {/each} <label class="color"> <span>you / speaker</span> <span class="pick" style:--swatch={palette.you ?? 'var(--you)'}> <input type="color" value={palette.you ?? '#808080'} oninput={(event) => set({ you: event.currentTarget.value })} aria-label="you / speaker colour" /> </span> <input class="hex" value={palette.you ?? ''} placeholder="auto" spellcheck="false" maxlength="7" aria-label="you / speaker hex" oninput={(event) => typed('you', event.currentTarget.value)} onblur={(event) => (event.currentTarget.value = palette.you ?? '')} /> </label> <p class="note">leave the speaker hex empty to follow the accent automatically. syntax highlighting is unchanged.</p> </details>
<label class="contrast"> <span>contrast</span> <input type="range" min="0" max="100" value={palette.contrast} oninput={(event) => set({ contrast: Number(event.currentTarget.value) })} /> <span class="value">{palette.contrast}</span> </label>
<div class="actions"> <button onclick={() => prefs.reset(editing)} disabled={JSON.stringify(palette) === JSON.stringify(DEFAULTS[editing])}>reset {editing}</button> <button onclick={copyTheme}>copy theme</button> <button onclick={pasteTheme}>paste theme</button> </div> {#if notice}<p class="note" role="status">{notice}</p>{/if} </div>
<div class="group"> <h3>fonts</h3> {#each fontFields as field (field.key)} <label class="font"> <span>{field.label}</span> <input list="installed-fonts" value={prefs.fonts[field.key]} placeholder={field.placeholder} spellcheck="false" maxlength="200" aria-label="{field.label} font" oninput={(event) => (prefs.fonts = { ...prefs.fonts, [field.key]: event.currentTarget.value })} /> </label> <p class="sample" style:font-family={fontStack(prefs.fonts[field.key], FONT_FALLBACK[field.key]) ?? FONT_FALLBACK[field.key]}>{field.sample}</p> {/each} <datalist id="installed-fonts"> {#each installed as family (family)}<option value={family}></option>{/each} </datalist> <div class="actions"> {#if 'queryLocalFonts' in window} <button onclick={findFonts} disabled={installed.length > COMMON_FONTS.length}>suggest installed fonts</button> {/if} <button onclick={() => (prefs.fonts = { ui: '', code: '' })} disabled={!prefs.fonts.ui && !prefs.fonts.code}>default fonts</button> </div> <p class="note">any font installed on this computer, by name; a comma adds fallbacks. kept in this browser and a cookie, so every albedo web port opens the same way.</p> </div>
<div class="group"> <h3>text</h3> <label class="contrast size"> <span>chrome text</span> <input type="range" min={SIZE_MIN} max={SIZE_MAX} value={prefs.text.ui} aria-label="chrome text size, in pixels" oninput={(event) => (prefs.text = { ...prefs.text, ui: Number(event.currentTarget.value) })} /> <span class="value">{prefs.text.ui}px</span> </label> <label class="toggle"> <span> <span class="label">canvas text at its own size</span> <span class="note">resizes size of text inside canvas blocks while sidebar and other ui remain unchanged</span> </span> <input type="checkbox" role="switch" aria-label="canvas text at its own size" bind:checked={prefs.text.own} /> </label> {#if prefs.text.own} <label class="contrast size"> <span>canvas text</span> <input type="range" min={SIZE_MIN} max={SIZE_MAX} value={prefs.text.canvas} aria-label="canvas text size, in pixels" oninput={(event) => (prefs.text = { ...prefs.text, canvas: Number(event.currentTarget.value) })} /> <span class="value">{prefs.text.canvas}px</span> </label> {/if} <div class="actions"> <button onclick={() => (prefs.text = { ...TEXT_DEFAULTS })} disabled={JSON.stringify(prefs.text) === JSON.stringify(TEXT_DEFAULTS)}>default sizes</button> </div> </div>
<div class="group"> <label class="toggle"> <span> <span class="label">dots on the canvas</span> <span class="note">a faint guide for panning. they merge into fewer as you zoom out.</span> </span> <input type="checkbox" role="switch" bind:checked={prefs.dots} /> </label> <label class="toggle"> <span> <span class="label">sessions on the right</span> <span class="note">the sidebar sits on the right, and search and settings move to the left of the top bar.</span> </span> <input type="checkbox" role="switch" bind:checked={prefs.sidebarRight} /> </label> <label class="toggle"> <span> <span class="label">zen desk on the left</span> <span class="note">a session full screen keeps the chat on the left and what it makes on a desk to its right. turn this on to swap them.</span> </span> <input type="checkbox" role="switch" bind:checked={prefs.deskLeft} /> </label> <label class="toggle"> <span> <span class="label">compact title bars</span> <span class="note">windows get a thin bar with just the title. a chat's model, artifacts, jobs and the rest move under its composer.</span> </span> <input type="checkbox" role="switch" bind:checked={prefs.compactBars} /> </label> <label class="toggle"> <span> <span class="label">canvas on small screens</span> <span class="note">on a phone, albedo shows one chat at a time and opens changes inside it. turn this on for the canvas instead.</span> </span> <input type="checkbox" role="switch" bind:checked={prefs.canvasOnPhone} /> </label> </div>
<div class="group"> <h3>MCP sign-ins</h3> <McpServers /> </div>
<p class="credit"> colours from <a href="https://madoromi.moonwit.ch/" target="_blank" rel="noopener noreferrer">madoromi 微睡</a> by <a href="https://bsky.app/profile/personhood.removal.surgery" target="_blank" rel="noopener noreferrer">samhain</a>, inks of pilot iroshizuku </p></section>
<style> /* Under its button, wherever the top bar puts that. */ :global([data-sidebar='right']) .pane { right: auto; left: 10px; } .pane { position: fixed; top: 50px; right: 10px; z-index: var(--z-pane); display: grid; grid-template-columns: minmax(0, 1fr); gap: 22px; width: 320px; max-height: calc(100vh - 64px); padding: 18px; overflow-y: auto; overscroll-behavior: contain; background: var(--surface); border-radius: 14px; box-shadow: var(--shadow-lg); scrollbar-width: thin; } h2 { margin: 0; font-size: calc(15px * var(--ui-scale)); font-weight: 620; letter-spacing: -0.01em; } h3 { margin: 0 0 8px; font-size: calc(13px * var(--ui-scale)); font-weight: 600; } .heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } .heading h3 { margin: 0; } .note { display: block; margin: 8px 0 0; color: var(--muted); font-size: calc(12px * var(--ui-scale)); line-height: 1.4; } button { color: var(--text); font: inherit; font-size: calc(12.5px * var(--ui-scale)); background: var(--field); border: 0; border-radius: 10px; cursor: pointer; transition: background-color 120ms ease, box-shadow 120ms ease, transform 100ms ease-out; } button:hover:not(:disabled) { background: var(--selected); } button:active:not(:disabled) { transform: scale(0.97); } button:disabled { color: var(--faint); cursor: default; } [aria-checked='true'] { box-shadow: 0 0 0 2px var(--accent); } .font { display: grid; grid-template-columns: 78px 1fr; align-items: center; gap: 10px; margin-top: 8px; font-size: calc(12.5px * var(--ui-scale)); } .font input { width: 100%; min-width: 0; height: 30px; padding: 0 10px; color: var(--text); font: inherit; background: var(--field); border: 0; border-radius: 8px; outline: 0; } .font input:focus-visible { box-shadow: 0 0 0 2px var(--focus-ring); } .sample { margin: 6px 0 0 88px; overflow: hidden; color: var(--muted); font-size: calc(13px * var(--ui-scale)); white-space: nowrap; text-overflow: ellipsis; } .themes { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; } .themes button { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 8px 6px; } /* Small pictures of each theme, drawn in its own colours: a window on its ground. */ .preview { display: flex; width: 100%; height: 38px; overflow: hidden; border-radius: 6px; } .half { flex: 1; padding: 8px 0 0 8px; background: var(--bg); } .half + .half { padding-left: 0; } .window { display: grid; align-content: start; gap: 4px; height: 100%; padding: 6px; background: color-mix(in oklab, var(--bg), #fff 78%); border-radius: 4px 0 0 0; } .half[data-mode='dark'] .window { background: color-mix(in oklab, var(--bg), var(--ink) 7%); } .half + .half .window { border-radius: 0; } .line { height: 3px; background: var(--ink); border-radius: 2px; opacity: 0.5; } .line.short { width: 55%; background: var(--tint); opacity: 1; } .modes { display: flex; gap: 2px; padding: 2px; background: var(--field); border-radius: 8px; } .modes button { height: 24px; padding: 0 10px; font-size: calc(12px * var(--ui-scale)); background: none; border-radius: 6px; } .modes [aria-checked='true'] { background: var(--surface); box-shadow: var(--shadow); } .seasons { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 12px; } .seasons button { display: flex; flex-direction: column; align-items: center; gap: 1px; height: 48px; padding: 0; color: var(--ink); font-size: calc(11.5px * var(--ui-scale)); background: var(--bg); box-shadow: inset 0 0 0 1px var(--hover); } .seasons button:hover:not(:disabled) { background: var(--bg); } .seasons button.on { box-shadow: 0 0 0 2px var(--accent); } .kanji { padding-top: 7px; color: var(--tint); font-family: var(--face); font-size: calc(15px * var(--ui-scale)); line-height: 1.2; } .credit { margin: -6px 0 0; color: var(--muted); font-size: calc(11.5px * var(--ui-scale)); line-height: 1.4; } .credit a { color: var(--accent); text-decoration: underline; text-decoration-color: color-mix(in oklab, var(--accent) 40%, transparent); text-underline-offset: 2px; } .presets { display: flex; gap: 8px; margin-bottom: 12px; } .preset { width: 22px; height: 22px; padding: 0; background: var(--swatch); border-radius: 50%; } .preset:hover:not(:disabled) { background: var(--swatch); } .preset.on { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--swatch); } .advanced { margin: 10px 0; } .advanced summary { color: var(--muted); font-size: calc(12.5px * var(--ui-scale)); cursor: pointer; } .advanced .note { margin-bottom: 8px; } .color, .contrast { display: grid; grid-template-columns: 1fr auto 82px; align-items: center; gap: 8px; min-height: 34px; font-size: calc(12.5px * var(--ui-scale)); } .pick { position: relative; width: 24px; height: 24px; overflow: hidden; background: var(--swatch); border-radius: 7px; box-shadow: inset 0 0 0 1px var(--hover); } .pick input { position: absolute; inset: -8px; width: 40px; height: 40px; opacity: 0; cursor: pointer; } .pick:focus-within { box-shadow: 0 0 0 2px var(--focus-ring); } .hex { height: 28px; padding: 0 8px; color: var(--text); font-family: var(--mono); font-size: calc(12px * var(--ui-scale)); background: var(--field); border: 0; border-radius: 7px; outline: 0; } .hex:focus { box-shadow: 0 0 0 2px var(--focus-ring); } /* The value column is in em so its number still fits as the text grows. */ .contrast { grid-template-columns: auto 1fr 2.3em; margin-top: 4px; } .size { grid-template-columns: auto 1fr 2.8em; } .size + .toggle, .toggle + .size, .toggle + .actions { margin-top: 10px; } .contrast input { width: 100%; accent-color: var(--accent); } .value { color: var(--muted); font-size: calc(12px * var(--ui-scale)); font-variant-numeric: tabular-nums; text-align: right; } .actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; } .actions button { height: 28px; padding: 0 10px; } .toggle { display: flex; align-items: flex-start; gap: 14px; cursor: pointer; } .toggle .label { font-size: calc(13px * var(--ui-scale)); font-weight: 560; } .toggle .note { margin-top: 2px; } #notification-status { min-height: 4.2em; } /* The switch is the native checkbox, drawn as a track and a knob. */ input[role='switch'] { flex: none; position: relative; width: 34px; height: 20px; margin: 1px 0 0; background: var(--field); border-radius: 10px; box-shadow: inset 0 0 0 1px var(--hover); cursor: pointer; appearance: none; transition: background-color 160ms cubic-bezier(0.23, 1, 0.32, 1); } input[role='switch']::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; background: #fff; border-radius: 50%; box-shadow: 0 1px 3px rgb(0 0 0 / 0.25); transition: transform 180ms cubic-bezier(0.23, 1, 0.32, 1); } input[role='switch']:checked { background: var(--accent); } input[role='switch']:checked::after { transform: translateX(14px); } input[role='switch']:disabled { opacity: 0.5; cursor: default; } input[role='switch']:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; } @media (prefers-reduced-motion: reduce) { input[role='switch'], input[role='switch']::after { transition: none; } }</style>