-
Notifications
You must be signed in to change notification settings - Fork 2
Add "splash screen" to hide the slow loading of the app #631
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: () => ( | ||
| <div className="grid h-full grid-cols-2 gap-4"> | ||
| {SPLASH_STAGES.map((stage) => ( | ||
| <div | ||
| key={stage} | ||
| className="relative h-72 overflow-hidden rounded-lg border border-border" | ||
| > | ||
| <SplashScreen stage={stage} appearDelayMs={0} /> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| ), | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <NixmacMascot3D>: 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<SplashStage, string> = { | ||
| 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 ( | ||
| <div | ||
| className="nixmac-splash" | ||
| style={{ "--splash-delay": `${delayMs}ms` } as CSSProperties} | ||
| data-testid="splash-screen" | ||
| data-splash-stage={stage} | ||
| aria-busy="true" | ||
| > | ||
| <div className="nixmac-splash__mark"> | ||
| <NixmacMascotCube | ||
| size={CUBE_SIZE_PX} | ||
| style={{ "--hop-period": SPLASH_HOP_PERIOD } as CSSProperties} | ||
| /> | ||
| </div> | ||
|
|
||
| <div className="nixmac-splash__text"> | ||
| <span className="nixmac-splash__name">nixmac</span> | ||
|
|
||
| <div | ||
| className="nixmac-splash__track" | ||
| role="progressbar" | ||
| aria-valuemin={0} | ||
| aria-valuemax={100} | ||
| aria-valuenow={Math.round(progress)} | ||
| aria-label="Starting nixmac" | ||
| > | ||
| <div className="nixmac-splash__bar" style={{ width: `${progress}%` }} /> | ||
| </div> | ||
|
|
||
| {/* Keyed on the stage so each label fades in as its probe starts. */} | ||
| <span key={stage} className="nixmac-splash__stage" role="status"> | ||
| {STAGE_LABEL[stage]}… | ||
| </span> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. <SplashScreen>, 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 { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would consider just leaving all these |
||
| position: absolute; | ||
| inset: 0; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [major] Splash layout breaks on the declared macOS minimum The app declares macOS 10.13 as its minimum, whose Safari-era WKWebView predates the |
||
| 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 <SplashScreen>) 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; | ||
| } | ||
|
|
||
| /* <SplashScreen> 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [minor] Stage-label fade animation is a no-op
|
||
| } | ||
|
|
||
| @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%; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[major] Clamping elapsed delay resets the splash at handoff
The static copy in
index.htmlstartsnixmac-splash-inafter 400ms and fades for 500ms. When React replaces it after that threshold, thisMath.max(0, ...)clamp forces the replacement animation to start at opacity 0. For example, a handoff at 600ms replaces a partially visible splash with a transparent element and replays the full fade; after 900ms it blanks an already opaque splash for another 500ms. Preserve the negative delay (appearDelayMs - performance.now()) so CSS starts the replacement at the elapsed animation progress.