From 26f4770c4dc144fbb05bb0a84e75b805218d26fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pedro=20Bol=C3=ADvar=20Puente?= Date: Sun, 2 Aug 2026 09:59:26 +0200 Subject: [PATCH 1/4] feat(widget): add a launch splash screen The window is shown as soon as it is built, so the seconds spent hydrating the ViewModel and running the launch probes read as an empty pane. SplashScreen fills it with the mascot, the wordmark, and a label naming the probe in flight. Deliberately the CSS-3D cube rather than : three.js must stay out of the main bundle, since it would add to the very startup cost this screen exists to cover. Its styling lives in a plain-CSS splash.css rather than Tailwind utilities so that the pre-React boot splash can share it verbatim. --- .../nixmac-mascot/NixmacMascotCube.tsx | 6 +- .../splash-screen.stories.tsx.snap | 291 ++++++++++++++++++ .../widget/layout/splash-screen.stories.tsx | 36 +++ .../widget/layout/splash-screen.tsx | 101 ++++++ .../src/components/widget/layout/splash.css | 135 ++++++++ 5 files changed, 567 insertions(+), 2 deletions(-) create mode 100644 apps/native/src/components/widget/layout/__snapshots__/splash-screen.stories.tsx.snap create mode 100644 apps/native/src/components/widget/layout/splash-screen.stories.tsx create mode 100644 apps/native/src/components/widget/layout/splash-screen.tsx create mode 100644 apps/native/src/components/widget/layout/splash.css diff --git a/apps/native/src/components/nixmac-mascot/NixmacMascotCube.tsx b/apps/native/src/components/nixmac-mascot/NixmacMascotCube.tsx index 29e7d457a..5dce30405 100644 --- a/apps/native/src/components/nixmac-mascot/NixmacMascotCube.tsx +++ b/apps/native/src/components/nixmac-mascot/NixmacMascotCube.tsx @@ -17,13 +17,15 @@ interface NixmacMascotCubeProps { /** Rendered cube edge in px. Default 160. */ size?: number; className?: string; + /** Extra wrapper styles — handy for overriding CSS vars like `--hop-period`. */ + style?: CSSProperties; } -export function NixmacMascotCube({ size = 160, className }: NixmacMascotCubeProps) { +export function NixmacMascotCube({ size = 160, className, style }: NixmacMascotCubeProps) { return (
diff --git a/apps/native/src/components/widget/layout/__snapshots__/splash-screen.stories.tsx.snap b/apps/native/src/components/widget/layout/__snapshots__/splash-screen.stories.tsx.snap new file mode 100644 index 000000000..a82fb4f8d --- /dev/null +++ b/apps/native/src/components/widget/layout/__snapshots__/splash-screen.stories.tsx.snap @@ -0,0 +1,291 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Default 1`] = ` +"
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
nixmac
Checking permissions…
" +`; + +exports[`Stages 1`] = ` +"
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
nixmac
Starting up…
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
nixmac
Loading configuration…
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
nixmac
Checking permissions…
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
nixmac
Checking Nix…
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
nixmac
Reading repository…
" +`; diff --git a/apps/native/src/components/widget/layout/splash-screen.stories.tsx b/apps/native/src/components/widget/layout/splash-screen.stories.tsx new file mode 100644 index 000000000..2a1086f28 --- /dev/null +++ b/apps/native/src/components/widget/layout/splash-screen.stories.tsx @@ -0,0 +1,36 @@ +// @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) +import preview from "#storybook/preview"; +import { SPLASH_STAGES, SplashScreen } from "./splash-screen"; + +const meta = preview.meta({ + title: "App/SplashScreen", + component: SplashScreen, + parameters: { layout: "fullscreen" }, + tags: ["autodocs"], + // Stories skip the 400ms hold — there is nothing to wait for here. + args: { stage: "permissions", appearDelayMs: 0 }, + argTypes: { + stage: { control: "inline-radio", options: [...SPLASH_STAGES] }, + }, +}); + +export default meta; + +/** What the window shows while the launch probes run. */ +export const Default = meta.story({}); + +/** Every stage the progress bar walks through, first to last. */ +export const Stages = meta.story({ + render: () => ( +
+ {SPLASH_STAGES.map((stage) => ( +
+ +
+ ))} +
+ ), +}); diff --git a/apps/native/src/components/widget/layout/splash-screen.tsx b/apps/native/src/components/widget/layout/splash-screen.tsx new file mode 100644 index 000000000..2a681e6b3 --- /dev/null +++ b/apps/native/src/components/widget/layout/splash-screen.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { NixmacMascotCube } from "@/components/nixmac-mascot/NixmacMascotCube"; +import type { CSSProperties } from "react"; +import { useState } from "react"; +import "./splash.css"; + +/** + * Launch splash. Fills the window while the ViewModel hydrates and the launch + * probes (permissions, Nix, git) run — the stretch that otherwise shows an + * empty pane for a second or two. + * + * The markup and classes match the boot splash in index.html, which covers the + * stretch before this component exists; both are styled by splash.css. Only two + * things change at the handover: the static mark becomes the animated cube, and + * the scanning bar becomes real probe progress. + * + * Deliberately the CSS-3D cube and not : three.js must stay out + * of the main bundle (it would add to the very startup cost this screen exists + * to cover). The cube is pure CSS and honours prefers-reduced-motion. + */ + +/** Launch probes, in the order `DarwinWidget` runs them. Drives the progress bar. */ +export const SPLASH_STAGES = ["starting", "state", "permissions", "nix", "repository"] as const; + +export type SplashStage = (typeof SPLASH_STAGES)[number]; + +const STAGE_LABEL: Record = { + starting: "Starting up", + state: "Loading configuration", + permissions: "Checking permissions", + nix: "Checking Nix", + repository: "Reading repository", +}; + +/** + * Boots that finish faster than this never show the splash: a sub-blink flash of + * mascot reads as a glitch, not as feedback. Kept in sync with the `--splash-delay` + * default in splash.css, which is what index.html's copy uses. + */ +const APPEAR_DELAY_MS = 400; + +/** Matches `.nixmac-splash__mark` once perspective scales the cube up. */ +const CUBE_SIZE_PX = 128; + +/** The mascot's idle cadence (8s) would hop maybe once per launch — hurry it up. */ +const SPLASH_HOP_PERIOD = "2.6s"; + +interface SplashScreenProps { + stage: SplashStage; + /** Delay before fading in. 0 renders immediately (stories, tests). */ + appearDelayMs?: number; +} + +export function SplashScreen({ stage, appearDelayMs = APPEAR_DELAY_MS }: SplashScreenProps) { + // Measured from page load, not from mount: if index.html's copy already faded + // in, this one must appear at once rather than restart the countdown. Frozen + // on first render — re-resolving `animation-delay` restarts the fade, and this + // component re-renders on every stage change. + const [delayMs] = useState(() => Math.max(0, appearDelayMs - performance.now())); + + const stageIndex = Math.max(SPLASH_STAGES.indexOf(stage), 0); + const progress = ((stageIndex + 1) / SPLASH_STAGES.length) * 100; + + return ( +
+
+ +
+ +
+ nixmac + +
+
+
+ + {/* Keyed on the stage so each label fades in as its probe starts. */} + + {STAGE_LABEL[stage]}… + +
+
+ ); +} diff --git a/apps/native/src/components/widget/layout/splash.css b/apps/native/src/components/widget/layout/splash.css new file mode 100644 index 000000000..92283ee65 --- /dev/null +++ b/apps/native/src/components/widget/layout/splash.css @@ -0,0 +1,135 @@ +/* + Launch splash — the single source of truth for how it looks. + + Two things render this markup, so the styling cannot live in either of them: + + 1. index.html, before any JavaScript runs (it links this file directly). + 2. , once React is up (it imports this file). + + Consequences to respect when editing: + + - Plain CSS only. This loads before the bundle, so no Tailwind utilities, no + @apply, no build-time theme resolution. + - Design tokens are read with a literal fallback — `var(--foreground, #fafafa)` + — because index.css has not necessarily loaded yet. The fallbacks are the + dark-theme values; the tokens take over the moment that CSS lands. + - `position: absolute` (not fixed): with no positioned ancestor it fills the + window, and inside a positioned box it fills the box, which is what the + Storybook stories need. +*/ + +.nixmac-splash { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 28px; + font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + user-select: none; + background: color-mix(in oklch, var(--background, oklch(0.1445 0 0)) 60%, transparent); + + /* Boots that finish before this never flash a splash. React overrides the + variable with the time *remaining* since page load, so the fade happens + once at 400 ms whichever of the two is on screen at the time. */ + --splash-delay: 400ms; + opacity: 0; + animation: nixmac-splash-in 500ms ease-out var(--splash-delay) forwards; +} + +/* Sized so the static mark and the perspective-scaled 128px cube + (NixmacMascotCube in ) land at the same visual size — the swap + from one to the other should not move anything. */ +.nixmac-splash__mark { + display: grid; + place-items: center; + width: 148px; + height: 148px; +} +.nixmac-splash__mark img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.nixmac-splash__text { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +/* Explicit line-heights: index.html's copy renders before index.css sets a base + one, so leaving them to inherit shifts the layout a few px at the handover. */ +.nixmac-splash__name { + font-size: 16px; + line-height: 1.5; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--foreground, #fafafa); +} + +.nixmac-splash__track { + width: 160px; + height: 2px; + overflow: hidden; + border-radius: 9999px; + background: var(--border, #27272a); +} + +.nixmac-splash__bar { + height: 100%; + border-radius: 9999px; + background: var(--muted-foreground, #a1a1aa); + transition: width 500ms ease-out; +} + +/* Before React, there is no probe to report — the bar scans instead of filling. */ +.nixmac-splash__bar--indeterminate { + width: 40%; + animation: nixmac-splash-scan 1.4s ease-in-out infinite; +} + +/* keys this element on the stage, so each new label remounts and + fades in; index.html's copy just plays it once. */ +.nixmac-splash__stage { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + line-height: 1.5; + text-transform: uppercase; + letter-spacing: -0.01em; + color: var(--muted-foreground, #a1a1aa); + animation: nixmac-splash-in 300ms ease-out both; +} + +@keyframes nixmac-splash-in { + to { + opacity: 1; + } +} + +@keyframes nixmac-splash-scan { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(250%); + } +} + +@media (prefers-reduced-motion: reduce) { + .nixmac-splash { + animation-duration: 1ms; + } + .nixmac-splash__stage { + animation-duration: 1ms; + } + .nixmac-splash__bar { + transition: none; + } + .nixmac-splash__bar--indeterminate { + animation: none; + width: 100%; + } +} From d8af41b12abd4708879ad9f1a6e0efa5e7a370ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pedro=20Bol=C3=ADvar=20Puente?= Date: Sun, 2 Aug 2026 09:59:32 +0200 Subject: [PATCH 2/4] feat(widget): show the splash while the ViewModel hydrates Replaces the neutral empty container the widget held before hydration, and reports which launch probe is running as the mount effect walks them. --- apps/native/src/components/widget/widget.tsx | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/native/src/components/widget/widget.tsx b/apps/native/src/components/widget/widget.tsx index e18d1d887..e09bd7e82 100644 --- a/apps/native/src/components/widget/widget.tsx +++ b/apps/native/src/components/widget/widget.tsx @@ -34,6 +34,7 @@ import { usePanicHandler } from "@/hooks/use-panic-handler"; import { usePermissions } from "@/hooks/use-permissions"; import { useTrayEvents } from "@/hooks/use-tray-events"; import { markBootRenderStage, markBootStage } from "@/lib/boot-diagnostics"; +import { SplashScreen, type SplashStage } from "@/components/widget/layout/splash-screen"; import { useEvolveMascot } from "@/hooks/use-evolve-mascot"; import { useUiState, useViewModel } from "@nixmac/state"; import { useCurrentStep } from "@/hooks/use-current-step"; @@ -41,7 +42,7 @@ import { UpdateBanner } from "@/components/widget/layout/update-banner"; import { markViewModelHydrated, startViewModelSync } from "@/viewmodel"; import { setupErrorTestHelpers } from "@/utils/error-test-helpers"; import { setupWidgetTestHelpers } from "@/utils/widget-test-helpers"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { nav, useIsOverlayActive } from "@/router"; /** @@ -125,16 +126,24 @@ export function DarwinWidget() { return () => window.removeEventListener("keydown", handleKeyDown); }, [isOverlayActive]); + // Which launch probe is running, so the splash can say so instead of showing + // a blank pane. Only read while `hydrated` is false. + const [splashStage, setSplashStage] = useState("starting"); + // Load initial data once on mount, then start watching for changes useEffect(() => { let cancelled = false; let stopViewModelSync: (() => void) | null = null; + const enterStage = (stage: SplashStage) => { + if (!cancelled) setSplashStage(stage); + }; (async () => { try { // Hydrate every mirrored slice (preferences/hosts, permissions, // prompt history, evolve, git, change map) before anything that // depends on config being available. + enterStage("state"); const stop = await startViewModelSync(); if (cancelled) { stop(); @@ -145,8 +154,11 @@ export function DarwinWidget() { // Explicit probes: permissions (writes the backend cell, which // round-trips through `permissions_changed`), Nix availability, and // the cached git status snapshot. + enterStage("permissions"); await checkPermissions(); + enterStage("nix"); await checkNix(); + enterStage("repository"); await getInitialStatus(); } catch (e: unknown) { uiActions.setError((e as Error)?.message || String(e)); @@ -181,8 +193,8 @@ export function DarwinWidget() { const repair = useLaunchRepair(); // Suppress the boot flash: before the ViewModel hydrates, every gate input // is a default (null preferences/nixInstall), so both OnboardingFlow and the - // main widget would render against stale state for a frame. Hold a neutral - // container until hydration completes, then render the correct path directly. + // main widget would render against stale state for a frame. Hold the splash + // until hydration completes, then render the correct path directly. const hydrated = useViewModel((s) => s.hydrated); // permissions/nix-setup/setup are owned by OnboardingFlow. Reaching one of @@ -204,7 +216,7 @@ export function DarwinWidget() { }, [step, hydrated, showOnboarding, isBootstrapping]); if (!hydrated) { - return
; + return ; } // Routing mechanism From e1a4c1ca344978f8e9e3d0cea07b3381be11a253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pedro=20Bol=C3=ADvar=20Puente?= Date: Sun, 2 Aug 2026 09:59:39 +0200 Subject: [PATCH 3/4] feat(widget): cover the pre-React boot with the same splash SplashScreen can only paint once the bundle has loaded and parsed, which is itself part of the wait. index.html now carries the same markup inside #root, styled by the same splash.css and gated on the same 400ms delay, with icon.svg standing in for the animated mascot. React clears #root on its first commit, so the handover costs nothing: the mark simply starts hopping. --- apps/native/index.html | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/native/index.html b/apps/native/index.html index a6d8053dc..a30587d99 100644 --- a/apps/native/index.html +++ b/apps/native/index.html @@ -5,9 +5,33 @@ nixmac + + -
+
+ +
+
+
+ nixmac +
+
+
+ Starting up… +
+
+
From a551815f5eb6efce97cb4a109773153ff912841a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pedro=20Bol=C3=ADvar=20Puente?= Date: Fri, 4 Sep 2026 22:20:15 +0200 Subject: [PATCH 4/4] fix(widget): resume the splash fade instead of replaying it at handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on the splash, all in the same fade: - The elapsed delay was clamped at 0, so a React handoff after the 400ms mark replaced a partly- or fully-visible splash with a transparent one and replayed the whole 500ms fade. Letting the value go negative makes CSS start the animation at the progress it has already reached, which is what the clamp was trying to express. `appearDelayMs: 0` (stories, tests) skips the measurement so the rendered value stays deterministic. - `nixmac-splash-in` had no `from` keyframe, so on `.nixmac-splash__stage` — which never sets an opacity of its own — it interpolated 1 to 1 and faded nothing. The root rule sets `opacity: 0` explicitly, so spelling the `from` out changes only the stage label. - tauri.conf.json still declares macOS 10.13, whose WKWebView has neither `inset` nor `color-mix`. Tailwind v4 and the oklch tokens gave that floor up long ago, but splash.css is the one stylesheet that renders before the bundle, so give it offset longhands and a flat background fallback rather than have it collapse to a transparent, content-sized box on the platform we claim to support. --- .../widget/layout/splash-screen.tsx | 14 +++++++++---- .../src/components/widget/layout/splash.css | 21 +++++++++++++++++-- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/native/src/components/widget/layout/splash-screen.tsx b/apps/native/src/components/widget/layout/splash-screen.tsx index 2a681e6b3..a38b538d3 100644 --- a/apps/native/src/components/widget/layout/splash-screen.tsx +++ b/apps/native/src/components/widget/layout/splash-screen.tsx @@ -54,10 +54,16 @@ interface SplashScreenProps { export function SplashScreen({ stage, appearDelayMs = APPEAR_DELAY_MS }: SplashScreenProps) { // Measured from page load, not from mount: if index.html's copy already faded - // in, this one must appear at once rather than restart the countdown. Frozen - // on first render — re-resolving `animation-delay` restarts the fade, and this - // component re-renders on every stage change. - const [delayMs] = useState(() => Math.max(0, appearDelayMs - performance.now())); + // in, this one must appear at once rather than restart the countdown. The + // result is deliberately allowed to go negative — a negative `animation-delay` + // starts the fade at the progress it would already have reached, so a handoff + // mid-fade continues it and a handoff after it finished lands straight on + // opacity 1 (clamping to 0 would blank the splash and replay the fade). A + // delay of 0 (stories, tests) opts out of the measurement entirely, so the + // rendered value stays deterministic. Frozen on first render — re-resolving + // `animation-delay` restarts the fade, and this component re-renders on every + // stage change. + const [delayMs] = useState(() => (appearDelayMs > 0 ? appearDelayMs - performance.now() : 0)); const stageIndex = Math.max(SPLASH_STAGES.indexOf(stage), 0); const progress = ((stageIndex + 1) / SPLASH_STAGES.length) * 100; diff --git a/apps/native/src/components/widget/layout/splash.css b/apps/native/src/components/widget/layout/splash.css index 92283ee65..9894d7979 100644 --- a/apps/native/src/components/widget/layout/splash.css +++ b/apps/native/src/components/widget/layout/splash.css @@ -20,6 +20,15 @@ .nixmac-splash { position: absolute; + /* Longhands as well as the shorthand: tauri.conf.json still declares macOS + 10.13, whose WKWebView has neither `inset` nor `color-mix`. The rest of the + app (Tailwind v4, oklch tokens) gave that floor up long ago, but this one + stylesheet renders before the bundle, so it degrades instead of collapsing + to a content-sized, transparent box. */ + top: 0; + right: 0; + bottom: 0; + left: 0; inset: 0; display: flex; flex-direction: column; @@ -28,11 +37,14 @@ gap: 28px; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; user-select: none; + background: #0b0b0b; background: color-mix(in oklch, var(--background, oklch(0.1445 0 0)) 60%, transparent); /* Boots that finish before this never flash a splash. React overrides the - variable with the time *remaining* since page load, so the fade happens - once at 400 ms whichever of the two is on screen at the time. */ + variable with the time remaining since page load — negative once the delay + has already elapsed, which resumes the fade at its current progress rather + than replaying it — so the fade happens once at 400 ms whichever of the two + is on screen at the time. */ --splash-delay: 400ms; opacity: 0; animation: nixmac-splash-in 500ms ease-out var(--splash-delay) forwards; @@ -103,7 +115,12 @@ animation: nixmac-splash-in 300ms ease-out both; } +/* Explicit `from`: `.nixmac-splash__stage` has no opacity of its own, so without + it the keyframes would interpolate from the element's own 1 and fade nothing. */ @keyframes nixmac-splash-in { + from { + opacity: 0; + } to { opacity: 1; }