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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591<script lang="ts"> import { untrack } from 'svelte'; import type { Board, Float } from '../board.svelte'; import { mac, shortcut } from '../keys'; import Tethers from '../Tethers.svelte'; import TraceTie from '../TraceTie.svelte'; import { Dock, guideBoxes, type Where } from './dock.svelte'; import { reflow } from './reflow'; import TileView from './TileView.svelte'; import { hosts, windowOfHost } from './hosts'; import { find, shape, windows } from './tree'; import WindowSlot from './WindowSlot.svelte';
// A board as its layout tree lays it out: a canvas, tiles, tabs, or any of // them inside another. Tethers and the trace's tie are drawn once over the // whole of it, so they reach between windows wherever each one sits. let { board, onpalette, onnew }: { board: Board; onpalette?: () => void; onnew?: () => void } = $props();
let element = $state<HTMLElement>(); const dock = new Dock( () => board, () => element, ); const carry = $derived(dock.carry); const tiled = $derived(board.root.kind !== 'canvas'); const empty = $derived(windows(board.root).length === 0); let alt = $state(false);
$effect(() => () => dock.cancel());
// A tile lifted to fill the board is fixed over it, by where the board is on screen. let box = $state<DOMRect | null>(null); $effect(() => { if (!board.expanded || !element) return; const watched = element; const measure = () => (box = watched.getBoundingClientRect()); const sizes = new ResizeObserver(measure); sizes.observe(watched); return () => sizes.disconnect(); });
// Filling the board and back slides as a layout change does; a window // going back to its canvas is sheltered from the slot it leaves. let filled = untrack(() => board.expanded); let over = untrack(() => board.expandedOver); $effect.pre(() => { const next = board.expanded; const above = board.expandedOver; if (next === filled) return; untrack(() => { reflow(board.id); if (over) hosts.shelter(board.id); }); filled = next; over = above; });
// Before the DOM shows a new arrangement (not a resize or a move on a // canvas): its windows are sheltered from their slots being rebuilt, and // what moves slides to where it goes. let arranged = untrack(() => shape(board.root)); $effect.pre(() => { const next = shape(board.root); if (next === arranged) return; arranged = next; untrack(() => { reflow(board.id); hosts.shelter(board.id); }); });
// What was just closed, said where it was until it times out or is taken // back. Once the board changes again, ⌘Z would take that back instead. let closed = $state<{ text: string; x: number; y: number; shape: string } | null>(null); let fading: ReturnType<typeof setTimeout> | undefined; let leaving = $state(false); const hold = () => { clearTimeout(fading); leaving = false; }; // Fades out over its last LEAVE ms, the length of `.leaving`'s transition. const LEAVE = 240; const fade = (after: number) => { hold(); fading = setTimeout(() => { leaving = true; fading = setTimeout(() => (closed = null), LEAVE); }, after); }; $effect(() => { const told = board.told; if (!told) return; untrack(() => { board.told = null; const at = told.at ?? element?.getBoundingClientRect(); if (!at) return; closed = { text: told.text, x: at.left + at.width / 2, y: at.top + at.height / 2, shape: shape(board.root) }; fade(3500); }); }); $effect(() => { if (closed && shape(board.root) !== closed.shape) closed = null; }); $effect(() => () => clearTimeout(fading));
/** * ⌘-click (Ctrl-click off a Mac) on a window's title bar, tab or empty * ground, a section's header or a canvas's empty ground picks it to make a * section of; a plain press anywhere lets the picks go, and one on a * header or a canvas tile's empty ground selects that pane (a window * selects itself, WindowSlot.svelte). */ function pick(event: PointerEvent): boolean { if (event.button !== 0) return false; if (!(mac ? event.metaKey : event.ctrlKey)) { board.unpick(); const pane = paneAt(event.target as Element); if (pane) { board.select(pane); // A field or shell left focused in a window would keep the keys, and take the selection back when focus returns to it. if (document.activeElement instanceof HTMLElement && document.activeElement.closest('.window-slot')) document.activeElement.blur(); } return false; } const under = event.target as Element; const tab = under.closest<HTMLElement>('[data-dock-window]'); const window = tab?.dataset.dockWindow ?? windowOfHost(board.id, under.closest('.window-handle')) ?? (bare(event) ? windowOfHost(board.id, under) : null); const section = under.closest<HTMLElement>('.section-head[data-dock-tile]')?.dataset.dockTile; const ground = under.closest('.svelte-flow__pane') && !under.closest('.svelte-flow__node'); const canvas = ground ? under.closest<HTMLElement>('.canvas-tile.nested')?.dataset.tile : undefined; const id = window ?? section ?? canvas; if (!id) return false; event.stopPropagation(); board.togglePick(id); return true; }
/** The section, group or canvas whose header is under a press, or the canvas tile whose empty ground is. */ function paneAt(under: Element): string | undefined { const head = under.closest<HTMLElement>('.section-head[data-pane]')?.dataset.pane; const ground = under.closest('.svelte-flow__pane, .svelte-flow__node')?.classList.contains('svelte-flow__pane'); return head ?? (ground ? under.closest<HTMLElement>('.canvas-tile.nested')?.dataset.tile : undefined); }
/** Whether a press lands on nothing: off any text, field or control. */ function bare(event: PointerEvent): boolean { if ((event.target as Element).closest('a, button, input, textarea, select, [contenteditable], iframe, img, .xterm')) return false; const caret = document.caretPositionFromPoint(event.clientX, event.clientY); const text = caret?.offsetNode; if (!caret || !(text instanceof Text)) return true; const glyph = document.createRange(); for (const at of [caret.offset - 1, caret.offset]) { if (at < 0 || at >= text.length) continue; glyph.setStart(text, at); glyph.setEnd(text, at + 1); for (const box of glyph.getClientRects()) if (event.clientX >= box.left && event.clientX <= box.right && event.clientY >= box.top && event.clientY <= box.bottom) return false; } return true; }
/** Carries a floating note by its header, kept on the board. */ function carryFloat(event: PointerEvent, float: Float) { const handle = (event.target as Element).closest<HTMLElement>('.note-handle'); if (event.button !== 0 || !handle || (event.target as Element).closest('button:not(.about), input, textarea, .nodrag') || !element) return; // A press on the label is still its click until it moves: capturing sooner would take the click from it. const label = (event.target as Element).closest('.about'); let carrying = !label; if (carrying) { event.preventDefault(); handle.setPointerCapture(event.pointerId); } const start = { x: event.clientX - float.x, y: event.clientY - float.y }; const bounds = element.getBoundingClientRect(); const move = (moved: PointerEvent) => { if (!carrying) { if (Math.hypot(moved.clientX - event.clientX, moved.clientY - event.clientY) < 4) return; carrying = true; handle.setPointerCapture(event.pointerId); } board.moveFloat(float.tile.window, { x: Math.round(Math.max(0, Math.min(bounds.width - float.width, moved.clientX - start.x))), y: Math.round(Math.max(0, Math.min(bounds.height - 48, moved.clientY - start.y))), }); }; const done = (ended: PointerEvent) => { handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', done); handle.removeEventListener('pointercancel', done); if (!carrying || ended.type !== 'pointerup') return; // Let go over a canvas, it joins the canvas there. const under = document.elementsFromPoint(ended.clientX, ended.clientY).find((element) => !element.closest('.float')); const canvas = under?.closest<HTMLElement>('.canvas[data-canvas]')?.dataset.canvas; if (canvas) board.dockFloat(float.tile.window, canvas); }; handle.addEventListener('pointermove', move); handle.addEventListener('pointerup', done); handle.addEventListener('pointercancel', done); }
let labelWidth = $state(0); let labelHeight = $state(0); let innerWidth = $state(0); let innerHeight = $state(0);
const arrows: Record<Where, string> = { left: 'M10 4 6 8l4 4', right: 'm6 4 4 4-4 4', top: 'm4 10 4-4 4 4', bottom: 'm4 6 4 4 4-4', tab: 'M3.5 5.5h9v7h-9zM3.5 5.5V3.5h4v2' };</script>
<!-- A keyup can be lost (a focused shell keeps it), so every move reads ⌥ itself. --><div class="board-view" class:tiled class:expanding={!!board.expanded} class:alt class:carrying={carry !== null && !carry.flow} bind:this={element} style:--board-top={box && `${box.top + 6}px`} style:--board-left={box && `${box.left + 6}px`} style:--board-width={box && `${box.width - 12}px`} style:--board-height={box && `${box.height - 12}px`} onpointerdowncapture={(event) => pick(event) || dock.press(event)} onpointermovecapture={(event) => (alt = event.altKey)} role="presentation"> {#if !(empty && tiled)} <div class="layout"><TileView {board} tile={board.root} /></div> {/if} {#if board.expanded && board.expandedOver} {@const canvas = board.windows[board.expanded] ? null : find(board.root, board.expanded)} {#key board.expanded} <div class="over"> {#if canvas}<TileView {board} tile={canvas} />{:else}<WindowSlot {board} window={board.expanded} over />{/if} </div> {/key} {/if} {#if empty} <!-- Over an empty canvas only the buttons take the pointer: a double-click still leaves a note. --> <div class="empty"> <p class="face kaomoji" aria-hidden="true">(・ω・)ノ</p> {#if tiled} <p class="lead">nothing tiled yet</p> <p>open a session and it takes the space; each one after splits it.</p> {:else} <p class="lead">an empty board</p> <p>open a session to start. windows can sit anywhere; pinch to zoom out and see them all. double-click to leave a note.</p> {/if} {#if onpalette && onnew} <div class="empty-actions"> <button onclick={onpalette}>open a session <kbd>{shortcut('K')}</kbd></button> <button onclick={onnew}>new session <kbd>S</kbd></button> </div> {/if} </div> {/if} {#if board.floats.length} <div class="floats"> {#each board.floats as float (float.tile.window)} <div class="float" style:left="{float.x}px" style:top="{float.y}px" style:width="{float.width}px" style:z-index={float.z} role="presentation" onpointerdown={(event) => carryFloat(event, float)}> <WindowSlot {board} window={float.tile.window} /> </div> {/each} </div> {/if} <Tethers {board} /> <TraceTie /></div>
{#if closed} <div class="close-note" class:leaving role="status" style:left="{closed.x}px" style:top="{closed.y}px" onpointermove={hold} onpointerleave={() => fade(1200)}> {closed.text}<kbd>{shortcut('Z')}</kbd><button onclick={() => (board.undo(), (closed = null))}>undo</button> </div>{/if}
{#if carry} {@const drop = carry.drop} {@const box = drop ? drop.preview : carry.home} <div class="docking" aria-hidden="true"> {#if carry.slot} <div class="slot" style:left="{carry.slot.left}px" style:top="{carry.slot.top}px" style:width="{carry.slot.width}px" style:height="{carry.slot.height}px"></div> {/if} {#each drop?.strips ?? [] as strip, at (at)} <div class="strip" class:lit={strip.lit} style:left="{strip.box.left}px" style:top="{strip.box.top}px" style:width="{strip.box.width}px" style:height="{strip.box.height}px"></div> {/each} {#if box} <div class="preview" class:refused={drop?.refused} style:left="{box.left}px" style:top="{box.top}px" style:width="{box.width}px" style:height="{box.height}px"> {#if drop?.swap}<span class="swap">⇄</span>{/if} </div> {/if} {#if drop?.caret && !drop.refused} <div class="caret" style:left="{drop.caret.left}px" style:top="{drop.caret.top}px" style:width="{drop.caret.width}px" style:height="{drop.caret.height}px"></div> {/if} {#if carry.guides} {@const guides = guideBoxes(carry.guides)} {#each Object.entries(guides) as [where, rect] (where)} <div class="guide" class:lit={drop?.where === where} style:left="{rect.left}px" style:top="{rect.top}px" style:width="{rect.width}px" style:height="{rect.height}px"> <svg viewBox="0 0 16 16" width="14" height="14"><path d={arrows[where as Where]} fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg> </div> {/each} {/if} {#if !carry.flow || carry.hint} <!-- Below and right of the pointer, flipped to its other side near the window's edge. --> <div class="label" bind:offsetWidth={labelWidth} bind:offsetHeight={labelHeight} style:left="{carry.x + 18 + labelWidth < innerWidth - 6 ? carry.x + 18 : Math.max(6, carry.x - 18 - labelWidth)}px" style:top="{carry.y + 22 + labelHeight < innerHeight - 6 ? carry.y + 22 : Math.max(6, carry.y - 14 - labelHeight)}px" > {#if !carry.flow}<div class="ghost">{carry.title}</div>{/if} {#if carry.hint}<div class="hint" class:muted={!drop} class:refused={drop?.refused}>{carry.hint}</div>{/if} </div> {/if} </div>{/if}
<svelte:window bind:innerWidth bind:innerHeight onkeydown={(event) => event.key === 'Alt' && (alt = true)} onkeyup={(event) => event.key === 'Alt' && (alt = false)} onblur={() => (alt = false)} />
<style> .board-view { position: absolute; inset: 0; isolation: isolate; display: flex; } /* Tiles sit in the ground with zen's gap around them, as the desk does. */ .tiled { padding: 6px; background: var(--bg); } .layout { display: contents; } /* Behind what fills the board everything stays laid out as it was, unseen and out of reach. */ .expanding { background: var(--bg); } .expanding > .layout :global(:not(.expanded, .expanded *)) { visibility: hidden; } .over { position: absolute; inset: 6px; z-index: 1; display: flex; } /* Floating notes ride over the tiles; only the notes themselves take the pointer. */ .floats { position: absolute; inset: 0; z-index: var(--z-floats); pointer-events: none; } .float { position: absolute; pointer-events: auto; } /* ⌥ carries a window from anywhere in it: a tiled one by the dock, one on a canvas by the canvas. */ .alt :global(.window-slot:is([data-place='tile'], [data-place='tab']) *), .alt :global(.svelte-flow__node), .alt :global(.svelte-flow__node *) { cursor: move !important; user-select: none; } .carrying, .carrying :global(*) { cursor: grabbing !important; user-select: none; } .carrying :global(iframe) { pointer-events: none; } /* Where the closed window was, over the gap its neighbours slide into. */ .close-note { position: fixed; z-index: var(--z-docking); display: flex; align-items: center; gap: 10px; padding: 6px 6px 6px 16px; color: var(--text); font-size: calc(13px * var(--ui-scale)); white-space: nowrap; /* A step toward the accent, so it stands off the window it lands over. */ background: color-mix(in oklab, var(--surface), var(--accent) 14%); border-radius: 999px; box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 45%, transparent), var(--shadow-lg); translate: -50% -50%; animation: close-note-in 160ms ease-out; transition: opacity 240ms ease-in, scale 240ms ease-in; } .close-note.leaving { opacity: 0; scale: 0.96; } .close-note button { padding: 3px 11px; color: var(--surface); font: inherit; font-weight: 600; background: var(--accent); border: 0; border-radius: 999px; cursor: pointer; transition: background-color 120ms ease; } .close-note button:hover { background: color-mix(in oklab, var(--accent), var(--text) 15%); } @keyframes close-note-in { from { opacity: 0; scale: 0.94; } } @media (prefers-reduced-motion: reduce) { .close-note { animation: none; transition: none; } } .docking { position: fixed; inset: 0; z-index: var(--z-docking); pointer-events: none; } .preview { position: fixed; background: color-mix(in oklab, var(--accent) 14%, transparent); border-radius: 14px; box-shadow: inset 0 0 0 2px color-mix(in oklab, var(--accent) 55%, transparent); transition: left 120ms ease, top 120ms ease, width 120ms ease, height 120ms ease; } .preview.refused { background: color-mix(in oklab, var(--danger) 14%, transparent); box-shadow: inset 0 0 0 2px color-mix(in oklab, var(--danger) 60%, transparent); } .swap { position: absolute; inset: 0; display: grid; place-items: center; color: var(--accent); font-size: calc(28px * var(--ui-scale)); } .caret { position: fixed; background: var(--accent); border-radius: 2px; transition: left 120ms ease, top 120ms ease, width 120ms ease, height 120ms ease; } .strip { position: fixed; background: color-mix(in oklab, var(--accent) 8%, transparent); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 35%, transparent); } .strip.lit { background: color-mix(in oklab, var(--accent) 30%, transparent); } .guide { position: fixed; display: grid; place-items: center; color: var(--muted); background: var(--surface); border-radius: 9px; box-shadow: var(--shadow); } .guide.lit { color: var(--text); background: color-mix(in oklab, var(--accent) 22%, var(--surface)); } .label { position: fixed; display: grid; justify-items: start; gap: 4px; } /* The place a carry began, while the pointer is still in it and nothing has moved. */ .slot { position: fixed; background: repeating-linear-gradient(45deg, color-mix(in oklab, var(--accent) 10%, transparent) 0 6px, transparent 6px 12px); border: 2px dashed color-mix(in oklab, var(--accent) 45%, transparent); border-radius: 14px; } .ghost { max-width: 240px; padding: 5px 10px; overflow: hidden; color: var(--text); font-size: calc(12px * var(--ui-scale)); white-space: nowrap; text-overflow: ellipsis; background: var(--surface); border-radius: 8px; box-shadow: var(--shadow-raised); } .empty { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 6px; text-align: center; pointer-events: none; } .empty p { max-width: 34ch; margin: 0; color: var(--muted); font-size: calc(13px * var(--ui-scale)); text-wrap: balance; } .empty .face { margin-bottom: 4px; color: var(--agent); font-size: calc(22px * var(--ui-scale)); } .empty .lead { color: var(--text); font-size: calc(15px * var(--ui-scale)); font-weight: 560; } .empty-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 12px; } .empty button { display: flex; align-items: center; gap: 10px; height: 34px; padding: 0 8px 0 14px; color: var(--text); font: inherit; font-size: calc(13px * var(--ui-scale)); background: var(--surface); border: 0; border-radius: 10px; box-shadow: var(--shadow); cursor: pointer; pointer-events: auto; transition: transform 120ms ease-out, box-shadow 160ms ease; } .empty button:hover { box-shadow: var(--shadow-raised); } .empty button:active { transform: scale(0.97); } .hint { max-width: 420px; padding: 5px 10px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--text); font-size: calc(12px * var(--ui-scale)); background: var(--surface); border-radius: 8px; box-shadow: var(--shadow); } .hint.muted { color: var(--muted); } .hint.refused { color: var(--danger); } @media (prefers-reduced-motion: reduce) { .preview, .caret { transition: none; } }</style>