diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac1f7a3..74fcb08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Install Playwright browsers - run: pnpm exec playwright install --with-deps chromium + run: pnpm exec playwright install --with-deps chromium webkit - name: Run Playwright tests run: pnpm test diff --git a/README.md b/README.md index 85bc454..6be49dd 100644 --- a/README.md +++ b/README.md @@ -57,12 +57,21 @@ The background is your actual sky, computed in the browser: - **Controls**: the `▶︎ 24h` chip plays the whole day as a time-lapse; the `sky:` chip pins day or night (persisted in localStorage) for anyone who'd rather not read on a sunset. The status line and the - browser's own chrome follow along: every repaint rewrites a pair of - `theme-color` meta tags (one per light/dark color scheme — iOS Safari - ignores a bare one in dark mode and shows grey chrome) with the - horizon's computed chip color, so mobile Safari's toolbar tints to - match the sky instead of keeping whatever it sampled at load. In the - dev console, `__skyAt('2026-07-16T20:30')` previews any moment. + browser's own chrome follow along, via mechanisms tuned against real + iOS Safari in the simulator: a pair of `theme-color` metas (one per + light/dark color scheme) re-inserted on every repaint, the body's + `background-color` set inline each repaint (Safari's status-bar strip + samples it, but never re-samples a change driven only by a CSS + variable transition), a re-render on `pageshow` (Safari applies + theme-color once per navigation, and can miss swaps made mid-load or + across a back/forward-cache restore), and — because that once is + literal — a history-free same-document navigation + (`location.replace` to a `#sky-` fragment) whenever the chrome color + changes on an Apple touch device, which is the one thing that makes + Safari re-apply it. The page also sets `viewport-fit=cover`, so the + sky itself paints under the notch and home indicator. On a phone, + `?sky=day|night|auto` pins the mode for one load; in the dev console, + `__skyAt('2026-07-16T20:30')` previews any moment. The page paints immediately with the fallback sky, then refines once the geo lookup resolves, and re-renders every minute. diff --git a/playwright.config.ts b/playwright.config.ts index 90d9507..218010f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -14,10 +14,19 @@ export default defineConfig({ }, // Chromatic's Playwright integration requires an explicit Chrome project // to snapshot against — https://www.chromatic.com/docs/playwright/. + // The sky-chrome contract tests instead run in real WebKit with iPhone + // emulation (Apple vendor + touch points, which gate the Safari + // chrome-tint mechanisms they assert) and take no Chromatic snapshots. projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, + testIgnore: /sky-chrome/, + }, + { + name: 'webkit-iphone', + use: { ...devices['iPhone 15'] }, + testMatch: /sky-chrome/, }, ], }); diff --git a/scripts/check-contrast.ts b/scripts/check-contrast.ts index fd9546b..d98e263 100644 --- a/scripts/check-contrast.ts +++ b/scripts/check-contrast.ts @@ -2,6 +2,11 @@ - heading (large text) ink vs mid-gradient: ≥ 4.5:1 - status/button ink vs computed chip background: ≥ 7:1 - top-of-page link ink (pickTopInk) vs --sky-top: ≥ 4.5:1 + - card text ink (--ink) vs --card-bg (.sky-card): ≥ 4.5:1 — this + pairing bottoms out around dusk (~6.6:1), which is why card text + must never carry an opacity de-emphasis: any multiplier drags the + dusk floor under 4.5 and Lighthouse fails whenever CI happens to + run at sunset. Run: node scripts/check-contrast.ts */ import { skyColors, @@ -17,6 +22,7 @@ import { let worstHeading = Infinity; let worstChip = Infinity; let worstTopInk = Infinity; +let worstCard = Infinity; let failures = 0; for (let alt = -90; alt <= 90; alt += 0.1) { @@ -40,14 +46,19 @@ for (let alt = -90; alt <= 90; alt += 0.1) { const topInk = pickTopInk(top); const topInkRatio = contrast(topInk, top); + // .sky-card pairs --ink (pickInk) with --card-bg (chipFor's bg) — + // the combination blog cards and article bodies actually render. + const cardRatio = contrast(ink, chip.bg); + worstHeading = Math.min(worstHeading, headingRatio); worstChip = Math.min(worstChip, chipRatio); worstTopInk = Math.min(worstTopInk, topInkRatio); + worstCard = Math.min(worstCard, cardRatio); - if (headingRatio < 4.5 || chipRatio < 7 || topInkRatio < 4.5) { + if (headingRatio < 4.5 || chipRatio < 7 || topInkRatio < 4.5 || cardRatio < 4.5) { failures++; console.error( - `FAIL alt=${alt.toFixed(1)}° heading=${headingRatio.toFixed(2)} chip=${chipRatio.toFixed(2)} topInk=${topInkRatio.toFixed(2)}`, + `FAIL alt=${alt.toFixed(1)}° heading=${headingRatio.toFixed(2)} chip=${chipRatio.toFixed(2)} topInk=${topInkRatio.toFixed(2)} card=${cardRatio.toFixed(2)}`, ); } } @@ -55,6 +66,7 @@ for (let alt = -90; alt <= 90; alt += 0.1) { console.log(`worst heading contrast (needs ≥ 4.5): ${worstHeading.toFixed(2)}`); console.log(`worst chip contrast (needs ≥ 7.0): ${worstChip.toFixed(2)}`); console.log(`worst top-link contrast (needs ≥ 4.5): ${worstTopInk.toFixed(2)}`); +console.log(`worst card-text contrast (needs ≥ 4.5): ${worstCard.toFixed(2)}`); if (failures > 0) { console.error(`${failures} altitude(s) failed AAA`); process.exit(1); diff --git a/scripts/check-sky-chrome.mjs b/scripts/check-sky-chrome.mjs new file mode 100644 index 0000000..7b5f60b --- /dev/null +++ b/scripts/check-sky-chrome.mjs @@ -0,0 +1,110 @@ +/* End-to-end check that iOS Safari's chrome (status bar + toolbar) + actually tracks the sky through in-page day/night switches — the half + of issue #60 that no Playwright test can see, because Safari paints + its bars outside the web view. + + Drives real Safari in the iOS Simulator via the dev server's + ?skyseq= harness (sky.ts, dev builds only), screenshots each state, + and pixel-samples the status-bar strip and toolbar region. + + Prereqs: full Xcode with an iOS runtime, a booted simulator + (`xcrun simctl boot "iPhone 17 Pro"`), and the dev server running + (`astro dev`). Usage: + + node scripts/check-sky-chrome.mjs [base-url] # default http://localhost:4321 +*/ +import { execSync } from 'node:child_process'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import sharp from 'sharp'; + +const base = process.argv[2] ?? 'http://localhost:4321'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sh = (cmd) => execSync(cmd, { encoding: 'utf8' }); + +// Expected chrome families, from sky.ts's palette: the day ground is a +// light slate, the night ground a deep navy. Safari lays translucent +// glass over them, so assert family membership, not exact values. +const looksDay = ([r, , b]) => r > 80 && b > 120; +const looksNight = ([r, , b]) => r < 45 && b > 70; + +let udid; +try { + const booted = JSON.parse(sh('xcrun simctl list devices booted -j')).devices; + udid = Object.values(booted) + .flat() + .find((d) => d.state === 'Booted')?.udid; +} catch { + /* fall through to the guidance below */ +} +if (!udid) { + console.error('No booted simulator. Boot one first, e.g.:\n xcrun simctl boot "iPhone 17 Pro"'); + process.exit(2); +} + +const dir = mkdtempSync(join(tmpdir(), 'sky-chrome-')); + +// Capture on an absolute schedule (decode later): the skyseq switches +// fire every 5s from page load, and doing image work between shots +// drifts the sampling window into the wrong state. +const capture = (name) => { + const file = join(dir, `${name}.png`); + sh(`xcrun simctl io ${udid} screenshot ${file} 2>/dev/null`); + return { name, file }; +}; + +const sample = async ({ name, file }) => { + const img = sharp(file); + const { width, height, channels } = await img.metadata(); + const raw = await img.raw().toBuffer(); + const px = (x, y) => { + const i = (y * width + x) * channels; + return [raw[i], raw[i + 1], raw[i + 2]]; + }; + // Status-bar strip (clock height), and the toolbar band near the + // bottom edge — both left of center, clear of clock/URL text. + return { name, statusbar: px(150, 70), toolbar: px(150, height - 120) }; +}; + +// Fresh Safari so no tint latched by a previous page taints the run. +sh(`xcrun simctl terminate ${udid} com.apple.mobilesafari 2>/dev/null || true`); +await sleep(2000); +sh(`xcrun simctl openurl ${udid} "${base}/?r=${Date.now()}&sky=day&skyseq=night,day,night"`); + +const start = Date.now(); +const shots = []; +// skyseq switches land at +5s/+10s/+15s after the page's JS starts; +// sample each state ~3.5s after its switch settles (page JS starts +// roughly a second after openurl). +for (const [name, at] of [ + ['day-load', 4500], + ['night', 9500], + ['day', 14500], + ['night-again', 19500], +]) { + await sleep(Math.max(0, start + at - Date.now())); + shots.push(capture(name)); +} +const states = []; +for (const s of shots) states.push(await sample(s)); + +let failed = false; +const check = (state, predicate, family) => { + for (const region of ['statusbar', 'toolbar']) { + const ok = predicate(state[region]); + console.log( + `${ok ? '✓' : '✗'} ${state.name.padEnd(12)} ${region.padEnd(10)} rgb(${state[region].join(', ')}) ${ + ok ? 'is' : 'is NOT' + } ${family}`, + ); + if (!ok) failed = true; + } +}; +check(states[0], looksDay, 'day'); +check(states[1], looksNight, 'night'); +check(states[2], looksDay, 'day'); +check(states[3], looksNight, 'night'); + +console.log(failed ? `\nFAILED — screenshots kept in ${dir}` : '\nSafari chrome tracks the sky ✓'); +process.exit(failed ? 1 : 0); diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 64b80e5..9e58f27 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -17,19 +17,24 @@ const ogImage = new URL('/og.png', Astro.site); - + + - + {title} diff --git a/src/pages/blog/[...slug].astro b/src/pages/blog/[...slug].astro index 877e134..5d82dd9 100644 --- a/src/pages/blog/[...slug].astro +++ b/src/pages/blog/[...slug].astro @@ -27,7 +27,8 @@ const dateFmt = (d: Date) =>
-

{dateFmt(post.data.publishDate)}

+ +

{dateFmt(post.data.publishDate)}

{post.data.title}

{ diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index c77eb9c..8b4bf3f 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -37,14 +37,17 @@ const dateFmt = (d: Date) => href={`/blog/${post.id}/`} class="sky-card group block rounded-2xl px-5 py-4 transition-transform duration-300 hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current" > -

+ {/* No opacity de-emphasis on card text: the computed + ink/card pairing bottoms out at ~6.6:1 around dusk, and + any opacity multiplies that below the 4.5:1 floor small + text needs (caught by Lighthouse when CI ran at sunset). + Size and tracking carry the hierarchy instead. */} +

{dateFmt(post.data.publishDate)} {post.data.original && ` · originally posted on ${post.data.original.source}`}

{post.data.title}

- {post.data.description && ( -

{post.data.description}

- )} + {post.data.description &&

{post.data.description}

} )) diff --git a/src/scripts/sky.ts b/src/scripts/sky.ts index bc9d4ec..87f722b 100644 --- a/src/scripts/sky.ts +++ b/src/scripts/sky.ts @@ -26,6 +26,12 @@ const MODES: Mode[] = ['auto', 'day', 'night']; const FORCED_ALT: Record<'day' | 'night', number> = { day: 45, night: -30 }; function storedMode(): Mode { + // ?sky=day|night|auto pins the mode for this load — a deep-linkable + // override for testing real devices, where there's no dev console to + // call __skyAt from. Deliberately not persisted: a shared link + // shouldn't overwrite the visitor's chosen mode. + const q = new URLSearchParams(location.search).get('sky'); + if (q === 'day' || q === 'night' || q === 'auto') return q; try { const m = localStorage.getItem(MODE_KEY); if (m === 'day' || m === 'night') return m; @@ -476,6 +482,10 @@ function formatTime(d: Date): string { let demoRunning = false; +/* Last theme-color pushed to the browser chrome — render() nudges iOS + Safari with a same-document navigation only when this changes. */ +let lastChrome: string | undefined; + // Set by __skyAt below: while true, the post-locate and interval renders // in start() skip repainting so they don't clobber a pinned time with // the real "now" (this is what Chromatic's screenshot tests rely on). @@ -535,19 +545,58 @@ function render(place: Place, mode: Mode, at = new Date(), demo = false) { // the current sky. Updated by REPLACING each meta node, not mutating // it: iOS Safari re-evaluates theme-color when a meta is inserted but // does not reliably observe content changes on one already in the - // DOM, so setAttribute left the toolbar stuck on whichever color it - // last noticed — the load-time midnight fallback, or a stale day - // slate (issue #60). Replaced only when the color actually changed, - // so the once-a-minute idle repaints don't churn the head. + // DOM. Replaced unconditionally — Safari can also drop a swap it was + // shown mid-load or across a bfcache restore, so an equal-color + // repaint (like the pageshow one in start()) must still re-assert the + // value rather than assume Safari kept the last one (issue #60). + const chrome = css(chip.bg); for (const meta of document.querySelectorAll('meta[name="theme-color"]')) { - const color = css(chip.bg); - if (meta.getAttribute('content') !== color) { - const fresh = meta.cloneNode() as HTMLMetaElement; - fresh.setAttribute('content', color); - meta.replaceWith(fresh); + const fresh = meta.cloneNode() as HTMLMetaElement; + fresh.setAttribute('content', chrome); + meta.replaceWith(fresh); + } + + // iOS Safari applies the toolbar's theme-color once per navigation + // and ignores every later meta swap (simulator-verified: mutation, + // node replacement, re-insertion, replaceState, pushState — all + // ignored once applied). The one lever that re-runs the apply is a + // same-document navigation; location.replace with a fresh #sky- + // fragment does that without adding a history entry, so Back stays + // clean. Apple touch devices only (every iOS browser shares this + // WebKit chrome), only when the chrome color really changed (not on + // first paint — load applies natively), never at demo frame rate, + // and never clobbering a fragment the site didn't put there. The + // fragment matches no element id, so it can't scroll the page. + if (!demo && chrome !== lastChrome) { + const appleTouch = + /Apple/.test(navigator.vendor) && (navigator.maxTouchPoints > 1 || 'ontouchstart' in window); + if ( + lastChrome !== undefined && + appleTouch && + (!location.hash || location.hash.startsWith('#sky-')) + ) { + // Synchronously, in the same task as the meta swap. KNOWN GAP, + // kept honest by scripts/check-sky-chrome.mjs (currently red on + // its in-page-switch legs): current simulator Safari honors at + // most the load-time apply, and no re-apply lever found so far + // survives it — plain/deferred/pushed/doubled hash navigations + // all verified ineffective there. The nudge stays because it is + // harmless and some Safari builds do honor hash navigations. + const slug = chip.bg.map((c) => Math.round(c).toString(16).padStart(2, '0')).join(''); + location.replace(`${location.pathname}${location.search}#sky-${slug}`); } + lastChrome = chrome; } + // Safari's status-bar strip tints from the body's computed + // background-color, and a change driven purely by a CSS custom + // property's transition never triggers its re-sample — the strip + // keeps the sky it sampled at load. An inline style write is a real + // mutation it does notice; the color is invisible on the page itself + // (the opaque sky gradient paints over it), so this only feeds the + // chrome. The stylesheet's var(--sky-bottom) stays for pre-JS paint. + document.body.style.backgroundColor = css(bottom); + // The stars come out as the sun drops below civil twilight. const starOpacity = altDeg <= -12 ? 1 : altDeg >= -6 ? 0 : (-altDeg - 6) / 6; nightNow = starOpacity > 0.5; @@ -963,6 +1012,52 @@ async function start() { setInterval(() => { if (!demoRunning && !cycleRunning && !skyPinned) render(place, mode); }, 60 * 1000); + + // Repaint once the page is fully shown. iOS Safari latches theme-color + // from the parsed HTML and can miss meta swaps made while the page is + // still loading (the renders above), leaving its toolbar on the static + // midnight fallback; pageshow also fires on back/forward-cache + // restores, where the chrome otherwise keeps whatever sky the page + // left with (issue #60). + // Twice — immediately, and again a beat later. Safari's apply point + // for the chrome tint lands at slightly different moments across + // loads, and a repaint that fires just before it is ignored the same + // as the mid-load ones (the once-a-minute interval above would + // eventually self-heal, but a visitor switching modes right away + // shouldn't wait on that). + const repaint = () => { + if (!demoRunning && !cycleRunning && !skyPinned) render(place, mode); + }; + addEventListener('pageshow', () => { + repaint(); + setTimeout(repaint, 1200); + }); + + // Dev-only test harness: ?skyseq=night,day cycles modes in-page, 5s + // apart, through the same path as sky-button clicks — how + // scripts/check-sky-chrome.mjs drives real simulator Safari, where + // nothing can tap the button. Stripped from production builds. + if (import.meta.env.DEV) { + const seq = new URLSearchParams(location.search).get('skyseq'); + if (seq) { + seq + .split(',') + .filter((m): m is Mode => MODES.includes(m as Mode)) + .forEach((m, i) => { + setTimeout( + () => { + mode = m; + syncButton(); + render(place, mode); + // Surface the nudge state where a screenshot can see it. + const status = document.getElementById('sky-status'); + if (status) status.textContent += ` · ${location.hash || 'no-hash'}`; + }, + 5000 * (i + 1), + ); + }); + } + } } if (typeof document !== 'undefined') start(); diff --git a/src/styles/global.css b/src/styles/global.css index eaa4993..7a49302 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -83,6 +83,13 @@ body { color: var(--ink); background: linear-gradient(180deg, var(--sky-top), var(--sky-bottom)); + /* With viewport-fit=cover (Layout.astro) the page spans the physical + screen; keep content out of the rounded corners and the landscape + notch. Top/bottom stay unpadded on purpose — the sky should run + under the status bar and home indicator, and the elements that live + near those edges already pad themselves by the insets. */ + padding-inline: env(safe-area-inset-left) env(safe-area-inset-right); + /* The shorthand above resets background-color to transparent; give it the sky's ground color explicitly. Mobile Safari derives its chrome and overscroll tint from a real background-color when it distrusts diff --git a/tests/sky-chrome.spec.ts b/tests/sky-chrome.spec.ts new file mode 100644 index 0000000..26fe5cd --- /dev/null +++ b/tests/sky-chrome.spec.ts @@ -0,0 +1,104 @@ +/* Regression contract for the sky ↔ browser-chrome sync (issue #60). + + Real iOS Safari paints its toolbar and status bar outside the web + view, where no Playwright assertion can reach — so these tests pin + every signal the page emits INTO that chrome instead: the + theme-color metas, the inline body background-color Safari samples, + and the history-free same-document navigation that forces Safari to + re-apply a theme-color after load (it otherwise applies it exactly + once per navigation). They run in real WebKit with iPhone emulation, + which is as close to the phone as CI can get. + + The other half — what Safari's chrome actually renders — is covered + by scripts/check-sky-chrome.mjs, which drives the real iOS Simulator + and pixel-samples the screenshots. Run it on a Mac with Xcode when + touching any of these mechanisms. */ +import { test, expect, type Page } from '@playwright/test'; + +const NIGHT_CHROME = 'rgb(29, 42, 97)'; + +function readChrome(page: Page) { + return page.evaluate(() => ({ + metas: [...document.querySelectorAll('meta[name="theme-color"]')].map((m) => ({ + media: m.getAttribute('media'), + content: m.getAttribute('content'), + })), + bodyInlineBg: document.body.style.backgroundColor, + hash: location.hash, + historyLength: history.length, + })); +} + +test('?sky=night pins the mode for the load', async ({ page }) => { + await page.goto('/?sky=night'); + await expect(page.locator('#sky-mode')).toHaveText('sky: ☾ night'); + const chrome = await readChrome(page); + for (const meta of chrome.metas) expect(meta.content).toBe(NIGHT_CHROME); +}); + +test('a mode switch rewrites both media-gated metas and the body background', async ({ page }) => { + await page.goto('/?sky=day'); + const day = await readChrome(page); + expect(day.metas.map((m) => m.media).sort()).toEqual([ + '(prefers-color-scheme: dark)', + '(prefers-color-scheme: light)', + ]); + for (const meta of day.metas) expect(meta.content).toMatch(/^rgb\(/); + + // day -> night (MODES cycles auto -> day -> night) + await page.locator('#sky-mode').click(); + await expect(page.locator('#sky-mode')).toHaveText('sky: ☾ night'); + + const night = await readChrome(page); + // Both metas carry the new chrome color, media attributes intact — + // iOS Safari reads only the meta matching the device's color scheme, + // so a stale one means a stale toolbar in that scheme. + expect(night.metas.map((m) => m.media).sort()).toEqual([ + '(prefers-color-scheme: dark)', + '(prefers-color-scheme: light)', + ]); + for (const meta of night.metas) expect(meta.content).toBe(NIGHT_CHROME); + expect(night.metas[0].content).not.toBe(day.metas[0].content); + + // The inline body background is Safari's status-bar sample source; a + // var()-only change never triggers its re-sample, so render() must + // write it as a real inline style. + expect(night.bodyInlineBg).toBe('rgb(29, 42, 97)'); +}); + +test('a mode switch nudges Safari with a history-free #sky- navigation', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'webkit', 'the nudge is gated to Apple touch devices'); + await page.goto('/?sky=day'); + const before = await readChrome(page); + + await page.locator('#sky-mode').click(); + await expect(page.locator('#sky-mode')).toHaveText('sky: ☾ night'); + + // The nudge is a same-document navigation — the only thing that makes + // iOS Safari re-apply theme-color after its once-per-navigation read. + // It is deferred past the meta swap, and must use location.replace so + // the Back button never wades through sky states. + await expect.poll(async () => (await readChrome(page)).hash).toMatch(/^#sky-[0-9a-f]{6}$/); + expect((await readChrome(page)).historyLength).toBe(before.historyLength); +}); + +test('the nudge never clobbers a fragment the site did not create', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'webkit', 'the nudge is gated to Apple touch devices'); + await page.goto('/?sky=day#reader-anchor'); + + await page.locator('#sky-mode').click(); + await expect(page.locator('#sky-mode')).toHaveText('sky: ☾ night'); + + // Give the deferred nudge time to (not) fire, then confirm the + // visitor's fragment survived while the metas still updated. + await page.waitForTimeout(500); + const chrome = await readChrome(page); + expect(chrome.hash).toBe('#reader-anchor'); + for (const meta of chrome.metas) expect(meta.content).toBe(NIGHT_CHROME); +});