{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); +});