@@ -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();
+}