From e65767a1c00b5908a09e6f491c6eebbfb0974b09 Mon Sep 17 00:00:00 2001 From: Chris <76012699+ChrisPlayer@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:56:12 +0200 Subject: [PATCH 1/7] feat(deck): show the bar's tabs as icons, the shown tab's name unrolling from its icon The other tabs and icon-only apps name themselves in a tooltip under the bar, on hover or keyboard focus, gliding from one thing to the next. Renamed tabs show their own name; edit mode names each thing with how to edit it. --- src/components/island/deck/DeckBar.svelte | 240 ++++++++++++++++++++-- src/lib/island/bar.test.ts | 33 +++ src/lib/island/bar.ts | 24 +++ src/lib/island/deck-tabs.test.ts | 8 +- src/lib/island/deck-tabs.ts | 18 ++ 5 files changed, 308 insertions(+), 15 deletions(-) diff --git a/src/components/island/deck/DeckBar.svelte b/src/components/island/deck/DeckBar.svelte index 5e3134f..2dcb9cf 100644 --- a/src/components/island/deck/DeckBar.svelte +++ b/src/components/island/deck/DeckBar.svelte @@ -6,6 +6,12 @@ on the right: the quotas, the pin, Settings (the panel under the bar, `settings/SettingsPanel.svelte`) and the sound. + Each tab is its icon (`DECK_TAB_ICONS`); the tab shown also unrolls its + name from it, on a soft pill, as an app the user named shows its name and + a link its host (`nameShown`). The others name themselves in a tooltip + under the bar, on hover or focus (`barTip`), which glides from one thing + to the next while the pointer moves along the bar. + In edit mode (`frame.editing`, asked from Settings or by a right-click on the bar), each thing wiggles a little: a drag moves it along its row (`RowSorter`), a click renames it in place, × takes it out (the last tab @@ -19,10 +25,10 @@ import { Check, Gauge, Globe, Pin, PinOff, Settings, Volume2, VolumeX, X } from '@lucide/svelte'; import type { Companion, Member } from '../../../lib/companion/companion.svelte'; import { writeCompanionPrefs } from '../../../lib/companion/prefs'; - import { barTabs, canRemove, LABEL_MAX, linkHost, moveItem, moveTool, removeItem, removeTool, renameItem, shownTab, writeBarEdit, type BarItem, type BarTool } from '../../../lib/island/bar'; + import { barTabs, barTip, canRemove, LABEL_MAX, linkHost, moveItem, moveTool, nameShown, removeItem, removeTool, renameItem, shownTab, tipLeft, writeBarEdit, type BarItem, type BarTool } from '../../../lib/island/bar'; import { appIcons as icons, type Bar } from '../../../lib/island/bar.svelte'; import { RowSorter } from '../../../lib/island/bar-sort.svelte'; - import { tabKey, type DeckTab } from '../../../lib/island/deck-tabs'; + import { DECK_TAB_ICONS, tabKey, type DeckTab } from '../../../lib/island/deck-tabs'; import { gazeOf, moodOf } from '../../../lib/island/face'; import type { IslandFrame } from '../../../lib/island/frame.svelte'; import { settingsPanel } from '../../../lib/island/settings-panel.svelte'; @@ -197,6 +203,77 @@ } const waitingSaid = $derived(fill(copy.sessions.waitingCount, { count: String(companion.blocking) })); + + /** How long the pointer rests on a thing before its tooltip shows, and how long it lingers once left. */ + const TIP_DELAY_MS = 320; + const TIP_LINGER_MS = 90; + /** A tooltip just closed: the next one shows at once, as the pointer runs along the bar. */ + const TIP_WARM_MS = 500; + + let barRow = $state(null); + let tipBox = $state(null); + /** The thing whose tooltip shows, by key, and where the tooltip sits. */ + let tip = $state<{ key: string; left: number } | null>(null); + /** The tooltip moves from a thing to its neighbour: it glides rather than appearing there. */ + let gliding = $state(false); + let tipTimer: ReturnType | undefined; + let warmUntil = 0; + $effect(() => () => clearTimeout(tipTimer)); + + const tipItem = $derived(tip ? layout.items.find((item) => item.key === tip!.key) : undefined); + const tipSays = $derived(tipItem && renaming !== tipItem.key && items.carried === null ? barTip(tipItem, tab, editing) : null); + + /** The tooltip goes under `node`, kept inside the bar; the deck's zoom while it blooms in left out. */ + async function placeTip(node: HTMLElement, key: string) { + if (!barRow || !node.isConnected) return; + const outer = barRow.getBoundingClientRect(); + const box = node.getBoundingClientRect(); + const zoom = barRow.offsetWidth > 0 ? outer.width / barRow.offsetWidth : 1; + const centre = (box.left + box.width / 2 - outer.left) / zoom; + gliding = tip !== null && tip.key !== key; + tip = { key, left: tip?.left ?? 0 }; + // Its new words drawn, its width is known. + await tick(); + if (tip?.key === key && barRow) tip.left = tipLeft(centre, tipBox?.offsetWidth ?? 0, barRow.offsetWidth); + } + + function openTip(node: HTMLElement, key: string, now: boolean) { + clearTimeout(tipTimer); + if (now || tip !== null || performance.now() < warmUntil) void placeTip(node, key); + else tipTimer = setTimeout(() => void placeTip(node, key), TIP_DELAY_MS); + } + + function closeTip(key: string, now = false) { + clearTimeout(tipTimer); + if (tip?.key !== key) return; + const close = () => { + tip = null; + warmUntil = performance.now() + TIP_WARM_MS; + }; + if (now) close(); + else tipTimer = setTimeout(close, TIP_LINGER_MS); + } + + /** A thing of the bar names itself in the tooltip: on hover after a moment, on keyboard focus at once; a press puts it away. */ + const tipped = (key: string) => (node: HTMLElement) => { + const hover = () => openTip(node, key, false); + const focus = () => node.matches(':focus-visible') && openTip(node, key, true); + const leave = () => closeTip(key); + const press = () => closeTip(key, true); + node.addEventListener('pointerenter', hover); + node.addEventListener('focus', focus); + node.addEventListener('pointerleave', leave); + node.addEventListener('blur', leave); + node.addEventListener('pointerdown', press); + return () => { + node.removeEventListener('pointerenter', hover); + node.removeEventListener('focus', focus); + node.removeEventListener('pointerleave', leave); + node.removeEventListener('blur', leave); + node.removeEventListener('pointerdown', press); + closeTip(key, true); + }; + }; {#snippet face(item: BarItem)} @@ -210,7 +287,13 @@ {nameOf(item)} {:else} - {nameOf(item)} + {@const Icon = DECK_TAB_ICONS[item.tab]} + + + + {/if} {/snippet} @@ -219,7 +302,7 @@ {/snippet} -
+
{:else} - {/if} @@ -395,6 +488,14 @@ {/if}
+ + +
diff --git a/src/components/island/views/TodoCard.svelte b/src/components/island/views/TodoCard.svelte index 47571d1..73be013 100644 --- a/src/components/island/views/TodoCard.svelte +++ b/src/components/island/views/TodoCard.svelte @@ -12,6 +12,13 @@ import { todoCount } from '../../../lib/companion/todo'; import { fill, strings } from '../../../lib/strings'; + interface Props { + /** The list scrolls inside a deck tile with no bar, its edges fading (`deck-scroll`); its height is the tile's to set. */ + tile?: boolean; + } + + let { tile = false }: Props = $props(); + const view = useView(); const todos = $derived(view.companion.todos); const copy = strings.companion.todo; @@ -51,7 +58,7 @@ {#if items.length > 0} -
    +
      {#each items as item (item.id)} {@const done = item.doneAt !== null}
    • From 8edb95b743896e9a658492477a9eec4b7603f653 Mon Sep 17 00:00:00 2001 From: Chris <76012699+ChrisPlayer@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:53:16 +0200 Subject: [PATCH 6/7] feat(island): a collapsed pill that answers at a glance One state at a time, from a pure priority (pill-view.ts): an agent waiting, in amber and named; a told line in green; the rest mode; the agents at work with a hairline goal bar; the robot alone. The dots and the detail line move to hover and keyboard focus. sessions=none also empties the pill, to stage the idle state. --- src/components/island/IslandBar.svelte | 259 ++++++++++++++++--------- src/lib/island/pill-view.test.ts | 74 +++++++ src/lib/island/pill-view.ts | 129 ++++++++++++ src/lib/island/pose.ts | 4 +- src/lib/strings.en.ts | 2 + src/lib/strings.fr.ts | 1 + 6 files changed, 375 insertions(+), 94 deletions(-) create mode 100644 src/lib/island/pill-view.test.ts create mode 100644 src/lib/island/pill-view.ts diff --git a/src/components/island/IslandBar.svelte b/src/components/island/IslandBar.svelte index a448987..595b264 100644 --- a/src/components/island/IslandBar.svelte +++ b/src/components/island/IslandBar.svelte @@ -1,14 +1,19 @@ @@ -45,6 +51,8 @@ import { bloom, blurAway, ROLL_MS, rollIn, rollOut } from '../../lib/island/motion'; import { restLine, robotWidth, sessionLine, type TimerSource } from '../../lib/island/pill-rest'; import { pillStops, stepStop, stopAt, WHEEL_STILL, wheelStep } from '../../lib/island/pill-stops'; + import { posed } from '../../lib/island/posed.svelte'; + import { fitWords, pillText, pillView } from '../../lib/island/pill-view'; import { QuotaWatch } from '../../lib/island/quotas.svelte'; import { requestQueue } from '../../lib/island/requests'; import { isLive } from '../../lib/island/sessions'; @@ -76,8 +84,10 @@ /** With a request to answer: the line, then what is asked and the three answers. */ const ASK = { w: 480, h: 78 }; const PEEK = { min: 240, max: 420, chrome: 82 }; - /** The collapsed pill with a conversation at work: its line, the face, the gaps and the padding, and the dots. */ - const LIVE = { min: 200, max: 360, chrome: 74 }; + /** The pill at rest: its words, the face, the gap and the padding. */ + const VIEW = { min: 150, max: 420, chrome: 68 }; + /** The longest detail the hover tells, cut on a word. */ + const DETAIL_MAX = 54; /** Each dot's hover target: wider than the dot. */ const DOT_STEP = 12; /** How long the answers take no click once a request shows. */ @@ -85,11 +95,13 @@ let barFace = $state(null); let statusWidth = $state(0); - let liveWidth = $state(0); + let viewWidth = $state(0); const lead = $derived(companion.members[0]!); /** The conversations at work, and the one the wheel picked among them. */ - const liveRows = $derived(sessions.rows.filter(isLive)); + /** Home's session rows; a capture pose (`sessions=none`, `pose.ts`) shows none, in the dev bundle only. */ + const rows = $derived(import.meta.env.DEV && posed.idle ? [] : sessions.rows); + const liveRows = $derived(rows.filter(isLive)); let picked = $state(null); const leadSession = $derived(liveRows.find((row) => row.id === picked) ?? sessions.lead); /** The thread followed from Home keeps the line whatever else starts (`followed.ts`). */ @@ -129,7 +141,7 @@ if (picked !== null && !liveRows.some((row) => row.id === picked)) picked = null; }); - const dots = $derived(pillDots(sessions.rows, companion.blocking)); + const dots = $derived(pillDots(rows, companion.blocking)); /** The pointer is over the dots' strip, and over which dot. */ let onDots = $state(false); let hoveredKey = $state(null); @@ -152,27 +164,50 @@ const inProject = $derived(ask ? companion.threads.some((thread) => thread.id === ask.request.threadId && thread.projectId) : false); /** Each row of answers arms on its own: the scope's too. */ const armKey = $derived(ask ? (scoping === ask.id ? `scope:${ask.id}` : ask.id) : null); - /** The last request a rule answered: the pill peeks with its line, a shield by the dots. */ + /** The last request a rule answered: told in the line a few seconds, its shield before it. */ const trace = $derived(companion.rules.trace); const traceLine = $derived(trace ? { text: fill(copy.allow.traced, { rule: trace.text }), tone: 'done' as const } : null); - /** The line the peek shows: a hovered dot, else the request, else a thread just ended, else the status. */ - const line = $derived.by(() => { - if (hoveredDot) return dotLine(hoveredDot); - if (ask && origin) return { text: queue.length > 1 ? `${origin.title} · ${fill(card.of, { index: '1', count: String(queue.length) })}` : origin.title, tone: 'attention' }; - if (told && frame.flashing) return told; - return traceLine ?? status; + /** The first request's thread, when Bots knows it. */ + const asker = $derived.by(() => { + const first = queue[0]; + const thread = first ? companion.threads.find((entry) => entry.id === first.request.threadId) : undefined; + return thread ? thread.title || strings.companion.untitled : null; }); - /** While the wheel turns, the hover's peek gives way to where the wheel lands; flashes, requests and a dot's name do not. */ + /** The row the pill follows alone: followed from Home, else picked with the wheel. */ + const focusRow = $derived(sessions.followedRow ?? (picked !== null ? (liveRows.find((row) => row.id === picked) ?? null) : null)); + /** What the pill says at rest (`pill-view.ts`). With a request to answer, the asking thread is named and the queue counted aside. */ + const view = $derived( + pillView({ + waiting: asking ? { count: 1, from: origin?.title ?? null } : { count: companion.blocking, from: asker }, + told: told && frame.flashing ? { text: told.text, tone: told.tone } : null, + rule: traceLine?.text ?? null, + rest: live && live.kind !== 'live' ? live : null, + robot: rest === 'robot', + focus: focusRow ? sessionLine(focusRow, (focusRow.projectId && companion.projects.get(focusRow.projectId)) || '') : null, + working: { count: liveRows.length, first: liveRows[0] ? liveRows[0].title || strings.companion.untitled : null }, + goal: (focusRow ?? liveRows.find((row) => row.progress && row.progress.total > 0))?.progress ?? null + }) + ); + const viewText = $derived(pillText(view)); + const meta = $derived(asking && queue.length > 1 ? fill(card.of, { index: '1', count: String(queue.length) }) : view.meta); + + /** The pill is focused from the keyboard: it tells the hover's detail. */ + let focused = $state(false); + /** While the wheel turns, the hover's detail gives way to where the wheel lands. */ let wheeling = $state(false); - const peeking = $derived((frame.size === 'peek' && (!wheeling || frame.flashing)) || asking || hoveredDot !== null || trace !== null); + /** Hovered or focused: the detail and the dots. */ + const hovered = $derived(((frame.hovering && !wheeling) || focused || onDots) && !companion.dropped); + /** The hover's detail replaces the rest's words; a request, a thing just told and a dot's name keep theirs. */ + const detail = $derived(hoveredDot !== null || (hovered && (view.state === 'rest' || view.state === 'working' || view.state === 'idle'))); + /** The line the detail shows: a hovered dot, else the status with the lead's step. */ + const line = $derived(hoveredDot ? dotLine(hoveredDot) : { text: fitWords(status.text, DETAIL_MAX), tone: status.tone }); + const peeking = $derived(detail || asking); - const dotsWidth = $derived((dots.length + (trace ? 1 : 0)) * DOT_STEP); + const dotsWidth = $derived(hovered ? dots.length * DOT_STEP : 0); const peekWidth = $derived(Math.round(Math.min(PEEK.max, Math.max(PEEK.min, statusWidth + PEEK.chrome + dotsWidth)))); - const collapsedWidth = $derived( - live ? Math.round(Math.min(LIVE.max, Math.max(LIVE.min, liveWidth + LIVE.chrome + dotsWidth))) : rest === 'robot' ? robotWidth(dotsWidth) : SIZES.collapsed.w - ); - const wanted = $derived(asking ? ASK : peeking ? { ...SIZES.peek, w: peekWidth } : { ...SIZES.collapsed, w: collapsedWidth }); + const restWidth = $derived(viewText ? Math.round(Math.min(VIEW.max, Math.max(VIEW.min, viewWidth + VIEW.chrome + dotsWidth))) : robotWidth(dotsWidth)); + const wanted = $derived(asking ? ASK : detail ? { ...SIZES.peek, w: peekWidth } : { ...SIZES.collapsed, w: restWidth }); // Over the dots the width holds: a dot's name is longer or shorter than the line, and the dots would slide from under the pointer. $effect(() => { const next = wanted; @@ -207,7 +242,7 @@ // The timer's drop leaves its time to the line while the line tells it. $effect(() => { - const inLine = !peeking && live?.kind === 'timer'; + const inLine = !peeking && view.glyph === 'timer'; untrack(() => (timerInLine = inLine)); }); $effect(() => () => (timerInLine = false)); @@ -291,18 +326,21 @@
      - {#if live}{/if} + {#if viewText}{/if} {#if ask && said} {@const request = ask.request} @@ -428,63 +477,91 @@ .status[data-tone='failed'] { color: var(--island-danger); } .status[data-tone='talking'] { color: #b9aefb; } .status[data-tone='idle'] { color: var(--island-muted); } - /* Collapsed, the conversation at work: its project, then what it does. */ - .live { - color: var(--island-muted); - font-size: 12px; - } - .live b { - color: var(--island-text); - font-weight: 600; - } - /* The quota's or the music's mark before the line; the measure keeps its room. */ - .live :global(.kind), - .live-measure .kind { - display: inline-block; - width: 12px; - height: 12px; - margin-right: 5px; + /* + * The pill at rest: one line, larger than the hover's detail, the name or + * the count in bold. Only someone waiting on the user turns the words amber; + * the quota's and the timer's marks keep their own tint. + */ + .say, + .say-measure { + font-size: 14px; + letter-spacing: -0.005em; + } + .say { + --say: var(--island-text); + color: var(--say); + } + .say b, + .say-measure b { + font-weight: 650; + } + .say .after { + color: color-mix(in srgb, var(--say) 80%, transparent); + } + .say[data-tone='attention'] { --say: var(--island-attention); } + .say[data-tone='done'] { --say: var(--island-done); } + .say[data-tone='failed'] { --say: var(--island-danger); } + /* A told line is one piece: it reads at full strength. */ + .bar[data-state='told'] .say .after { + color: var(--say); + font-weight: 550; + } + .glyph, + .say-measure .glyph { + display: inline-grid; + place-items: center; + width: 14px; + height: 14px; + margin-right: 7px; vertical-align: -2px; } - .live[data-tone='waiting'] b, - .live[data-tone='amber'] b { - color: var(--island-attention); - } - .live[data-tone='done'] b { - color: var(--island-done); - } - .live[data-tone='failed'] b, - .live[data-tone='red'] b { - color: var(--island-danger); - } + .glyph[data-tone='amber'] { color: var(--island-attention); } + .glyph[data-tone='red'] { color: var(--island-danger); } + .glyph[data-tone='done'] { color: var(--island-done); } /* The timer's clock: its digits keep their width, so the pill does not twitch each second. */ - .live[data-kind='timer'] .doing, - .live-measure[data-kind='timer'] .doing { + .say .after, + .say-measure { font-variant-numeric: tabular-nums; } - .live[data-paused] .doing { + .say[data-paused] .after { opacity: 0.55; } - /* The goal: how far the task list is, a short rounded meter before its count ("2/5"), in the line. */ - .goal { - display: inline-block; - width: 28px; - height: 5px; - margin: 0 7px 0 3px; + /* The count beside the words: the task list's, or the requests'. */ + .meta { + flex: none; + color: var(--island-muted); + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + } + .meta[data-tone='attention'] { + color: color-mix(in srgb, var(--island-attention) 80%, transparent); + } + .say-measure .meta { + margin-left: 10px; + } + /* + * The task list's progress, a hairline along the pill's lower lip, under + * the words. It moves only when the list does. + */ + .lip { + position: absolute; + right: 18px; + bottom: 3px; + left: 50px; + height: 2px; overflow: hidden; border-radius: 999px; background: var(--island-line-strong); - vertical-align: 1px; + pointer-events: none; } - .goal i { + .lip i { display: block; height: 100%; border-radius: inherit; background: var(--island-working); - transition: width 400ms ease; - } - .live[data-tone='waiting'] .goal i { - background: var(--island-attention); + transform-origin: left center; + transition: transform 500ms cubic-bezier(0.22, 1, 0.36, 1); } .dots { display: flex; @@ -586,10 +663,6 @@ .hint { color: var(--island-muted); } - /* The shield a rule's answer leaves by the dots. */ - .dot.rule { - color: var(--island-done); - } /* Asleep for a moment after the request shows: seen, not yet clickable. */ .answer[aria-disabled='true'], .answer:disabled { @@ -611,12 +684,11 @@ font-weight: 500; pointer-events: none; } - .live-measure { - font-size: 12px; + .say-measure { font-weight: 400; } - .live-measure b { - font-weight: 600; + .say-measure[data-state='told'] { + font-weight: 550; } .sr { position: absolute; @@ -648,6 +720,9 @@ animation: none !important; transition: none; } + .lip i { + transition: none; + } .ask { animation: ask-fade 200ms ease-out; } diff --git a/src/lib/island/pill-view.test.ts b/src/lib/island/pill-view.test.ts new file mode 100644 index 0000000..e09807b --- /dev/null +++ b/src/lib/island/pill-view.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from 'vitest'; +import { fill, strings } from '../strings'; +import type { RestLine } from './pill-rest'; +import { emphasis, fitWords, pillText, pillView, TITLE_MAX, type PillFacts } from './pill-view'; + +const copy = strings.island.pill; +const calm: PillFacts = { waiting: { count: 0, from: null }, told: null, rule: null, rest: null, robot: false, focus: null, working: { count: 0, first: null }, goal: null }; +const busy: PillFacts = { ...calm, working: { count: 3, first: 'Bench against legacy' }, goal: { done: 2, total: 5 } }; +const quota: RestLine = { name: 'Max', doing: '18 % left', progress: null, kind: 'quota', tone: 'amber' }; + +test('someone waiting comes before everything else, in amber, the asking thread named', () => { + const view = pillView({ ...busy, waiting: { count: 1, from: 'Atlas' }, told: { text: 'Port the scheduler · done', tone: 'done' }, rest: quota }); + expect(view).toMatchObject({ state: 'waiting', tone: 'attention', strong: 'Atlas', meta: null, progress: null }); + expect(pillText(view)).toBe(fill(copy.waitsOne, { name: 'Atlas' })); + expect(pillView({ ...calm, waiting: { count: 1, from: null } }).strong).toBe(copy.someone); +}); + +test('several waiting are counted', () => { + const view = pillView({ ...busy, waiting: { count: 3, from: 'Atlas' } }); + expect(view).toMatchObject({ state: 'waiting', tone: 'attention', strong: '3' }); + expect(pillText(view)).toBe(fill(copy.waitsMany, { count: '3' })); +}); + +test('a thread just ended is told next, green or red, then what a rule allowed with its shield', () => { + expect(pillView({ ...busy, told: { text: 'Port the scheduler · done', tone: 'done' }, rule: 'Allowed by a rule', rest: quota })).toMatchObject({ + state: 'told', + tone: 'done', + glyph: null, + after: 'Port the scheduler · done' + }); + expect(pillView({ ...calm, told: { text: 'Tidy the README · failed', tone: 'failed' } }).tone).toBe('failed'); + expect(pillView({ ...busy, rule: 'Allowed by a rule · Bash · bun run test' })).toMatchObject({ state: 'told', tone: 'done', glyph: 'rule' }); +}); + +test('at rest, what Settings chose comes before the agents at work, its mark tinted and not its words', () => { + expect(pillView({ ...busy, rest: quota })).toMatchObject({ state: 'rest', tone: 'plain', glyph: 'quota', glyphTone: 'amber', strong: 'Max', after: ' 18 % left', progress: null }); + const timer: RestLine = { name: 'Work', doing: '24:59', progress: null, kind: 'timer', tone: 'amber', paused: true }; + expect(pillView({ ...busy, rest: timer })).toMatchObject({ glyph: 'timer', paused: true }); + expect(pillView({ ...busy, robot: true })).toMatchObject({ state: 'idle', strong: '', after: '' }); +}); + +test('agents at work are counted, with the task list followed as a count and a hairline', () => { + const view = pillView(busy); + expect(view).toMatchObject({ state: 'working', tone: 'plain', strong: '3', meta: '2/5', progress: 0.4 }); + expect(pillText(view)).toBe(fill(copy.workingMany, { count: '3' })); + expect(pillView({ ...busy, goal: null })).toMatchObject({ meta: null, progress: null }); + expect(pillText(pillView({ ...calm, working: { count: 1, first: 'Bench against legacy' } }))).toBe(fill(copy.workingOne, { name: 'Bench against legacy' })); +}); + +test('a thread followed or picked speaks alone: at work its goal, ended its state', () => { + const focus: RestLine = { name: 'notes', doing: '2/5 Read queue.test.ts', progress: 0.4, kind: 'live', tone: 'working' }; + expect(pillView({ ...busy, focus })).toMatchObject({ state: 'working', strong: 'notes', meta: '2/5', progress: 0.4 }); + expect(pillView({ ...busy, focus: { ...focus, doing: 'Done', progress: null, tone: 'done' } })).toMatchObject({ tone: 'plain', strong: 'notes', after: ' · done', meta: null, progress: null }); + expect(pillView({ ...busy, focus: { ...focus, doing: 'Failed', tone: 'failed' } }).tone).toBe('failed'); +}); + +test('with nothing going on, the robot alone', () => { + expect(pillView(calm)).toMatchObject({ state: 'idle', strong: '', after: '', meta: null, progress: null }); +}); + +test('long titles are cut on a word, never in one', () => { + expect(fitWords('Port the scheduler', 28)).toBe('Port the scheduler'); + expect(fitWords('Write the bench runner inside the project', 28)).toBe('Write the bench runner…'); + expect(fitWords('Fix it: the login', 9)).toBe('Fix it…'); + expect(fitWords('Supercalifragilistic', 8)).toBe('Superca…'); + const named = pillView({ ...calm, waiting: { count: 1, from: 'Bench against legacy and the new parser' } }).strong; + expect(named.length).toBeLessThanOrEqual(TITLE_MAX + 1); + expect(named.endsWith('…')).toBe(true); +}); + +test('a template is split around its value', () => { + expect(emphasis('{name} is waiting for you', 'name', 'Atlas')).toEqual({ before: '', strong: 'Atlas', after: ' is waiting for you' }); + expect(emphasis('Wait for {name}', 'name', 'Atlas')).toEqual({ before: 'Wait for ', strong: 'Atlas', after: '' }); +}); diff --git a/src/lib/island/pill-view.ts b/src/lib/island/pill-view.ts new file mode 100644 index 0000000..e74e48d --- /dev/null +++ b/src/lib/island/pill-view.ts @@ -0,0 +1,129 @@ +/* + * What the collapsed pill says, one thing at a time, in this order: + * + * 1. Someone waits on the user (a permission, a question): amber, the thread + * that asks ("Atlas attend votre réponse"), or how many wait. + * 2. Something was just told: a thread ended, a pull request's CI settled + * (green, red when it failed), or a rule answered a request (its shield). + * 3. What Settings shows at rest instead of the agents: the quota, the music, + * the timer (`pill-rest.ts`), or the robot alone. + * 4. Agents at work: how many ("3 au travail"), or the one followed from Home + * or picked with the wheel, with a task list's progress when one has it. + * 5. Nothing: the robot alone, small and quiet. + * + * The words never stop mid-word: long titles are cut on a word (`fitWords`). + * The per-thread detail (each thread's state, its step) is the hover's + * (`IslandBar.svelte`). Pure, so the order is tested without the island. + */ +import { fill, strings } from '../strings'; +import type { RestLine } from './pill-rest'; + +export type PillState = 'waiting' | 'told' | 'rest' | 'working' | 'idle'; +/** The words' colour: amber only while someone waits on the user. */ +export type PillTone = 'attention' | 'done' | 'failed' | 'plain'; +/** The mark before the words, tinted on its own (`glyphTone`). */ +export type PillGlyph = 'rule' | 'quota' | 'music' | 'timer' | null; + +export interface PillView { + state: PillState; + tone: PillTone; + glyph: PillGlyph; + /** The mark's tint: the quota's level, the timer's phase. */ + glyphTone: RestLine['tone']; + /** The words, the part set in bold apart: `before`, `strong`, `after`. */ + before: string; + strong: string; + after: string; + /** A short count at the right: the task list's ("2/5") or the requests' ("1 sur 2"). */ + meta: string | null; + /** The hairline under the words, 0 to 1; null for none. */ + progress: number | null; + /** A timer stopped for now. */ + paused: boolean; +} + +export interface PillFacts { + /** Requests that stop an agent, and the title of the thread the first one comes from (null when unknown). */ + waiting: { count: number; from: string | null }; + /** A thread that ended or a pull request's CI, while the island flashes for it. */ + told: { text: string; tone: 'done' | 'failed' } | null; + /** What a rule just allowed, said in full. */ + rule: string | null; + /** The quota, the music or the timer, when Settings shows one at rest and it has something to show. */ + rest: RestLine | null; + /** Settings shows the robot alone at rest. */ + robot: boolean; + /** The thread followed from Home or picked with the wheel: its own line (`sessionLine`). */ + focus: RestLine | null; + /** The conversations at work, and the first one's name for when it is alone. */ + working: { count: number; first: string | null }; + /** The task list the pill follows: the focused thread's, else the first at work that keeps one. */ + goal: { done: number; total: number } | null; +} + +/** The longest title the pill names before cutting it on a word. */ +export const TITLE_MAX = 28; +/** The longest told line. */ +export const TOLD_MAX = 48; + +/** `text` in at most `max` characters, cut after a whole word with an ellipsis; a lone word too long is cut in it, as a last resort. */ +export function fitWords(text: string, max: number): string { + const clean = text.trim().replace(/\s+/g, ' '); + if (clean.length <= max) return clean; + const room = clean.slice(0, max); + const space = room.lastIndexOf(' '); + const kept = space > 0 ? room.slice(0, space) : room.slice(0, max - 1); + return `${kept.replace(/[\s·:;,.–-]+$/, '')}…`; +} + +/** A template's words around its one placeholder, the value set apart to be bold. */ +export function emphasis(template: string, key: string, value: string): Pick { + const mark = `{${key}}`; + const at = template.indexOf(mark); + if (at < 0) return { before: '', strong: '', after: template }; + return { before: template.slice(0, at), strong: value, after: template.slice(at + mark.length) }; +} + +/** The whole line, as a screen reader and the roll's key read it. */ +export function pillText(view: Pick): string { + return `${view.before}${view.strong}${view.after}`.trim(); +} + +const QUIET: PillView = { state: 'idle', tone: 'plain', glyph: null, glyphTone: null, before: '', strong: '', after: '', meta: null, progress: null, paused: false }; + +function goalOf(goal: PillFacts['goal']): Pick { + if (!goal || goal.total <= 0) return { meta: null, progress: null }; + return { meta: `${goal.done}/${goal.total}`, progress: Math.min(1, Math.max(0, goal.done / goal.total)) }; +} + +/** What the pill says at rest; `state: 'idle'` with no words is the robot alone. */ +export function pillView(facts: PillFacts): PillView { + const copy = strings.island.pill; + const { waiting } = facts; + if (waiting.count > 0) { + const words = + waiting.count > 1 + ? emphasis(copy.waitsMany, 'count', String(waiting.count)) + : emphasis(copy.waitsOne, 'name', waiting.from ? fitWords(waiting.from, TITLE_MAX) : copy.someone); + return { ...QUIET, state: 'waiting', tone: 'attention', ...words }; + } + if (facts.told) return { ...QUIET, state: 'told', tone: facts.told.tone, after: fitWords(facts.told.text, TOLD_MAX) }; + if (facts.rule) return { ...QUIET, state: 'told', tone: 'done', glyph: 'rule', after: fitWords(facts.rule, TOLD_MAX) }; + if (facts.robot) return QUIET; + if (facts.rest) { + const line = facts.rest; + const glyph = line.kind === 'live' ? null : line.kind; + return { ...QUIET, state: 'rest', glyph, glyphTone: line.tone, strong: fitWords(line.name, TITLE_MAX), after: line.doing ? ` ${line.doing}` : '', paused: line.paused ?? false }; + } + if (facts.focus) { + const line = facts.focus; + const name = fitWords(line.name, TITLE_MAX); + // Ended, its state follows its name ("notes · terminé"); at work, its task list's progress. + if (line.tone === 'done' || line.tone === 'failed') return { ...QUIET, state: 'working', tone: line.tone === 'failed' ? 'failed' : 'plain', strong: name, after: ` · ${line.doing.toLocaleLowerCase()}` }; + return { ...QUIET, state: 'working', ...emphasis(copy.workingOne, 'name', name), ...goalOf(facts.goal) }; + } + const { count, first } = facts.working; + if (count <= 0) return QUIET; + const words = count === 1 && first ? emphasis(copy.workingOne, 'name', fitWords(first, TITLE_MAX)) : emphasis(copy.workingMany, 'count', String(count)); + return { ...QUIET, state: 'working', ...words, ...goalOf(facts.goal) }; +} diff --git a/src/lib/island/pose.ts b/src/lib/island/pose.ts index 106c934..943ee47 100644 --- a/src/lib/island/pose.ts +++ b/src/lib/island/pose.ts @@ -30,8 +30,8 @@ * - `memory=thread|budget|machine|stopped`: the memory warning, or what the * guard stopped; * - `update=nightly|stable`: a newer Boite in Home; - * - `sessions=none`: Home's sessions shown empty, the robot napping (pair it - * with `calm=1`); + * - `sessions=none`: Home's sessions shown empty, and the pill with no agent + * at work, the robot napping (pair it with `calm=1`); * - `rest=live|quota|music|timer|robot`: what the pill shows at rest (pair `music` * with `music=playing`, `timer` with `timer=1`). A thread followed in the pill is chosen through * `localStorage` (`bots.followed`, `followed.ts`) before the page loads. diff --git a/src/lib/strings.en.ts b/src/lib/strings.en.ts index e223b49..fce69b8 100644 --- a/src/lib/strings.en.ts +++ b/src/lib/strings.en.ts @@ -59,6 +59,8 @@ export const strings = { waitingOne: 'An agent needs you', waitingMany: '{count} requests wait for you', finished: 'Done', + /** The collapsed pill's one line (`pill-view.ts`): its `{name}` or `{count}` is set in bold. */ + pill: { waitsOne: '{name} is waiting for you', waitsMany: '{count} are waiting for you', someone: 'An agent', workingOne: '{name} is working', workingMany: '{count} working' }, /** A thread that just ended, told in the pill: "Port the scheduler · done · 3 files". */ finish: { done: 'done', diff --git a/src/lib/strings.fr.ts b/src/lib/strings.fr.ts index 9a2e64e..70cf9c0 100644 --- a/src/lib/strings.fr.ts +++ b/src/lib/strings.fr.ts @@ -49,6 +49,7 @@ export const fr: Messages = { waitingOne: 'Un agent a besoin de vous', waitingMany: '{count} demandes vous attendent', finished: 'Terminé', + pill: { waitsOne: '{name} attend votre réponse', waitsMany: '{count} vous attendent', someone: 'Un agent', workingOne: '{name} au travail', workingMany: '{count} au travail' }, finish: { done: 'terminé', failed: 'en échec', From 6a34f7b20705e74cdbcd46cc7cc9d0542eb4ecff Mon Sep 17 00:00:00 2001 From: Chris <76012699+ChrisPlayer@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:53:16 +0200 Subject: [PATCH 7/7] style(island): the timer drop takes the pill's type --- src/components/island/TimerBubble.svelte | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/island/TimerBubble.svelte b/src/components/island/TimerBubble.svelte index 8745157..29a28ed 100644 --- a/src/components/island/TimerBubble.svelte +++ b/src/components/island/TimerBubble.svelte @@ -165,9 +165,10 @@ stroke: var(--tone); stroke-linecap: round; } + /* The pill's type: the drop reads as its sibling. */ .time { - font-size: 13px; - font-weight: 600; + font-size: 14px; + font-weight: 650; font-variant-numeric: tabular-nums; } .paused .time,