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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575<script lang="ts"> import { anchorPoint, intersect, pick, selectionLines, showTarget, targetBox, targetLines, textRange, textTarget } from './anchors'; import { chipForWindow, slotOf, type Board, type NoteWindow, type Target } from './board.svelte'; import { selection, tying } from './tie.svelte'; import ConnectorClip from './ConnectorClip.svelte'; import { connectorOcclusion } from './connectorOcclusion';
// The lines from notes to what they are about. Targets live inside windows // that scroll and redraw on their own, so the lines are measured in screen // space, frame by frame while anything could have moved them, and drawn // over the canvas. let { board }: { board: Board } = $props(); let element = $state<SVGSVGElement>(); // Notes lie over the lines: each note's box is cut out of them, drawn by a // mask and, for the lines a click follows, clipped too, since a mask still // takes the pointer. const uid = $props.id(); let holes = $state.raw<Box[]>([]); let occlusion = $state.raw<ReturnType<typeof connectorOcclusion>>({ holes: [], lifted: false }); // Each tie is a line and, where its target is a passage, a tint over it, // so a note and what it is about read as one even with several on screen. // The note in hand (selected or under the pointer) draws strongest. type Box = { x: number; y: number; width: number; height: number }; // A note out of its canvas's view draws its line from the canvas's edge; // several whose lines would lie on one another draw one, counted at that // edge (`ids`, `count`). A line fades the further its passage is scrolled // out of sight, against all there is to scroll (`fade`), and lines that end // on one spot, their passages scrolled away, count there (`piles`); a // click on the count scrolls to the nearest of them. A click on a line // from the canvas's edge shows its notes and scrolls to the nearest of // their passages too (`passages`). type Point = { x: number; y: number }; let paths = $state.raw<{ id: string; d: string | null; stub: string; dot: Point; sent: boolean; lit: boolean; away: boolean; fade: number; ids: string[]; passages: Passage[]; count: Point | null; box: Box | null; lines: Box[] | null }[]>([]); type Passage = { target: Target; far: number }; type Pile = Point & { away: Passage[] }; let piles = $state.raw<Pile[]>([]); let chipPaths = $state.raw<{ id: string; d: string }[]>([]); let preview = $state.raw<string | null>(null); // While a tie is drawn, what it would land on. let landing = $state.raw<{ x: number; y: number; width: number; height: number; window: boolean } | null>(null);
// Which note the pointer is over, read from the canvas rather than asked of each note. let hovered = $state<string | null>(null); $effect(() => { const over = (event: PointerEvent) => { hovered = (event.target as Element | null)?.closest<HTMLElement>('.window-slot[data-type="note"]')?.dataset.id ?? null; }; document.addEventListener('pointerover', over); return () => document.removeEventListener('pointerover', over); });
const tied = $derived(board.list.filter((node): node is NoteWindow => node.type === 'note' && !!node.data.target)); const chipTied = $derived( board.list.flatMap((node) => { if (node.type === 'note' || !node.data.parent) return []; const kind = chipForWindow(node); if (!kind || !board.hasChip(node.data.parent, kind)) return []; return [{ id: node.id, chipNodeId: `chip:${node.data.parent}:${kind}` }]; }), );
// A passage of the client's own text is tinted under its words with the // CSS Custom Highlight API where there is one; a page's passages, and // every passage without it, by their lines over the canvas. const highlights = typeof CSS !== 'undefined' && 'highlights' in CSS ? CSS.highlights : null; const painted: Record<'normal' | 'lit' | 'sent', Range[]> = { normal: [], lit: [], sent: [] }; function paint(next: typeof painted) { for (const name of ['normal', 'lit', 'sent'] as const) { if (next[name].length === painted[name].length && next[name].every((range, index) => range === painted[name][index])) continue; painted[name] = next[name]; highlights!.set(`albedo-note-${name}`, new Highlight(...next[name])); } } $effect(() => () => { for (const name of ['normal', 'lit', 'sent']) highlights?.delete(`albedo-note-${name}`); });
// Text selected in a window gets a small button by it that makes a note // about exactly that. The page an artifact shows reports its own // selection through its bridge (artifacts.svelte.ts). let pressed = false; let offer = $state.raw<{ x: number; y: number } | null>(null); // When the current selection first appeared: the note button waits for it // to settle, so it is never under the pointer the moment a drag ends. let offerSince: number | null = null; $effect(() => { let frame = 0; const read = () => { const chosen = document.getSelection(); const range = chosen?.rangeCount && !chosen.isCollapsed ? chosen.getRangeAt(0) : null; const typing = document.activeElement?.matches('textarea, input, [contenteditable]:not([contenteditable="false"])'); const target = range && !typing ? textTarget(range) : undefined; if (target) selection.current = { target, range: range!.cloneRange() }; else if (selection.current?.range) selection.current = null; }; const changed = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(read); }; const down = (event: PointerEvent) => { pressed = true; // A page's selection outlives a click outside it: the click lets go of // it, and offers again whatever is still selected in the client's text. const on = event.target as Element | null; if (selection.current?.page && !on?.closest('.grip, .selection-note')) { selection.current = null; changed(); } }; const up = () => (pressed = false); document.addEventListener('selectionchange', changed); document.addEventListener('pointerdown', down, true); document.addEventListener('pointerup', up, true); return () => { cancelAnimationFrame(frame); document.removeEventListener('selectionchange', changed); document.removeEventListener('pointerdown', down, true); document.removeEventListener('pointerup', up, true); }; });
function noteSelection(event: PointerEvent) { event.preventDefault(); const chosen = selection.current; const top = selectionLines()[0]?.top; if (!chosen || top === undefined) return; board.noteBeside(chosen.target, top - 16); selection.current = null; if (chosen.range) document.getSelection()?.removeAllRanges(); }
/** How far a line goes on into a window past its edge, toward the passage. */ const STUB = 14; /** About as wide as the selection's note button. */ const PILL = 72; /** Past every edge the lines can reach: the bounds of the notes' mask. */ const FAR = 100_000;
function curve(a: { x: number; y: number }, b: { x: number; y: number }) { const bend = Math.max(24, Math.abs(b.x - a.x) / 2); const direction = b.x >= a.x ? 1 : -1; return `M${a.x} ${a.y} C${a.x + bend * direction} ${a.y} ${b.x - bend * direction} ${b.y} ${b.x} ${b.y}`; }
function overlaps(a: DOMRect, b: DOMRect | undefined) { return !!b && a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom; }
/** * Where a note's line leaves it. A note panned out of its canvas's view is * reached at the canvas's edge (`away`): the edge facing what the line goes * to when that is outside the canvas, level with it, so the line never * crosses the canvas; else the edge nearest the note. */ function noteEnd(id: string, toward: { x: number; y: number }) { const { rect, view } = measure(id) ?? {}; if (!rect) return null; const x = toward.x < rect.left + rect.width / 2 ? rect.left : rect.right; const y = rect.top + 16; if (!view || (x >= view.left && x <= view.right && y >= view.top && y <= view.bottom)) return { x, y, away: false }; const clamp = (value: number, low: number, high: number) => Math.min(Math.max(value, low), high); if (toward.x < view.left || toward.x > view.right) return { x: clamp(toward.x, view.left, view.right), y: clamp(toward.y, view.top + 16, view.bottom - 16), away: true }; if (toward.y < view.top || toward.y > view.bottom) return { x: clamp(toward.x, view.left + 16, view.right - 16), y: clamp(toward.y, view.top, view.bottom), away: true }; return { x: clamp(x, view.left, view.right), y: clamp(y, view.top, view.bottom), away: true }; }
/** Slots measured this frame: each window's box, and its canvas's when it is on one. */ let measured = new Map<string, { rect: DOMRect; view: DOMRect | undefined } | null>();
function measure(id: string) { if (!measured.has(id)) { const slot = slotOf(id); const rect = slot?.getBoundingClientRect(); measured.set(id, rect ? { rect, view: viewOf(slot!) } : null); } return measured.get(id)!; }
/** What of a window's canvas is on screen: its canvas's box, cut by every canvas round it (an empty box at its corner when none of it shows). */ function viewOf(slot: Element): DOMRect | undefined { let view: DOMRect | undefined; for (let canvas = slot.closest('.canvas-tile'); canvas; canvas = canvas.parentElement?.closest('.canvas-tile') ?? null) { const box = canvas.getBoundingClientRect(); view = view ? (intersect(view, box) ?? new DOMRect(view.x, view.y, 0, 0)) : box; } return view; }
function nearest(passages: Passage[]): Target { return passages.reduce((a, b) => (b.far < a.far ? b : a)).target; }
/** Shows the notes a line from the canvas's edge stands for, and what the nearest is about. */ function reach(ids: string[], passages: Passage[]) { if (ids.length > 1) board.revealTogether(ids); else board.focus(ids[0]); const away = passages.filter((passage) => passage.far); if (away.length) showTarget(nearest(away)); }
/** Frames in a row that draw nothing new before the lines wait for something to move them. */ const SETTLE = 3; const active = $derived(!!element && (tied.length > 0 || chipTied.length > 0 || !!tying.note || !!selection.current));
// Targets move without any state of ours changing (a chat scrolls or // streams, a canvas pans, a page's bridge answers), so the lines are drawn // from whatever could have moved them: input, a scroll, a resize, the // board's DOM changing, a message from a page. Each wakes the frames, which // run until a few in a row draw nothing new. $effect(() => { if (!active) { paths = []; holes = []; occlusion = { holes: [], lifted: false }; piles = []; chipPaths = []; preview = null; landing = null; offer = null; if (highlights) paint({ normal: [], lit: [], sent: [] }); return; } let frame = 0; let still = 0; let shown = ''; let reason = ''; const wake = (why: Event | MutationRecord[] | string = 'start') => { reason = typeof why === 'string' ? why : Array.isArray(why) ? 'mutation' : why.type; still = 0; frame ||= requestAnimationFrame(tick); }; const tick = () => { frame = 0; draw(); }; // `audit` only compares: what the frame would draw against what is drawn. const draw = (audit = false) => { measured = new Map(); const origin = element!.getBoundingClientRect(); const local = (point: { x: number; y: number }) => ({ x: point.x - origin.left, y: point.y - origin.top }); const shift = (rect: DOMRect) => ({ x: rect.left - origin.left, y: rect.top - origin.top, width: rect.width, height: rect.height }); const next: typeof paths = []; const nextChips: typeof chipPaths = []; const ranges: typeof painted = { normal: [], lit: [], sent: [] }; for (const item of chipTied) { const chipEl = slotOf(item.chipNodeId); const targetRect = measure(item.id)?.rect; if (!chipEl || !targetRect) continue; const rowEl = chipEl.querySelector<HTMLElement>(`[data-chip-target="${CSS.escape(item.id)}"]`); const chipRect = chipEl.getBoundingClientRect(); const listEl = chipEl.querySelector<HTMLElement>('.list.inline'); const listRect = listEl?.getBoundingClientRect() ?? chipRect; const rowRect = rowEl?.getBoundingClientRect(); const rawY = rowRect ? rowRect.top + rowRect.height / 2 : chipRect.top + chipRect.height / 2; const startY = Math.max(listRect.top + 6, Math.min(listRect.bottom - 6, rawY)); const endY = targetRect.top + Math.min(21, targetRect.height / 2); const xOverlap = targetRect.left < chipRect.right - 16 && targetRect.right > chipRect.left + 16; const stacked = xOverlap && (targetRect.top > chipRect.bottom - 8 || targetRect.bottom < chipRect.top + 8); let d: string; if (stacked) { const useLeft = Math.abs(targetRect.left - chipRect.left) <= Math.abs(targetRect.right - chipRect.right); const sx = (useLeft ? chipRect.left : chipRect.right) - origin.left; const sy = startY - origin.top; const ex = (useLeft ? targetRect.left : targetRect.right) - origin.left; const ey = endY - origin.top; const outX = useLeft ? Math.min(sx, ex) - 28 : Math.max(sx, ex) + 28; d = `M${sx} ${sy} C${outX} ${sy} ${outX} ${ey} ${ex} ${ey}`; } else { const toRight = targetRect.left + targetRect.width / 2 >= chipRect.left + chipRect.width / 2; const start = { x: toRight ? chipRect.right : chipRect.left, y: startY }; const end = { x: toRight ? targetRect.left : targetRect.right, y: endY }; d = curve(local(start), local(end)); } nextChips.push({ id: `${item.chipNodeId}->${item.id}`, d }); } const drawn: { start: Point; end: Point; tie: (typeof next)[number] }[] = []; const held: Pile[] = []; const near = (a: Point, b: Point) => Math.abs(a.x - b.x) < 12 && Math.abs(a.y - b.y) < 12; for (const note of tied) { const rect = measure(note.id)?.rect; if (!rect) continue; // A note lying on the window it is about needs no line to it. if (note.data.target!.kind === 'window' && overlaps(rect, measure(note.data.target!.window)?.rect)) continue; // A note out of view is reached from its canvas's edge, so that edge picks the passage's side. const middle = { x: rect.left + rect.width / 2, y: rect.top + 16 }; const seen = noteEnd(note.id, middle); const centre = seen?.away ? seen : middle; const box = targetBox(note.data.target!); // A visible passage takes the line to its window's edge, level with // it, and a short stub in toward it, so the line never crosses the // window's text. const point = anchorPoint(note.data.target!, centre); const end = point && box ? { x: point.x, y: Math.min(box.bottom - 2, Math.max(box.top + 2, point.y)) } : point; const start = end && noteEnd(note.id, end); const lit = board.selected === note.id || hovered === note.id; const target = note.data.target!; const range = highlights && target.kind === 'text' ? textRange(target) : null; if (range) ranges[note.data.sent ? 'sent' : lit ? 'lit' : 'normal'].push(range); // A passage that cannot be found any more is tinted as the block it was in. const lines = range ? null : targetLines(target); const words = lines?.length ? lines.map(shift) : null; if (!point || !end || !start) continue; const dot = local(end); if (box) dot.x += Math.max(-STUB, Math.min(STUB, (centre.x > box.left + box.width / 2 ? box.right : box.left) - end.x)); const tie = { id: note.id, d: curve(local(start), local(end)), stub: box ? `M${local(end).x} ${local(end).y} L${dot.x} ${dot.y}` : '', dot, sent: !!note.data.sent, lit, away: start.away, fade: 1 - 0.7 * point.far, ids: [note.id], passages: [{ target, far: point.far }], count: null, box: box && !range && !words ? shift(box) : null, lines: words }; const under = start.away ? drawn.find((other) => near(other.start, start) && near(other.end, end)) : undefined; if (under) { const shared = under.tie; shared.ids.push(note.id); shared.passages.push({ target, far: point.far }); shared.lit ||= lit; shared.sent &&= tie.sent; shared.fade = Math.max(shared.fade, tie.fade); shared.count = local(start); next.push({ ...tie, d: null }); } else { drawn.push({ start, end, tie }); next.push(tie); } if (point.far && !start.away) { const pile = held.find((other) => near(other, end)) ?? held[held.push({ ...end, away: [] }) - 1]; pile.away.push({ target, far: point.far }); } } const nextHoles = board.list.flatMap((node) => { const { rect, view } = (node.type === 'note' && measure(node.id)) || {}; const seen = rect && (view ? intersect(rect, view) : rect); return seen ? [shift(seen)] : []; }); const nextOcclusion = connectorOcclusion(element!.parentElement!, origin); const nextPiles = held.filter((pile) => pile.away.length > 1).map((pile) => ({ ...pile, ...local(pile) })); if (highlights) paint(ranges); // Under the end of the selection, so it covers none of the text chosen // or above it, held inside the window; and only once the selection has // settled for a beat, so a press meant to start or grow a selection // never lands on the button. Kept on the canvas, so it shows wherever // the window sits. const chosen = !tying.note && !pressed && selectionLines(); const last = chosen && chosen.at(-1); if (last && offerSince === null) offerSince = performance.now(); if (!last) offerSince = null; const settled = offerSince !== null && performance.now() - offerSince > 400; const right = last ? (measure(selection.current!.target.window)?.rect.right ?? origin.right) : 0; const nextOffer = last && settled ? { x: Math.max(8, Math.min(Math.min(last.right - 6, right - PILL - 8) - origin.left, origin.width - PILL)), y: Math.max(8, Math.min(last.bottom + 4 - origin.top, origin.height - 32)), } : null; if (tying.note && !board.windows[tying.note]) tying.note = null; const start = tying.note && noteEnd(tying.note, tying); const found = tying.note ? pick(tying.x, tying.y) : undefined; const nextPreview = start ? curve(local(start), local({ x: tying.x, y: tying.y })) : null; const nextLanding = found ? { ...shift(found.rect), window: found.target.kind === 'window' } : null; const now = JSON.stringify([nextChips, next, nextHoles, nextPiles, nextOffer, nextPreview, nextLanding, nextOcclusion]); if (now !== shown) { if (audit) { const was = shown ? (JSON.parse(shown) as unknown[]) : []; const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); const differ = [nextChips, next, nextHoles, nextPiles, nextOffer, nextPreview, nextLanding, nextOcclusion].flatMap((part, at) => same(part, was[at]) ? [] : at === 1 ? next.filter((path, index) => !same(path, (was[1] as unknown[] | undefined)?.[index])).map((path) => path.id) : ['chips', 'paths', 'holes', 'piles', 'offer', 'preview', 'landing', 'occlusion'][at], ); ((window as any).__albedoStale ??= []).push({ at: Math.round(performance.now()), differ, reason }); wake('audit'); } shown = now; still = 0; [chipPaths, paths, holes, piles, offer, preview, landing, occlusion] = [nextChips, next, nextHoles, nextPiles, nextOffer, nextPreview, nextLanding, nextOcclusion]; } // Nothing wakes the frames when the note button's beat is up, so they wait it out. if (!audit && (++still < SETTLE || (last && !settled))) frame = requestAnimationFrame(tick); }; // The kit sets the flag: a changed frame found at rest is a line that went stale. const audited = (window as any).__albedoAudit ? setInterval(() => !frame && still >= SETTLE && draw(true), 500) : 0; const move = () => tying.note && wake(); const wakers = ['scroll', 'pointerdown', 'pointerup', 'pointerover', 'keydown', 'selectionchange', 'transitionstart', 'transitionend', 'animationstart', 'animationend', 'load']; for (const name of wakers) document.addEventListener(name, wake, { capture: true, passive: true }); document.addEventListener('pointermove', move, { capture: true, passive: true }); window.addEventListener('resize', wake); window.addEventListener('message', wake); const changes = new MutationObserver(wake); changes.observe(element!.parentElement!, { subtree: true, childList: true, attributes: true, characterData: true }); wake(); return () => { clearInterval(audited); cancelAnimationFrame(frame); for (const name of wakers) document.removeEventListener(name, wake, { capture: true }); document.removeEventListener('pointermove', move, { capture: true }); window.removeEventListener('resize', wake); window.removeEventListener('message', wake); changes.disconnect(); }; });</script>
<svg class="tethers" bind:this={element} aria-hidden="true"> <defs> <mask id="{uid}-notes" maskUnits="userSpaceOnUse" x={-FAR} y={-FAR} width={2 * FAR} height={2 * FAR}> <rect x={-FAR} y={-FAR} width={2 * FAR} height={2 * FAR} fill="white" /> {#each holes as hole, index (index)}<rect x={hole.x} y={hole.y} width={hole.width} height={hole.height} rx="12" fill="black" />{/each} </mask> <clipPath id="{uid}-clear"> <path clip-rule="evenodd" d="M{-FAR} {-FAR}H{FAR}V{FAR}H{-FAR}Z{holes.map((hole) => `M${hole.x} ${hole.y}h${hole.width}v${hole.height}h${-hole.width}Z`).join('')}" /> </clipPath> </defs> <g mask="url(#{uid}-notes)"> <!-- Highlights and short endpoint stubs belong on their target, but the connecting curves must not cross other windows' contents. --> {#each paths as path (path.id)} {#if path.box}<rect class="passage" class:lit={path.lit} class:sent={path.sent} x={path.box.x} y={path.box.y} width={path.box.width} height={path.box.height} rx="3" />{/if} {#each path.lines ?? [] as line, index (index)}<rect class="passage words" class:lit={path.lit} class:sent={path.sent} x={line.x} y={line.y} width={line.width} height={line.height} rx="2" />{/each} {#if path.d} <g style:opacity={path.fade < 1 ? path.fade : undefined}> {#if path.stub}<path d={path.stub} class:sent={path.sent} class:lit={path.lit} />{/if} <circle cx={path.dot.x} cy={path.dot.y} r={path.lit ? 3.5 : 2.5} class:sent={path.sent} /> </g> {/if} {/each} <ConnectorClip {...occlusion}> {#each chipPaths as path (path.id)} <path d={path.d} class="chip-tether" /> {/each} {#each paths as path (path.id)} {#if path.d} <g style:opacity={path.fade < 1 ? path.fade : undefined}> <path d={path.d} class:sent={path.sent} class:lit={path.lit} class:away={path.away} /> </g> {#if path.away} <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions (a pointer shortcut: the notes are on their canvas for the keyboard too) --> <g class="reach" clip-path="url(#{uid}-clear)" onclick={() => reach(path.ids, path.passages)}> <title>{path.ids.length > 1 ? `show the ${path.ids.length} notes` : 'show the note'}</title> <path d={path.d} /> {#if path.count} <circle class="count" cx={path.count.x} cy={path.count.y} r="8" /> <text x={path.count.x} y={path.count.y}>{path.ids.length}</text> {/if} </g> {/if} {/if} {/each} {#each piles as pile, index (index)} <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions (a pointer shortcut: the chat scrolls for the keyboard too) --> <g class="reach" clip-path="url(#{uid}-clear)" onclick={() => showTarget(nearest(pile.away))}> <title>scroll to the nearest of {pile.away.length}</title> <circle class="count" cx={pile.x} cy={pile.y} r="8" /><text x={pile.x} y={pile.y}>{pile.away.length}</text> </g> {/each} </ConnectorClip> </g> {#if landing}<rect class="landing" class:whole={landing.window} x={landing.x - 2} y={landing.y - 1} width={landing.width + 4} height={landing.height + 2} rx="6" />{/if} {#if preview}<path d={preview} class="preview" />{/if}</svg>{#if offer} <button class="selection-note" style:left="{offer.x}px" style:top="{offer.y}px" title="leave a note on the selected text" onpointerdown={noteSelection}> <svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"><path d="M3 3.5h10v7H7l-3 2.5v-2.5H3z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" /></svg> note </button>{/if}
<style> .tethers { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; z-index: var(--z-tethers); } path { fill: none; stroke: color-mix(in oklab, var(--accent) 55%, transparent); stroke-width: 1.5; } path.chip-tether { stroke: color-mix(in oklab, var(--accent) 40%, transparent); stroke-width: 1.5; stroke-dasharray: 4 6; } path.lit { stroke: var(--accent); stroke-width: 2.25; } /* From a note out of its canvas's view: the line stops at the canvas's edge, and a click on it goes to the note. */ path.away { stroke-dasharray: 3 5; } .reach { cursor: pointer; } .reach path { stroke: transparent; stroke-width: 14; pointer-events: stroke; } .reach .count { pointer-events: all; } .reach text { fill: var(--surface); font-size: 10px; font-weight: 650; text-anchor: middle; dominant-baseline: central; pointer-events: none; } .passage { fill: color-mix(in oklab, var(--accent) 10%, transparent); stroke: color-mix(in oklab, var(--accent) 35%, transparent); stroke-width: 1; } .passage.lit { fill: color-mix(in oklab, var(--accent) 18%, transparent); stroke: var(--accent); } circle { fill: var(--accent); } .sent { opacity: 0.35; } .landing { fill: color-mix(in oklab, var(--accent) 12%, transparent); stroke: color-mix(in oklab, var(--accent) 70%, transparent); stroke-width: 1.5; } .landing.whole { fill: none; rx: 12px; } /* A passage's own words, tinted line by line, without an outline. */ .passage.words { stroke: none; } .passage.words.lit { fill: color-mix(in oklab, var(--accent) 24%, transparent); } :global(::highlight(albedo-note-normal)) { background-color: color-mix(in oklab, var(--accent) 16%, transparent); } :global(::highlight(albedo-note-lit)) { background-color: color-mix(in oklab, var(--accent) 30%, transparent); } :global(::highlight(albedo-note-sent)) { background-color: color-mix(in oklab, var(--accent) 7%, transparent); } /* Under the end of the selection; the note itself goes beside the window. */ .selection-note { position: absolute; z-index: var(--z-board-controls); display: flex; align-items: center; gap: 4px; height: 24px; padding: 0 9px 0 7px; color: var(--surface); font: inherit; font-size: calc(12px * var(--ui-scale)); font-weight: 560; background: var(--accent); border: 0; border-radius: 999px; box-shadow: var(--shadow-raised); cursor: pointer; } .selection-note path { stroke: currentColor; stroke-width: 1.4; } .preview { stroke: var(--accent); stroke-dasharray: none; }</style>