Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion apps/native/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,33 @@
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>nixmac</title>
<!--
Styles for the boot splash below. Same file <SplashScreen> imports, so the
two never drift; Vite bundles and fingerprints it like any other asset.
-->
<link rel="stylesheet" href="/src/components/widget/layout/splash.css" />
</head>
<body>
<div id="root"></div>
<div id="root">
<!--
Boot splash. The window is shown as soon as it is built, so without this
the user stares at an empty pane while the bundle loads and parses. It
is the same markup <SplashScreen> renders — icon.svg standing in for the
animated mascot, and a scanning bar in place of probe progress — so the
handover is seamless: React clears #root on its first commit, which
removes this, and the mark simply starts hopping.
-->
<div class="nixmac-splash" aria-busy="true">
<div class="nixmac-splash__mark"><img src="/icon.svg" alt="" /></div>
<div class="nixmac-splash__text">
<span class="nixmac-splash__name">nixmac</span>
<div class="nixmac-splash__track">
<div class="nixmac-splash__bar nixmac-splash__bar--indeterminate"></div>
</div>
<span class="nixmac-splash__stage">Starting up…</span>
</div>
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
6 changes: 4 additions & 2 deletions apps/native/src/components/nixmac-mascot/NixmacMascotCube.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
className={className ? `nixmac-cube-scene ${className}` : "nixmac-cube-scene"}
style={{ "--cube-size": `${size}px` } as CSSProperties}
style={{ "--cube-size": `${size}px`, ...style } as CSSProperties}
>
<div className="nixmac-cube">
<div className="nixmac-cube__face nixmac-cube__face--front">
Expand Down

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions apps/native/src/components/widget/layout/splash-screen.stories.tsx
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>
),
});
101 changes: 101 additions & 0 deletions apps/native/src/components/widget/layout/splash-screen.tsx
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()));

Copy link
Copy Markdown

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.html starts nixmac-splash-in after 400ms and fades for 500ms. When React replaces it after that threshold, this Math.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.


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>
);
}
135 changes: 135 additions & 0 deletions apps/native/src/components/widget/layout/splash.css
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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would consider just leaving all these nixmac- prefixes out, they add a bit of verbosity for probably not a lot of gain when we already have the splash part.

position: absolute;
inset: 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 inset shorthand, so this is ignored and the absolutely positioned splash has no full-window offsets. The same new stylesheet's only backdrop, background: color-mix(in oklch, ...) at line 31, is also unsupported there. During the pre-bundle interval this can leave the splash content-sized with no backdrop; its #fafafa text fallback then renders against the default white page. Add top/right/bottom/left and broadly supported background fallbacks, or raise the platform floor.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Stage-label fade animation is a no-op

.nixmac-splash__stage has no opacity declaration, so its underlying opacity is 1. The applied animation ends at opacity: 1 (line 108), and nixmac-splash-in has no explicit from keyframe; each keyed remount therefore animates 1→1 instead of fading in as the component comment claims. Add opacity: 0 to the stage rule or an explicit from { opacity: 0; } keyframe.

}

@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%;
}
}
Loading
Loading