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… +
+
+
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%; + } +} 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