Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ assets/static/fonts/*.woff2

# Output of 'npm pack'
*.tgz

# Claude Code local worktrees/artifacts
.claude/
6 changes: 5 additions & 1 deletion assets/static/js/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
// Browser entry. Bun bundles this (inlining ./timer) into a self-contained
// Browser entry. esbuild bundles this (inlining ./timer) into a self-contained
// classic script with no exports, so it loads from a plain <script>. Keep it
// export-free and free of top-level await.

// Side-effect import: installs the replaceChildren shim for the older-browser
// degraded mode. Must stay first so the shim is in place before any render.
import './polyfills'

import { computeState, pad2, parseTarget } from './timer'

// Shown when the page is opened with no settings (e.g. the store preview or a
Expand Down
23 changes: 23 additions & 0 deletions assets/static/js/polyfills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Minimal runtime shim for the older-browser degraded mode. esbuild lowers modern
// *syntax* (?., ??, spread) at build time, but it does not add missing runtime
// APIs. The one DOM gap the apps can hit at the support floor is
// Element.prototype.replaceChildren (Chrome 86 / Safari 14, 2020); it's included
// as a defensive shim even in apps that don't call it directly. Everything else
// the apps use (fetch, Intl, URLSearchParams) predates the floor, so no core-js.
//
// Imported only for its side effect (installing the shim) as the first line of
// main.ts; it exports nothing.

if (typeof Element !== 'undefined' && !Element.prototype.replaceChildren) {
Element.prototype.replaceChildren = function replaceChildren(
this: Element,
...nodes: (Node | string)[]
): void {
// Build the new children in a fragment first so a bad node throws before we
// touch the element, keeping the swap effectively atomic like the native API.
const frag = document.createDocumentFragment()
frag.append(...nodes)
while (this.firstChild) this.removeChild(this.firstChild)
this.appendChild(frag)
}
}
14 changes: 14 additions & 0 deletions assets/static/styles/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
}

body {
min-height: 100vh; /* fallback for engines below the svh floor (~2018 players) */
min-height: 100svh;
background-color: var(--color-ink);
background-image: radial-gradient(ellipse at 50% 40%, var(--color-glow) 0%, transparent 62%),
Expand All @@ -66,6 +67,7 @@
position: relative;
display: grid;
place-items: center;
min-height: 100vh; /* fallback for engines below the svh floor (~2018 players) */
min-height: 100svh;
padding: clamp(1.5rem, 6vmin, 4rem);
overflow: hidden;
Expand Down Expand Up @@ -224,3 +226,15 @@
}
}
}

/* =========================================================================
Degraded mode — old/weak signage players (html.legacy set by the inline gate
in index.html). Assume the device can't afford animation; drop it entirely.
========================================================================= */
html.legacy *,
html.legacy *::before,
html.legacy *::after {
animation: none !important;
transition: none !important;
will-change: auto !important;
}
70 changes: 54 additions & 16 deletions build.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,23 @@
// dist/ is gitignored; CI uploads it as the Pages artifact.

import { rm, mkdir, cp, readFile, writeFile } from 'node:fs/promises'
import cascadeLayers from '@csstools/postcss-cascade-layers'
import browserslist from 'browserslist'
import { build as esbuild } from 'esbuild'
import { browserslistToTargets, transform as lightningcss } from 'lightningcss'
import postcss from 'postcss'
import { run as syncFonts } from './sync-fonts.js'

const DIST = 'dist'
const DOMAIN = 'timer.srly.io'

// The `browserslist` field in package.json is the CSS support floor: Lightning
// CSS down-levels the stylesheet to it. The JS is lowered separately by esbuild to
// a fixed ES2017 syntax floor (kept at/below the browserslist minimum); esbuild
// can't read browserslist, so keep the two in sync if you change the floor. See
// the degraded-mode notes in index.html / tailwind.css.
const cssTargets = browserslistToTargets(browserslist())

// 1. Vendor the Bun-managed webfonts into ./assets before copying.
await syncFonts()

Expand All @@ -27,6 +39,9 @@ await syncFonts()
// are never minified in place.
await rm(DIST, { recursive: true, force: true })
await mkdir(`${DIST}/static`, { recursive: true })
// Create the output subdirs up front so Tailwind/esbuild never race an absent dir.
await mkdir(`${DIST}/static/styles`, { recursive: true })
await mkdir(`${DIST}/static/js`, { recursive: true })
await cp('assets/static/fonts', `${DIST}/static/fonts`, { recursive: true })
await cp('assets/static/images', `${DIST}/static/images`, { recursive: true })
await cp('index.html', `${DIST}/index.html`)
Expand All @@ -35,39 +50,62 @@ await cp('index.html', `${DIST}/index.html`)
// with Access-Control-Allow-Origin: * so the store and players can fetch it.
await cp('.well-known', `${DIST}/.well-known`, { recursive: true })

// 3. Tailwind: compile + minify the source CSS to the served stylesheet.
// 3. Tailwind: compile the source CSS (unminified), then down-level + minify it
// for the browserslist floor. cascade-layers flattens @layer into :not(#\#)
// specificity so the cascade survives on engines that drop @layer contents;
// Lightning CSS then lowers color-mix()/nesting, adds prefixes, and minifies.
const cssOut = `${DIST}/static/styles/main.css`
const tailwind = Bun.spawn(
Comment thread
vpetersson marked this conversation as resolved.
[
'node_modules/.bin/tailwindcss',
'--input',
'assets/static/styles/tailwind.css',
'--output',
`${DIST}/static/styles/main.css`,
'--minify'
cssOut
],
{ stdout: 'inherit', stderr: 'inherit' }
)
if ((await tailwind.exited) !== 0) {
console.error('✗ Tailwind build failed')
process.exit(1)
}
console.log(`✓ CSS: ${DIST}/static/styles/main.css`)
try {
const flattened = await postcss([cascadeLayers()]).process(await readFile(cssOut, 'utf8'), {
from: cssOut
})
const { code: cssCode } = lightningcss({
filename: cssOut,
code: Buffer.from(flattened.css),
minify: true,
targets: cssTargets
})
await writeFile(cssOut, cssCode)
} catch (err) {
console.error(`✗ CSS build failed (${cssOut})`)
console.error(err)
process.exit(1)
}
console.log(`✓ CSS: ${cssOut} (Tailwind → cascade-layers flatten → Lightning CSS)`)

// 4. TypeScript → browser JS. main.ts imports ./timer; external:[] inlines it so
// the output is a single self-contained classic script.
const js = await Bun.build({
entrypoints: ['assets/static/js/main.ts'],
minify: true,
target: 'browser',
external: []
})
if (!js.success) {
// 4. TypeScript → browser JS with esbuild. Bundles main.ts (inlining ./timer and
// the polyfills shim), lowers modern syntax (?., ??, spread) to the ES2017 floor
// so old engines can parse it, and emits an IIFE so the output stays a
// self-contained self-executing classic script loadable from a plain <script>.
try {
await esbuild({
entryPoints: ['assets/static/js/main.ts'],
bundle: true,
minify: true,
format: 'iife',
target: ['es2017'],
outfile: `${DIST}/static/js/main.js`
})
} catch (err) {
console.error('✗ JS build failed')
for (const message of js.logs) console.error(message)
console.error(err)
process.exit(1)
}
await Bun.write(`${DIST}/static/js/main.js`, await js.outputs[0].text())
console.log(`✓ JS: ${DIST}/static/js/main.js`)
console.log(`✓ JS: ${DIST}/static/js/main.js (esbuild, iife, es2017)`)

// 5. Cache-busting: hash the built JS + CSS so the token changes exactly when
// shipped code changes, then stamp it into the page's asset URLs.
Expand Down
Loading
Loading