Skip to content
Closed
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
64 changes: 64 additions & 0 deletions scripts/browser.mjs
Original file line number Diff line number Diff line change
@@ -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() }
}
86 changes: 86 additions & 0 deletions scripts/record.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Record the promotional film (/dev/promo) frame by frame into a video.
*
* node scripts/record.mjs <out.mp4> [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 <out.mp4> [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()
}
59 changes: 5 additions & 54 deletions scripts/shot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,67 +19,18 @@
* 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) {
console.error("usage: node scripts/shot.mjs <out.png|out.webp> <url> <width> <height> [scale] [waitMs]")
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))
Expand All @@ -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()
}
3 changes: 3 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 <DevBanner />
// The promotional film, played by the material itself (and recorded by scripts/record.mjs).
if (import.meta.env.DEV && pathname === "/dev/promo") return <DevPromo />
// A block by itself, in a window of its own: no header, no footer round it.
if (pathname.startsWith("/view/")) {
return (
Expand Down
6 changes: 3 additions & 3 deletions src/pages/dev-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SVGTextElement>, face?: [string, string]) => (
Expand All @@ -22,7 +22,7 @@ function GlassWordmark({ first, second }: { first: string; second: string }) {
</text>
)
return (
<svg width="222" height="48" viewBox="0 0 222 48" role="img" aria-label={`${first} ${second}`} className="overflow-visible font-sans">
<svg width={222 * scale} height={48 * scale} viewBox="0 0 222 48" role="img" aria-label={`${first} ${second}`} className="overflow-visible font-sans">
<defs>
{/* What is left of the letters once the same letters, moved down a little, are taken out: their top edges. */}
<mask id={`${id}rim`} maskUnits="userSpaceOnUse" x="-8" y="-8" width="240" height="64">
Expand Down Expand Up @@ -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)`,
})

Expand Down
Loading
Loading