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", 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/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; 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..46d7ab3 --- /dev/null +++ b/packages/ui/src/lib/components/UpdateBadge.svelte @@ -0,0 +1,75 @@ + + +{#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..c6f5221 --- /dev/null +++ b/packages/ui/src/lib/components/flyToBadge.ts @@ -0,0 +1,99 @@ +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: 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 = 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; + 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, + // `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 }; +} 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(); +}