From ff0c4c27eefd546b9622f132d56f520ca7eeb88a Mon Sep 17 00:00:00 2001 From: Sniphs <56806984+Sniphs98@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:42:05 +0200 Subject: [PATCH 1/5] feat(gui): the update banner tucks itself into the version badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After 8 seconds (not while the pointer or keyboard is on it, nor while it is downloading or reporting a failure) the update banner shrinks and flies into the sidebar's version badge, which then turns into the update's button: "Update" → "Updating 42%" → "Restart", with the version and what a click does in its tooltip. One click downloads, the next restarts to install; where this copy can't update itself it opens the release page. With the sidebar collapsed it's a round download button in the footer. Closing the banner tucks it away the same way; Skip still drops the version. A new update offer (or a manual check) brings the banner back. Reduced motion gets a plain fade. Co-Authored-By: Claude Opus 5.5 --- packages/ui/e2e/settings.spec.ts | 38 +++++++++- packages/ui/src/lib/components/Sidebar.svelte | 9 ++- .../ui/src/lib/components/UpdateBadge.svelte | 74 +++++++++++++++++++ .../ui/src/lib/components/UpdateBanner.svelte | 67 +++++++++-------- packages/ui/src/lib/components/flyToBadge.ts | 42 +++++++++++ packages/ui/src/lib/stores/update.ts | 16 +++- packages/ui/src/lib/stores/updateActions.ts | 48 ++++++++++++ 7 files changed, 259 insertions(+), 35 deletions(-) create mode 100644 packages/ui/src/lib/components/UpdateBadge.svelte create mode 100644 packages/ui/src/lib/components/flyToBadge.ts create mode 100644 packages/ui/src/lib/stores/updateActions.ts diff --git a/packages/ui/e2e/settings.spec.ts b/packages/ui/e2e/settings.spec.ts index 194bcf3..a55295a 100644 --- a/packages/ui/e2e/settings.spec.ts +++ b/packages/ui/e2e/settings.spec.ts @@ -129,7 +129,7 @@ test('the footer gear opens Settings; theme, interval, and update prefs work', a await expect(page.getByText('Update available — v2.0.0')).toBeVisible(); }); -test('startup update-available raises the banner; dismiss hides it', async ({ page }) => { +test('startup update-available raises the banner; closing it tucks it into the version badge', async ({ page }) => { await boot(page, { fireUpdateOnBoot: true }); const banner = page.getByText('Update available — v2.0.0'); @@ -137,6 +137,42 @@ test('startup update-available raises the banner; dismiss hides it', async ({ pa await page.getByRole('button', { name: 'Dismiss update notice' }).click(); await expect(banner).toHaveCount(0); + // The version badge now offers it. + await expect(page.locator('#update-badge')).toHaveText('Update'); + await expect(page.locator('#update-badge')).toHaveAttribute('title', 'BetterSshClient v2.0.0 is out — click to update'); + await expect(page.getByText('v1.4.2', { exact: true })).toHaveCount(0); +}); + +test('the banner hides itself into the badge after a few seconds; the badge downloads, then restarts', async ({ page }) => { + await page.clock.install(); + await boot(page, { fireUpdateOnBoot: true }); + const banner = page.getByText('Update available — v2.0.0'); + await expect(banner).toBeVisible(); + + // Not while the pointer rests on it… + await banner.hover(); + await page.clock.runFor(10_000); + await expect(banner).toBeVisible(); + // …but soon after it leaves. + await page.mouse.move(5, 5); + await page.clock.runFor(9_000); + await expect(banner).toHaveCount(0); + + const badge = page.locator('#update-badge'); + await expect(badge).toHaveText('Update'); + await badge.click(); + await expect(badge).toHaveText('Updating 42%'); + await page.evaluate(() => (window as unknown as UpdateTestWindow).__finishDownload()); + await expect(badge).toHaveText('Restart'); + await badge.click(); + await expect.poll(() => page.evaluate(() => (window as unknown as UpdateTestWindow).__restarted)).toBe(true); +}); + +test('where the app cannot update itself, the badge opens the release page', async ({ page }) => { + await boot(page, { fireUpdateOnBoot: true, canSelfUpdate: false }); + await page.getByRole('button', { name: 'Dismiss update notice' }).click(); + await page.locator('#update-badge').click(); + await expect.poll(() => page.evaluate(() => (window as unknown as UpdateTestWindow).__opened)).toBe(UPDATE.url); }); test('a settings toggle preserves a skipVersion the banner wrote out-of-band', async ({ page }) => { diff --git a/packages/ui/src/lib/components/Sidebar.svelte b/packages/ui/src/lib/components/Sidebar.svelte index 2431325..4bee815 100644 --- a/packages/ui/src/lib/components/Sidebar.svelte +++ b/packages/ui/src/lib/components/Sidebar.svelte @@ -8,6 +8,9 @@ import { get } from 'svelte/store'; import Logo from './Logo.svelte'; import ThemeToggle from './ThemeToggle.svelte'; + import UpdateBadge from './UpdateBadge.svelte'; + import { UPDATE_BADGE_ID } from './flyToBadge'; + import { availableUpdate, updateMinimized } from '$lib/stores/update'; import { Button, Icon, StatusDot, type IconName } from '$lib/theme'; import { activeEntity } from '$lib/stores/activeEntity'; import { @@ -269,8 +272,12 @@ > - {#if appVersion && !$sidebarCollapsed} + {#if $availableUpdate && $updateMinimized} + + + {:else if appVersion && !$sidebarCollapsed} diff --git a/packages/ui/src/lib/components/UpdateBadge.svelte b/packages/ui/src/lib/components/UpdateBadge.svelte new file mode 100644 index 0000000..a4f67da --- /dev/null +++ b/packages/ui/src/lib/components/UpdateBadge.svelte @@ -0,0 +1,74 @@ + + +{#if collapsed} + +{:else} + +{/if} diff --git a/packages/ui/src/lib/components/UpdateBanner.svelte b/packages/ui/src/lib/components/UpdateBanner.svelte index 90b1f49..4cf32e3 100644 --- a/packages/ui/src/lib/components/UpdateBanner.svelte +++ b/packages/ui/src/lib/components/UpdateBanner.svelte @@ -7,46 +7,42 @@ // background with a progress bar, then "Restart to update" installs it. Everywhere // else (macOS until builds are signed, the portable/zip builds, .deb/.rpm) it links to // the release page instead. Skip persists the version to the shared config so it is - // never offered again; Dismiss hides it for this session only. + // never offered again. After a few seconds — or when closed — the banner flies into + // the sidebar's version badge, which then offers the update itself (UpdateBadge). + import { fly } from 'svelte/transition'; import { Icon } from '$lib/theme'; - import { - availableUpdate, - beginUpdateDownload, - dismissUpdate, - updateDownload, - updateDownloadFailed - } from '$lib/stores/update'; - import { installUpdate, loadUpdateConfig, restartToUpdate, saveUpdateConfig } from '$lib/ipc/commands'; - import { openExternal } from '$lib/ipc/openExternal'; + import { availableUpdate, dismissUpdate, minimizeUpdate, updateDownload, updateMinimized } from '$lib/stores/update'; + import { openReleasePage, restartNow, startUpdate } from '$lib/stores/updateActions'; + import { loadUpdateConfig, saveUpdateConfig } from '$lib/ipc/commands'; import { lastError } from '$lib/stores/notifications'; + import { flyToBadge } from './flyToBadge'; const message = (e: unknown): string => (e instanceof Error ? e.message : String(e)); let busy = $state(false); - async function openReleasePage(url: string): Promise { - try { - await openExternal(url); - } catch (e) { - lastError.set(message(e)); - } - } - function updateNow(): void { - beginUpdateDownload(); - // Progress and completion arrive as events; only a failure comes back here. - installUpdate().catch((e) => updateDownloadFailed(message(e))); + startUpdate(); } async function restart(): Promise { busy = true; - try { - await restartToUpdate(); - } catch (e) { - lastError.set(message(e)); - busy = false; - } + await restartNow(); + busy = false; } + // How long the banner stays before tucking itself away. Not while the pointer or the + // keyboard is on it, and not while it has news to show (downloading, failed). + const AUTO_HIDE_MS = 8000; + let hovered = $state(false); + let focused = $state(false); + $effect(() => { + const phase = $updateDownload.phase; + if (!$availableUpdate || $updateMinimized || hovered || focused) return; + if (phase === 'downloading' || phase === 'failed') return; + const timer = setTimeout(minimizeUpdate, AUTO_HIDE_MS); + return () => clearTimeout(timer); + }); + // Persist the skip against the current config so `check_on_startup` is preserved. async function skip(version: string): Promise { busy = true; @@ -67,14 +63,23 @@ const secondary = `${action} text-muted hover:bg-surface-inset hover:text-fg`; -{#if $availableUpdate} +{#if $availableUpdate && !$updateMinimized} {@const info = $availableUpdate} {@const dl = $updateDownload} -
+
+
(hovered = true)} + onmouseleave={() => (hovered = false)} + onfocusin={() => (focused = true)} + onfocusout={() => (focused = false)} >
@@ -112,9 +117,9 @@ diff --git a/packages/ui/src/lib/components/flyToBadge.ts b/packages/ui/src/lib/components/flyToBadge.ts new file mode 100644 index 0000000..a117cec --- /dev/null +++ b/packages/ui/src/lib/components/flyToBadge.ts @@ -0,0 +1,42 @@ +import { cubicInOut } from 'svelte/easing'; +import type { TransitionConfig } from 'svelte/transition'; + +/** The element the update banner flies into: the sidebar's version badge (or, with the + * sidebar collapsed, the update button standing in for it). */ +export const UPDATE_BADGE_ID = 'update-badge'; + +/** + * Outro for the update banner: it shrinks and slides into the version badge, fading as + * it lands — so it's clear where the update went rather than it just vanishing. Falls + * back to a plain fade when the badge isn't on screen or motion is reduced. + */ +export function flyToBadge(node: HTMLElement, { duration = 500 }: { duration?: number } = {}): TransitionConfig { + const target = document.getElementById(UPDATE_BADGE_ID); + const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; + const card = (node.firstElementChild as HTMLElement | null) ?? node; + if (!target || reduced) return { duration: 150, css: (t) => `opacity: ${t}` }; + + const from = card.getBoundingClientRect(); + const to = target.getBoundingClientRect(); + if (from.width === 0 || from.height === 0) return { duration: 150, css: (t) => `opacity: ${t}` }; + const dx = to.left + to.width / 2 - (from.left + from.width / 2); + const dy = to.top + to.height / 2 - (from.top + from.height / 2); + const sx = to.width / from.width; + const sy = to.height / from.height; + // The transform applies to `node` (the full-width wrapper), so it scales about the + // card's centre expressed in the wrapper's own coordinates. + const box = node.getBoundingClientRect(); + const originX = from.left - box.left + from.width / 2; + const originY = from.top - box.top + from.height / 2; + + return { + duration, + easing: cubicInOut, + // `u` runs 0 → 1 as it leaves: 0 is where the banner is, 1 is on the badge. + css: (t, u) => + `transform: translate(${dx * u}px, ${dy * u}px) scale(${1 + (sx - 1) * u}, ${1 + (sy - 1) * u});` + + `transform-origin: ${originX}px ${originY}px;` + + // Solid most of the way, fading only in the last quarter — so it's seen landing. + `opacity: ${Math.min(1, t * 4)};` + }; +} diff --git a/packages/ui/src/lib/stores/update.ts b/packages/ui/src/lib/stores/update.ts index 0ed0f1f..706fe16 100644 --- a/packages/ui/src/lib/stores/update.ts +++ b/packages/ui/src/lib/stores/update.ts @@ -6,14 +6,26 @@ import type { UpdateDownloadProgress, UpdateInfoDto } from '$lib/bindings'; // (tech-gui.md §4.3). The banner (AppShell) renders whenever this is non-null. export const availableUpdate = writable(null); -/** Show an available update (from the event or a manual check). */ +/** Whether the banner has tucked itself into the sidebar's version badge — after a few + * seconds, or when closed. The update stays offered there (the badge turns into an + * "Update" button) until it's installed or skipped. */ +export const updateMinimized = writable(false); + +/** Show an available update (from the event or a manual check), as the banner. */ export function offerUpdate(info: UpdateInfoDto): void { availableUpdate.set(info); + updateMinimized.set(false); +} + +/** Tuck the banner into the version badge. */ +export function minimizeUpdate(): void { + updateMinimized.set(true); } -/** Dismiss the banner for this session (no persistence — it returns next launch). */ +/** Drop the offer for this session (no persistence — it returns next launch). */ export function dismissUpdate(): void { availableUpdate.set(null); + updateMinimized.set(false); } /** Where installing the offered update in place stands. */ diff --git a/packages/ui/src/lib/stores/updateActions.ts b/packages/ui/src/lib/stores/updateActions.ts new file mode 100644 index 0000000..924fe06 --- /dev/null +++ b/packages/ui/src/lib/stores/updateActions.ts @@ -0,0 +1,48 @@ +import { get } from 'svelte/store'; +import { installUpdate, restartToUpdate } from '$lib/ipc/commands'; +import { openExternal } from '$lib/ipc/openExternal'; +import { lastError } from './notifications'; +import { availableUpdate, beginUpdateDownload, updateDownload, updateDownloadFailed } from './update'; + +// What installing the offered update does, shared by the banner and the sidebar's +// version badge (which the banner tucks into), so both act alike. + +const message = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +/** Start downloading the update in the background; progress and completion arrive as events. */ +export function startUpdate(): void { + beginUpdateDownload(); + installUpdate().catch((e) => updateDownloadFailed(message(e))); +} + +/** Quit and install the downloaded update. */ +export async function restartNow(): Promise { + try { + await restartToUpdate(); + } catch (e) { + lastError.set(message(e)); + } +} + +export async function openReleasePage(url: string): Promise { + try { + await openExternal(url); + } catch (e) { + lastError.set(message(e)); + } +} + +/** The badge's one action, whatever stage the update is at: this copy can't update + * itself → the release page; not downloaded yet (or failed) → download; downloaded → + * restart. While downloading, nothing. */ +export function updateFromBadge(): void { + const info = get(availableUpdate); + if (!info) return; + if (!info.canSelfUpdate) { + void openReleasePage(info.url); + return; + } + const dl = get(updateDownload); + if (dl.phase === 'idle' || dl.phase === 'failed') startUpdate(); + else if (dl.phase === 'ready') void restartNow(); +} From 4c2bdec4065001ae790044a062e8ab315cc5b7ab Mon Sep 17 00:00:00 2001 From: Sniphs <56806984+Sniphs98@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:45:47 +0200 Subject: [PATCH 2/5] fix(dev): start the dev app from its package folder, so it has its own version `electron packages/electron/dist/main.js` runs the file without its package.json, so app.getVersion() is Electron's (33.4.11): the version badge showed that, and no update was ever offered. `electron packages/electron` reads the package and reports the app's version. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4e48349..dcb45c6 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ ], "scripts": { "dev": "npm run dev --workspace packages/ui", - "dev:electron": "npm run build --workspace packages/electron && electron packages/electron/dist/main.js", + "dev:electron": "npm run build --workspace packages/electron && electron packages/electron", "build": "npm run build --workspace packages/ui && npm run build --workspace packages/electron", "check": "npm run check --workspace packages/ui && npm run check --workspace packages/electron", "test": "npm run test --workspace packages/electron && npm run test --workspace packages/ui", From 1d7498957fb2ba3ffe4e9ba8341e170bee840e3d Mon Sep 17 00:00:00 2001 From: Sniphs <56806984+Sniphs98@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:48:29 +0200 Subject: [PATCH 3/5] fix(theme): a neutral raised surface in the dark theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dark theme's raised surface (#2a2a2d — the update banner, dialogs, context menus) had a blue tint none of the other surfaces have (#171717, #212121, #1a1a1a), which showed as the banner flew into the badge. Now #2a2a2a, just as light, no tint. The light theme is blue-grey throughout on purpose and stays as it is. Co-Authored-By: Claude Opus 5.5 --- packages/ui/src/app.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css index 8a4ba70..f125524 100644 --- a/packages/ui/src/app.css +++ b/packages/ui/src/app.css @@ -34,7 +34,7 @@ :root[data-theme='dark'] { --bg: #171717; --surface: #212121; - --surface-raised: #2a2a2d; + --surface-raised: #2a2a2a; --surface-inset: #1a1a1a; --text: #ffffff; --text-muted: #aeb7c2; From a82bdba170191f42615954289c486b79d70abda9 Mon Sep 17 00:00:00 2001 From: Sniphs <56806984+Sniphs98@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:57:13 +0200 Subject: [PATCH 4/5] feat(gui): the update banner goes into the badge like a macOS genie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of shrinking evenly, the banner now narrows into a funnel towards the version badge and is drawn in through it, the side nearest the badge first — the macOS minimise. A CSS transform can't bend a box, so for the 650 ms of the animation the banner is drawn as 40 strips, copies of it clipped to one slice each, every strip transformed on its own each frame; neighbours share their edges, so the shape stays whole. The geometry (genie.ts) is pure and tested: it starts as the banner, funnels near side first, stays in one piece, ends inside the badge. The strips' layer is removed when done, however the transition ends. Reduced motion still gets a fade. Co-Authored-By: Claude Opus 5.5 --- packages/ui/src/lib/components/flyToBadge.ts | 109 ++++++++++++++----- packages/ui/src/lib/components/genie.test.ts | 53 +++++++++ packages/ui/src/lib/components/genie.ts | 98 +++++++++++++++++ 3 files changed, 234 insertions(+), 26 deletions(-) create mode 100644 packages/ui/src/lib/components/genie.test.ts create mode 100644 packages/ui/src/lib/components/genie.ts diff --git a/packages/ui/src/lib/components/flyToBadge.ts b/packages/ui/src/lib/components/flyToBadge.ts index a117cec..c6f5221 100644 --- a/packages/ui/src/lib/components/flyToBadge.ts +++ b/packages/ui/src/lib/components/flyToBadge.ts @@ -1,42 +1,99 @@ -import { cubicInOut } from 'svelte/easing'; import type { TransitionConfig } from 'svelte/transition'; +import { genieFrame, genieSlicing, type Rect } from './genie'; /** The element the update banner flies into: the sidebar's version badge (or, with the * sidebar collapsed, the update button standing in for it). */ export const UPDATE_BADGE_ID = 'update-badge'; +// Enough that the funnel's edge reads as a curve rather than steps. +const STRIPS = 40; + +function rectOf(el: Element): Rect { + const r = el.getBoundingClientRect(); + return { left: r.left, top: r.top, width: r.width, height: r.height }; +} + /** - * Outro for the update banner: it shrinks and slides into the version badge, fading as - * it lands — so it's clear where the update went rather than it just vanishing. Falls - * back to a plain fade when the badge isn't on screen or motion is reduced. + * Outro for the update banner: the macOS genie into the version badge (genie.ts) — it + * narrows into a funnel towards the badge and is drawn in through it. While it runs the + * banner is hidden and drawn as strips in a layer above everything; the layer goes when + * it's done. Falls back to a plain fade when the badge isn't on screen or motion is + * reduced. */ -export function flyToBadge(node: HTMLElement, { duration = 500 }: { duration?: number } = {}): TransitionConfig { +export function flyToBadge(node: HTMLElement, { duration = 650 }: { duration?: number } = {}): TransitionConfig { const target = document.getElementById(UPDATE_BADGE_ID); const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; const card = (node.firstElementChild as HTMLElement | null) ?? node; - if (!target || reduced) return { duration: 150, css: (t) => `opacity: ${t}` }; - - const from = card.getBoundingClientRect(); - const to = target.getBoundingClientRect(); - if (from.width === 0 || from.height === 0) return { duration: 150, css: (t) => `opacity: ${t}` }; - const dx = to.left + to.width / 2 - (from.left + from.width / 2); - const dy = to.top + to.height / 2 - (from.top + from.height / 2); - const sx = to.width / from.width; - const sy = to.height / from.height; - // The transform applies to `node` (the full-width wrapper), so it scales about the - // card's centre expressed in the wrapper's own coordinates. - const box = node.getBoundingClientRect(); - const originX = from.left - box.left + from.width / 2; - const originY = from.top - box.top + from.height / 2; + const fade: TransitionConfig = { duration: 150, css: (t) => `opacity: ${t}` }; + if (!target || reduced) return fade; + + const source = rectOf(card); + const dest = rectOf(target); + if (source.width === 0 || source.height === 0) return fade; + const { columns, reversed } = genieSlicing(source, dest); + + // The strips: each a copy of the banner, clipped to its slice. + const layer = document.createElement('div'); + layer.setAttribute('aria-hidden', 'true'); + layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:60;'; + const strips: HTMLElement[] = []; + for (let i = 0; i < STRIPS; i += 1) { + const strip = document.createElement('div'); + strip.style.cssText = + `position:fixed;left:${source.left}px;top:${source.top}px;width:${source.width}px;height:${source.height}px;` + + 'transform-origin:0 0;will-change:transform;'; + const copy = card.cloneNode(true) as HTMLElement; + copy.style.cssText = `width:${source.width}px;max-width:none;margin:0;`; + copy.removeAttribute('role'); + strip.appendChild(copy); + layer.appendChild(strip); + strips.push(strip); + } + + let removed = false; + const cleanup = (): void => { + if (removed) return; + removed = true; + layer.remove(); + }; + document.body.appendChild(layer); + node.style.visibility = 'hidden'; + // However the transition ends (finished, or cut short), the layer goes. + setTimeout(cleanup, duration + 200); + + // Local slice [a, b) of the banner (in px along the sliced axis) for f-range [from, to). + const size = columns ? source.width : source.height; + const slice = (from: number, to: number): [number, number] => + reversed ? [(1 - to) * size, (1 - from) * size] : [from * size, to * size]; + + const draw = (p: number): void => { + const frames = genieFrame(source, dest, p, STRIPS); + const opacity = p > 0.85 ? Math.max(0, (1 - p) / 0.15) : 1; + frames.forEach((frame, i) => { + const [a, b] = slice(frame.from, frame.to); + const strip = strips[i]; + // A hair of overlap so no seam shows between neighbours. + const clipA = Math.max(0, a - 0.5); + const clipB = Math.min(size, b + 0.5); + strip.style.clipPath = columns + ? `inset(0 ${source.width - clipB}px 0 ${clipA}px)` + : `inset(${clipA}px 0 ${source.height - clipB}px 0)`; + const sx = columns ? frame.dest.width / (b - a) : frame.dest.width / source.width; + const sy = columns ? frame.dest.height / source.height : frame.dest.height / (b - a); + const tx = frame.dest.left - source.left - (columns ? sx * a : 0); + const ty = frame.dest.top - source.top - (columns ? 0 : sy * a); + strip.style.transform = `translate(${tx}px, ${ty}px) scale(${sx}, ${sy})`; + strip.style.opacity = String(opacity); + }); + }; + draw(0); return { duration, - easing: cubicInOut, - // `u` runs 0 → 1 as it leaves: 0 is where the banner is, 1 is on the badge. - css: (t, u) => - `transform: translate(${dx * u}px, ${dy * u}px) scale(${1 + (sx - 1) * u}, ${1 + (sy - 1) * u});` + - `transform-origin: ${originX}px ${originY}px;` + - // Solid most of the way, fading only in the last quarter — so it's seen landing. - `opacity: ${Math.min(1, t * 4)};` + // `u` runs 0 → 1 as it leaves: 0 is the banner as it was, 1 is inside the badge. + tick: (_t, u) => { + if (u >= 1) cleanup(); + else draw(u); + } }; } diff --git a/packages/ui/src/lib/components/genie.test.ts b/packages/ui/src/lib/components/genie.test.ts new file mode 100644 index 0000000..517910e --- /dev/null +++ b/packages/ui/src/lib/components/genie.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { genieFrame, genieSlicing } from './genie'; + +// The update banner (bottom centre) and the version badge (bottom left of the sidebar). +const banner = { left: 450, top: 780, width: 576, height: 60 }; +const badge = { left: 128, top: 827, width: 70, height: 20 }; + +const span = (frames: ReturnType) => ({ + left: Math.min(...frames.map((f) => f.dest.left)), + right: Math.max(...frames.map((f) => f.dest.left + f.dest.width)), + top: Math.min(...frames.map((f) => f.dest.top)), + bottom: Math.max(...frames.map((f) => f.dest.top + f.dest.height)) +}); + +describe('genieFrame', () => { + it('slices the banner into columns when the badge is off to the side, nearest edge towards it', () => { + expect(genieSlicing(banner, badge)).toEqual({ columns: true, reversed: true }); + }); + + it('starts as the banner itself, whole and in place', () => { + const s = span(genieFrame(banner, badge, 0, 20)); + expect(s.left).toBeCloseTo(450, 0); + expect(s.right).toBeCloseTo(1026, 0); + // Already the first hint of the funnel is off by less than a pixel. + expect(s.bottom - s.top).toBeCloseTo(60, 0); + }); + + it('funnels first: the edge nearest the badge narrows most, the far edge stays tall', () => { + const frames = genieFrame(banner, badge, 0.4, 20); + const near = frames[frames.length - 1].dest; + const far = frames[0].dest; + expect(near.height).toBeLessThan(far.height); + expect(far.height).toBeGreaterThan(40); + }); + + it('ends inside the badge', () => { + const s = span(genieFrame(banner, badge, 1, 20)); + expect(s.left).toBeCloseTo(128, 0); + expect(s.right).toBeCloseTo(198, 0); + expect(s.top).toBeCloseTo(827, 0); + expect(s.bottom).toBeCloseTo(847, 0); + }); + + it('keeps the strips in one piece — each edge where its neighbour ends', () => { + for (const p of [0.2, 0.5, 0.8]) { + const frames = genieFrame(banner, badge, p, 20); + // Travelling left, strip i (from the far, right edge) sits right of strip i+1. + for (let i = 0; i < frames.length - 1; i += 1) { + expect(frames[i + 1].dest.left + frames[i + 1].dest.width).toBeCloseTo(frames[i].dest.left, 3); + } + } + }); +}); diff --git a/packages/ui/src/lib/components/genie.ts b/packages/ui/src/lib/components/genie.ts new file mode 100644 index 0000000..91186da --- /dev/null +++ b/packages/ui/src/lib/components/genie.ts @@ -0,0 +1,98 @@ +/** + * The macOS "genie" minimise, for the update banner flying into the version badge: the + * banner narrows into a funnel towards the badge, then is drawn in through it — the + * side nearest the badge first. + * + * A CSS transform can only scale or skew a box evenly, and the genie bends it. So for + * the length of the animation the banner is drawn as strips — copies of it, each clipped + * to one slice across the direction of travel — and every strip gets its own transform + * each frame. Strips share their edges' positions, so the shape stays one piece. + */ + +export interface Rect { + left: number; + top: number; + width: number; + height: number; +} + +/** Where one strip goes: its slice of the source (along the travel axis, 0..1 from the + * far edge to the edge nearest the target) drawn at `dest` on screen. */ +export interface StripFrame { + from: number; + to: number; + dest: Rect; +} + +const clamp01 = (v: number): number => Math.min(1, Math.max(0, v)); +const lerp = (a: number, b: number, t: number): number => a + (b - a) * t; +const ease = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2); + +/** + * The strips at progress `p` (0 = the banner as it is, 1 = inside the target). Pure, so + * the shape can be tested without a DOM. The travel axis is the one the target is + * mostly away along; the funnel narrows across it. + */ +export function genieFrame(source: Rect, target: Rect, p: number, strips: number): StripFrame[] { + const dx = target.left + target.width / 2 - (source.left + source.width / 2); + const dy = target.top + target.height / 2 - (source.top + source.height / 2); + const alongX = Math.abs(dx) >= Math.abs(dy); + // Axis helpers: "main" is the direction of travel, "cross" the one the funnel narrows. + const main = (r: Rect) => (alongX ? { start: r.left, size: r.width } : { start: r.top, size: r.height }); + const cross = (r: Rect) => (alongX ? { start: r.top, size: r.height } : { start: r.left, size: r.width }); + const towardsStart = alongX ? dx < 0 : dy < 0; + + const sm = main(source); + const tm = main(target); + const sc = cross(source); + const tc = cross(target); + + // How far along an edge at `f` (0 = far edge, 1 = nearest the target) is: + // pinch — narrowing towards the target's width across — comes first and most at the + // near edge; the draw-in follows, near edge first. + const pinch = (f: number) => clamp01(ease(clamp01(p / 0.5)) * (0.25 + 0.75 * f)); + // A small lead for the near edge only: more stretches the banner along its way. + const drawIn = (f: number) => ease(clamp01(((p - 0.3) / 0.7) * 1.2 - (1 - f) * 0.2)); + + // An edge at `f`: where it sits along the travel axis, and the cross span there. + const edge = (f: number) => { + const b = drawIn(f); + const k = Math.max(pinch(f), b); + // Position along travel: from its place in the banner to its place in the target, + // keeping the order (far edge stays farthest). + const fromPos = towardsStart ? sm.start + (1 - f) * sm.size : sm.start + f * sm.size; + const toPos = towardsStart ? tm.start + (1 - f) * tm.size : tm.start + f * tm.size; + const pos = lerp(fromPos, toPos, b); + const start = lerp(sc.start, tc.start, k); + const size = lerp(sc.size, tc.size, k); + return { pos, start, size }; + }; + + const frames: StripFrame[] = []; + for (let i = 0; i < strips; i += 1) { + const f0 = i / strips; + const f1 = (i + 1) / strips; + const a = edge(f0); + const b = edge(f1); + const lo = Math.min(a.pos, b.pos); + const len = Math.max(0.01, Math.abs(b.pos - a.pos)); + // The strip spans the wider of its two edges across, centred between them. + const start = Math.min(a.start, b.start); + const size = Math.max(a.start + a.size, b.start + b.size) - start; + frames.push({ + from: f0, + to: f1, + dest: alongX ? { left: lo, top: start, width: len, height: size } : { left: start, top: lo, width: size, height: len } + }); + } + return frames; +} + +/** Which way the source is sliced for `genieFrame`: across x (columns) or y (rows), + * and whether f = 0 is its right/bottom side. */ +export function genieSlicing(source: Rect, target: Rect): { columns: boolean; reversed: boolean } { + const dx = target.left + target.width / 2 - (source.left + source.width / 2); + const dy = target.top + target.height / 2 - (source.top + source.height / 2); + const columns = Math.abs(dx) >= Math.abs(dy); + return { columns, reversed: columns ? dx < 0 : dy < 0 }; +} From e45cf14a44edc07762e89d88907b47753edf46c6 Mon Sep 17 00:00:00 2001 From: Sniphs <56806984+Sniphs98@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:00:13 +0200 Subject: [PATCH 5/5] feat(gui): a green pulsing dot on the update badge Co-Authored-By: Claude Opus 5.5 --- packages/ui/src/lib/components/UpdateBadge.svelte | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/lib/components/UpdateBadge.svelte b/packages/ui/src/lib/components/UpdateBadge.svelte index a4f67da..46d7ab3 100644 --- a/packages/ui/src/lib/components/UpdateBadge.svelte +++ b/packages/ui/src/lib/components/UpdateBadge.svelte @@ -65,8 +65,9 @@ {:else} - - + + + {/if} {label}