diff --git a/scripts/browser.mjs b/scripts/browser.mjs new file mode 100644 index 0000000..8f87bc6 --- /dev/null +++ b/scripts/browser.mjs @@ -0,0 +1,64 @@ +/** + * A headless Chromium driven over the DevTools protocol, shared by the scripts that look at pages: + * shot.mjs (one picture) and record.mjs (a film, frame by frame). + */ +import { spawn } from "node:child_process" +import { existsSync, readdirSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" + +/** The browser to run: $CHROME, Playwright's headless shell, or an installed Chrome. */ +export function findBrowser() { + if (process.env.CHROME) return process.env.CHROME + for (const dir of [join(homedir(), "Library/Caches/ms-playwright"), join(homedir(), ".cache/ms-playwright")]) { + if (!existsSync(dir)) continue + for (const name of readdirSync(dir).filter((entry) => entry.startsWith("chromium_headless_shell")).sort().reverse()) { + for (const sub of readdirSync(join(dir, name))) { + const bin = join(dir, name, sub, "chrome-headless-shell") + if (existsSync(bin)) return bin + } + } + } + for (const bin of ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/usr/bin/google-chrome", "/usr/bin/chromium"]) { + if (existsSync(bin)) return bin + } + console.error("No browser found. Set CHROME, or run: npx playwright install chromium-headless-shell") + process.exit(2) +} + +export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** Start the browser and connect to its page. `send(method, params)` speaks the protocol; `close()` ends it. */ +export async function openBrowser() { + const port = 9300 + Math.floor(Math.random() * 500) + const flags = ["--headless=new", "--no-sandbox", "--hide-scrollbars", `--remote-debugging-port=${port}`] + if (process.env.PROXY) flags.push(`--proxy-server=${process.env.PROXY}`) + const chrome = spawn(findBrowser(), [...flags, "about:blank"], { stdio: "ignore" }) + let target + for (let i = 0; i < 80 && !target; i++) { + await sleep(100) + try { + target = (await (await fetch(`http://127.0.0.1:${port}/json`)).json()).find((entry) => entry.type === "page") + } catch { + // not listening yet + } + } + if (!target) { + chrome.kill() + throw new Error("the browser did not start") + } + const socket = new WebSocket(target.webSocketDebuggerUrl) + await new Promise((resolve) => (socket.onopen = resolve)) + let id = 0 + const pending = new Map() + socket.onmessage = (event) => { + const message = JSON.parse(event.data) + if (message.id && pending.has(message.id)) pending.get(message.id)(message.result) + } + const send = (method, params = {}) => + new Promise((resolve) => { + pending.set(++id, resolve) + socket.send(JSON.stringify({ id, method, params })) + }) + return { send, close: () => chrome.kill() } +} diff --git a/scripts/record.mjs b/scripts/record.mjs new file mode 100644 index 0000000..e663cc9 --- /dev/null +++ b/scripts/record.mjs @@ -0,0 +1,86 @@ +/** + * Record the promotional film (/dev/promo) frame by frame into a video. + * + * node scripts/record.mjs [url] [fps] + * + * The page draws every frame from one clock. This sets the clock to each frame's time, lets the + * page draw, and photographs it, so the film comes out the same every time and as smooth as its + * frame rate, however long a frame takes to draw. The browser's own clocks are stood in for by the + * same time (requestAnimationFrame, performance.now, Date.now, CSS animations), so parts that animate + * themselves, like the status glyphs, keep time with the rest. + * + * Needs ffmpeg: $FFMPEG, or one on the PATH. url defaults to the dev server's /dev/promo, fps to 30. + * + * Environment: + * FFMPEG the ffmpeg to run + * SCALE device pixels per CSS pixel (default 1: a 1920 by 1080 film) + * FROM, TO record only part of the film, in ms + */ +import { spawn } from "node:child_process" + +import { openBrowser, sleep } from "./browser.mjs" + +const [out, url = "http://localhost:5191/dev/promo?capture", fps = "30"] = process.argv.slice(2) +if (!out) { + console.error("usage: node scripts/record.mjs [url] [fps]") + process.exit(2) +} + +// Stand-ins for the browser's clocks, put in place before the page's own scripts run. +const CLOCK = `(() => { + let now = 0 + const base = Date.now() + const queue = new Map() + let next = 0 + window.requestAnimationFrame = (callback) => (queue.set(++next, callback), next) + window.cancelAnimationFrame = (handle) => queue.delete(handle) + performance.now = () => now + Date.now = () => base + now + window.__frame = (t) => { + now = t + const callbacks = [...queue.values()] + queue.clear() + for (const callback of callbacks) { + try { callback(t) } catch (error) { console.error(error) } + } + for (const animation of document.getAnimations()) { + try { animation.pause(); animation.currentTime = t } catch {} + } + } +})()` + +const { send, close } = await openBrowser() +const ffmpeg = spawn( + process.env.FFMPEG ?? "ffmpeg", + ["-y", "-loglevel", "error", "-f", "image2pipe", "-framerate", fps, "-c:v", "mjpeg", "-i", "-", "-c:v", "libx264", "-preset", "slow", "-crf", "16", "-pix_fmt", "yuv420p", "-movflags", "+faststart", out], + { stdio: ["pipe", "inherit", "inherit"] } +) + +try { + await send("Emulation.setDeviceMetricsOverride", { width: 1920, height: 1080, deviceScaleFactor: +(process.env.SCALE ?? 1), mobile: false }) + await send("Page.enable") + await send("Page.addScriptToEvaluateOnNewDocument", { source: CLOCK }) + await send("Page.navigate", { url }) + await sleep(3500) // the fonts, and the first draw + + const evaluate = async (expression) => (await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }))?.result?.value + const duration = await evaluate("window.__promoDuration") + if (!duration) throw new Error("the page has no film on it: is the dev server running, and the url /dev/promo?capture?") + const from = +(process.env.FROM ?? 0) + const to = Math.min(+(process.env.TO ?? duration), duration) + const frames = Math.round(((to - from) / 1000) * +fps) + + for (let frame = 0; frame < frames; frame++) { + const t = from + (frame * 1000) / +fps + // twice: what the clock sets off (a glyph's next frame) is drawn on the second pass + await evaluate(`(async () => { window.__seek(${t}); window.__frame(${t}); await new Promise((r) => setTimeout(r, 0)); window.__frame(${t}); await new Promise((r) => setTimeout(r, 16)); return 1 })()`) + const shot = await send("Page.captureScreenshot", { format: "jpeg", quality: 95 }) + if (!ffmpeg.stdin.write(Buffer.from(shot.data, "base64"))) await new Promise((resolve) => ffmpeg.stdin.once("drain", resolve)) + if (frame % +fps === 0) process.stdout.write(`\r${Math.round(t / 1000)}s / ${Math.round(to / 1000)}s`) + } + process.stdout.write("\n") +} finally { + ffmpeg.stdin.end() + await new Promise((resolve) => ffmpeg.on("close", resolve)) + close() +} diff --git a/scripts/shot.mjs b/scripts/shot.mjs index 1dea893..59cfd61 100644 --- a/scripts/shot.mjs +++ b/scripts/shot.mjs @@ -19,10 +19,9 @@ * There are no unit tests here: what this repository makes is how things look. This is the tool * for looking, and for saying in a pull request what was looked at. */ -import { spawn } from "node:child_process" -import { existsSync, readdirSync, writeFileSync } from "node:fs" -import { homedir } from "node:os" -import { join } from "node:path" +import { writeFileSync } from "node:fs" + +import { openBrowser, sleep } from "./browser.mjs" const [out, url, width, height, scale = "2", wait = "2500"] = process.argv.slice(2) if (!out || !url || !width || !height) { @@ -30,56 +29,8 @@ if (!out || !url || !width || !height) { process.exit(2) } -function browser() { - if (process.env.CHROME) return process.env.CHROME - const cache = join(homedir(), "Library/Caches/ms-playwright") - const linux = join(homedir(), ".cache/ms-playwright") - for (const dir of [cache, linux]) { - if (!existsSync(dir)) continue - for (const name of readdirSync(dir).filter((entry) => entry.startsWith("chromium_headless_shell")).sort().reverse()) { - for (const sub of readdirSync(join(dir, name))) { - const bin = join(dir, name, sub, "chrome-headless-shell") - if (existsSync(bin)) return bin - } - } - } - for (const bin of ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/usr/bin/google-chrome", "/usr/bin/chromium"]) { - if (existsSync(bin)) return bin - } - console.error("No browser found. Set CHROME, or run: npx playwright install chromium-headless-shell") - process.exit(2) -} - -const port = 9300 + Math.floor(Math.random() * 500) -const flags = ["--headless=new", "--no-sandbox", "--hide-scrollbars", `--remote-debugging-port=${port}`] -if (process.env.PROXY) flags.push(`--proxy-server=${process.env.PROXY}`) -const chrome = spawn(browser(), [...flags, "about:blank"], { stdio: "ignore" }) -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) - +const { send, close } = await openBrowser() try { - let target - for (let i = 0; i < 80 && !target; i++) { - await sleep(100) - try { - target = (await (await fetch(`http://127.0.0.1:${port}/json`)).json()).find((entry) => entry.type === "page") - } catch { - // not listening yet - } - } - if (!target) throw new Error("the browser did not start") - const socket = new WebSocket(target.webSocketDebuggerUrl) - await new Promise((resolve) => (socket.onopen = resolve)) - let id = 0 - const pending = new Map() - socket.onmessage = (event) => { - const message = JSON.parse(event.data) - if (message.id && pending.has(message.id)) pending.get(message.id)(message.result) - } - const send = (method, params = {}) => - new Promise((resolve) => { - pending.set(++id, resolve) - socket.send(JSON.stringify({ id, method, params })) - }) const run = async (expression) => { const result = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }) console.log(JSON.stringify(result?.result?.value ?? result?.exceptionDetails?.exception?.description ?? null, null, 1)) @@ -99,5 +50,5 @@ try { const shot = await send("Page.captureScreenshot", webp ? { format: "webp", quality: +(process.env.QUALITY ?? 86) } : { format: "png" }) writeFileSync(out, Buffer.from(shot.data, "base64")) } finally { - chrome.kill() + close() } diff --git a/src/App.tsx b/src/App.tsx index 9be1386..c1c1dec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { Toaster as ToastToaster } from "@/components/ui/toast" import { TooltipProvider } from "@/components/ui/tooltip" import { Home } from "@/pages/home" import { DevBanner } from "@/pages/dev-banner" +import { DevPromo } from "@/pages/dev-promo" import { DevExamples } from "@/pages/dev-examples" import { ComponentsIndex, DocPage, DocsLayout } from "@/site/docs" import { SiteFooter } from "@/site/site-footer" @@ -30,6 +31,8 @@ export function App() { const { pathname } = useLocation() // The README's banner is a picture of the material and nothing else: no header, no footer behind it. if (import.meta.env.DEV && pathname === "/dev/banner") return + // The promotional film, played by the material itself (and recorded by scripts/record.mjs). + if (import.meta.env.DEV && pathname === "/dev/promo") return // A block by itself, in a window of its own: no header, no footer round it. if (pathname.startsWith("/view/")) { return ( diff --git a/src/pages/dev-banner.tsx b/src/pages/dev-banner.tsx index 3a9f41a..91ef0bf 100644 --- a/src/pages/dev-banner.tsx +++ b/src/pages/dev-banner.tsx @@ -12,7 +12,7 @@ import { SolidGlassMark } from "@/site/logo" * a thin dark edge underneath and its shadow on the page. No gradient; like a pane, a letter is * finished at its edges. */ -function GlassWordmark({ first, second }: { first: string; second: string }) { +export function GlassWordmark({ first, second, scale = 1 }: { first: string; second: string; scale?: number }) { const id = React.useId() // Every layer is the same line of type. Only the face is told the two words apart. const letters = (props: React.SVGProps, face?: [string, string]) => ( @@ -22,7 +22,7 @@ function GlassWordmark({ first, second }: { first: string; second: string }) { ) return ( - + {/* What is left of the letters once the same letters, moved down a little, are taken out: their top edges. */} @@ -55,7 +55,7 @@ function GlassWordmark({ first, second }: { first: string; second: string }) { // here, that tint alone would turn clear and take the light's colour, which is liquid glass's look. // So the page a pane lies on is put back under it: the page's colour, mostly opaque, beneath the // tint. The light then shows round a pane and along its edges, not through its face. -const over = (tint: string): React.CSSProperties => ({ +export const over = (tint: string): React.CSSProperties => ({ background: `linear-gradient(${tint}, ${tint}), color-mix(in oklch, var(--background) 90%, transparent)`, }) diff --git a/src/pages/dev-promo.tsx b/src/pages/dev-promo.tsx new file mode 100644 index 0000000..45cc45f --- /dev/null +++ b/src/pages/dev-promo.tsx @@ -0,0 +1,453 @@ +import * as React from "react" +import { flushSync } from "react-dom" +import { + CalculatorIcon, + CalendarIcon, + CheckIcon, + CreditCardIcon, + PlusIcon, + SettingsIcon, + SmileIcon, + UserIcon, +} from "lucide-react" + +import { cn } from "@/lib/utils" +import { AskCard } from "@/components/ui/ask-card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Composer } from "@/components/ui/composer" +import { Progress } from "@/components/ui/progress" +import { Slider } from "@/components/ui/slider" +import { StatusGlyph } from "@/components/ui/status-glyph" +import { Switch } from "@/components/ui/switch" +import { Tag } from "@/components/ui/tag" +import { StepList, type Step } from "@/components/ui/tool-steps" +import { DEFAULTS, overrides } from "@/create/options" +import { GlassWordmark, over } from "@/pages/dev-banner" +import { SolidGlassMark } from "@/site/logo" + +/* + * Development only (/dev/promo): a short film of the material, played by the material itself. Every + * frame is a function of one clock, `t`, in milliseconds, so the same frame can be drawn again: + * `scripts/record.mjs` steps the clock and photographs each frame. Left alone the page plays in a + * loop, scaled to the window; `?t=` holds one moment. + */ + +const W = 1920 +const H = 1080 +export const DURATION = 31_500 + +const clamp = (x: number) => Math.min(1, Math.max(0, x)) +const easeOut = (x: number) => 1 - Math.pow(1 - x, 3) +const easeInOut = (x: number) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2) +/** How far from `a` to `b` the clock is, eased: 0 before, 1 after. */ +const at = (t: number, a: number, b: number, ease = easeOut) => ease(clamp((t - a) / (b - a))) +/** On from `a` to `b`: fading in over the first `fadeIn` ms and out over the last `fadeOut`. */ +const life = (t: number, a: number, b: number, fadeIn = 600, fadeOut = 500) => + Math.min(at(t, a, a + fadeIn), 1 - at(t, b - fadeOut, b, easeInOut)) +const rise = (t: number, a: number, b: number, by = 24): React.CSSProperties => ({ + opacity: at(t, a, b), + transform: `translateY(${(1 - at(t, a, b)) * by}px)`, +}) + +// The scenes, end to end. +const INTRO = [0, 3_800] as const +const PANES = [3_800, 8_600] as const +const EDGES = [8_600, 12_400] as const +const STATE = [12_400, 16_200] as const +const AGENT = [16_200, 23_000] as const +const TUNE = [23_000, 27_400] as const +const LIGHT = [25_800, 27_400] as const +const END = [27_400, DURATION] as const + +function Caption({ t, from, to, eyebrow, title, sub }: { t: number; from: number; to: number; eyebrow?: string; title: string; sub?: string }) { + const o = life(t, from, to, 700, 450) + return ( +
+ {eyebrow &&

{eyebrow}

} +

{title}

+ {sub &&

{sub}

} +
+ ) +} + +/** Light behind the panes, drifting, so they have something to be solid against. */ +function Lights({ t }: { t: number }) { + const s = t / 1000 + const sweep = at(t, PANES[0] + 200, PANES[1], easeInOut) + return ( + <> + + + + + ) +} + +function Intro({ t }: { t: number }) { + const [a, b] = INTRO + const o = life(t, a, b, 1, 650) + return ( +
+
+ +
+
+ +
+

+ Designed for AI applications +

+
+ ) +} + +function Panes({ t }: { t: number }) { + const [a, b] = PANES + const o = life(t, a, b, 1, 600) + const enter = (index: number) => rise(t, a + 450 + index * 260, a + 1250 + index * 260, 70) + return ( +
+ +
+
+ Panel + Plant 2 · Line 3 + 1,472 + units per hour +
+ {[52, 78, 64, 88, 70, 38, 60].map((height, index) => ( + + ))} +
+
+
+ Card +
+ + Inspecting weld seams +
+ {["Read the sensor batch", "Compared with last week", "Flagged bearing 4"].map((line, index) => ( +
+ + {line} +
+ ))} +
+ +
+
+
+ Popover +
+ + Anomaly on bearing 4 + +12 °C +
+ Above baseline for six minutes. +
+ + +
+
+
+
+ ) +} + +function Edges({ t }: { t: number }) { + const [a, b] = EDGES + const o = life(t, a, b, 600, 600) + const zoom = at(t, a + 1100, a + 2700, easeInOut) + const glint = at(t, a + 500, a + 2300, easeInOut) + return ( +
+ +
+
+ {/* the glint, running along the top edge */} + +
+ +
+ Line 3 · Weld seams + Inspected 2,048 frames in 14 seconds +
+ Live +
+
+ {[44, 62, 58, 80, 72, 90, 66, 52, 74, 86, 60, 70].map((height, index) => ( + + ))} +
+
+
+
+ ) +} + +const MENU = [ + { icon: CalendarIcon, label: "Calendar" }, + { icon: SmileIcon, label: "Search emoji" }, + { icon: CalculatorIcon, label: "Calculator" }, + { icon: UserIcon, label: "Profile" }, + { icon: CreditCardIcon, label: "Billing" }, + { icon: SettingsIcon, label: "Settings" }, +] +const TABS = ["Docs", "Components", "Blocks", "Material", "Create"] +const TAB = 96 + +/** Where the one state is at this moment: it moves from mark to mark, easing between them. */ +function along(t: number, marks: [number, number][]) { + let value = marks[0][1] + for (let i = 1; i < marks.length; i++) { + const [when, to] = marks[i] + value = value + (to - value) * at(t, when, when + 380, easeInOut) + } + return value +} + +function OneState({ t }: { t: number }) { + const [a, b] = STATE + const o = life(t, a, b, 600, 500) + const row = along(t, [[0, 0], [a + 900, 2], [a + 1600, 4], [a + 2300, 1], [a + 3000, 3]]) + const tab = along(t, [[0, 0], [a + 1150, 2], [a + 1850, 4], [a + 2550, 1], [a + 3150, 3]]) + return ( +
+ + {/* drawn at the size it has on a page, and the whole enlarged */} +
+
+
+ + {MENU.map(({ icon: Icon, label }, index) => ( +
+ + {label} + ⌘{label[0]} +
+ ))} + {/* the pointer, on the row it is over */} + + + +
+
+
+ + {TABS.map((name, index) => ( + + {name} + + ))} +
+
+ {["Day", "Week", "Month"].map((name, index) => ( + + ))} + +
+

+ No colour for hover, another for selected, a third for open. One pane, wherever the attention is. +

+
+
+
+
+ ) +} + +const ASK = "Write that the weld seams on line 3 are fine, and check bearing 4 before the next shift." +const STEPS: [number, Omit][] = [ + [19_000, { id: "read", label: "Read 2,048 frames from camera 3", detail: "" }], + [19_700, { id: "compare", label: "Compared seams with last week's baseline" }], + [20_400, { id: "sensor", label: "Pulled bearing temperatures, line 3" }], +] + +function Agent({ t }: { t: number }) { + const [a, b] = AGENT + const o = life(t, a, b, 600, 500) + const typed = ASK.slice(0, Math.floor(ASK.length * at(t, a + 500, a + 1900, (x) => x))) + const sent = t >= a + 2050 + const steps: Step[] = STEPS.filter(([when]) => t >= when).map(([when, step]) => ({ ...step, status: t >= when + 600 ? "done" : "running" })) + const asking = t >= 21_200 + const doing = t < 19_000 ? "Thinking" : t < 20_400 ? "Reading the line" : asking ? "Waiting for you" : "Checking the bearings" + return ( +
+ +
+
+
+ {sent && ( +
+ {ASK} +
+ )} + {t >= 18_500 && ( +
+ + {doing} + {Math.max(0, Math.floor((t - 18_500) / 1000))}s +
+ )} + {steps.length > 0 && } + {asking && ( +
+ {}} + /> +
+ )} +
+ {}} placeholder="Ask the agent…" running={sent && !asking} /> +
+
+
+ ) +} + +const ACCENTS = ["violet", "blue", "teal", "green", "amber", "orange", "red", "ink"] as const + +function Tune({ t }: { t: number }) { + const [a, b] = TUNE + const o = life(t, a, b, 600, 500) + const light = t >= LIGHT[0] + const index = Math.min(ACCENTS.length - 1, Math.max(0, Math.floor((t - a - 500) / 300))) + const accent = light ? "blue" : ACCENTS[index] + const tint = overrides({ ...DEFAULTS, accent }, light ? "light" : "dark") + return ( +
+ + +
+
+
+ + + + Live + v2.4 +
+ + +
+
+ +
+ + {Math.round(18 + 70 * at(t, a + 500, b - 400, easeInOut))}% +
+
+
+
+ ) +} + +function End({ t }: { t: number }) { + const [a] = END + return ( +
+
+ + +
+
+ $ npx shadcn@latest add deeplethe/solid-glass/solid-glass +
+
+ deeplethe.github.io/solid-glass + + + by DeepLethe + +
+
+ ) +} + +declare global { + interface Window { + __seek?: (ms: number) => void + __promoDuration?: number + } +} + +export function DevPromo() { + const params = React.useMemo(() => new URLSearchParams(window.location.search), []) + const capture = params.has("capture") + const held = params.get("t") + const [t, setT] = React.useState(() => (held ? +held : 0)) + const [fit, setFit] = React.useState(1) + + // Played in a loop unless a moment is held or the recorder drives the clock. + React.useEffect(() => { + if (capture || held) return + const start = performance.now() + let frame = requestAnimationFrame(function step(now) { + setT((now - start) % DURATION) + frame = requestAnimationFrame(step) + }) + return () => cancelAnimationFrame(frame) + }, [capture, held]) + + React.useEffect(() => { + window.__seek = (ms) => flushSync(() => setT(ms)) + window.__promoDuration = DURATION + const resize = () => setFit(Math.min(window.innerWidth / W, window.innerHeight / H)) + resize() + window.addEventListener("resize", resize) + return () => { + delete window.__seek + delete window.__promoDuration + window.removeEventListener("resize", resize) + } + }, []) + + // The film is dark but for one stretch in daylight. + const light = t >= LIGHT[0] && t < LIGHT[1] + React.useLayoutEffect(() => { + document.documentElement.classList.toggle("dark", !light) + }, [light]) + + const on = (range: readonly [number, number]) => t >= range[0] - 1 && t <= range[1] + 1 + return ( +
+
+ + {on(INTRO) && } + {on(PANES) && } + {on(EDGES) && } + {on(STATE) && } + {on(AGENT) && } + {on(TUNE) && } + {on(END) && } + {/* a blink across the turn into daylight, so the page does not jump from one theme to the other */} +
+
+
+ ) +}