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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
},
],
});
16 changes: 14 additions & 2 deletions scripts/check-contrast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -40,21 +46,27 @@ 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)}`,
);
}
}

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);
Expand Down
110 changes: 110 additions & 0 deletions scripts/check-sky-chrome.mjs
Original file line number Diff line number Diff line change
@@ -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);
27 changes: 16 additions & 11 deletions src/layouts/Layout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,24 @@ const ogImage = new URL('/og.png', Astro.site);
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- viewport-fit=cover: the sky paints edge-to-edge, under the
notch and home indicator. This is what keeps iOS Safari's
status-bar strip in sync with the sky — the strip shows the
page's own pixels, whereas its theme-color-derived tint can
lag a day/night switch indefinitely (issue #60). Controls and
page content already pad by env(safe-area-inset-*). -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Tints mobile browser chrome (Safari's toolbar) to match the sky;
sky.ts rewrites both on every render so the tint tracks
day/night, including manual switches. Two media-gated metas, not
one: iOS Safari treats a bare theme-color as a light-mode
suggestion and falls back to its own neutral grey chrome in dark
mode unless a dark-scheme variant exists. Content stays plain
hex/rgb — Safari's theme-color parser rejects oklch(), and a
rejected value also lands on the default grey. The fallback is
the pre-render midnight chip (the hex twin of
--color-chip-bg-fallback). -->
<!-- Tints mobile browser chrome to match the sky; sky.ts re-replaces
both on every render. Two media-gated metas, not one: iOS
Safari treats a bare theme-color as a light-mode suggestion and
falls back to neutral grey chrome in dark mode. Content stays
plain hex/rgb (Safari's theme-color parser rejects oklch), and
the fallback is the pre-render midnight chip. Some Safari
versions only honor the value they see around pageshow — that's
why start() re-renders on pageshow rather than trusting the
swaps made mid-load (issue #60). -->
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#141a3d" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#141a3d" />
<title>{title}</title>
Expand Down
3 changes: 2 additions & 1 deletion src/pages/blog/[...slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@
← blog
</a>

<article class="sky-card rounded-2xl px-6 py-8 sm:px-10 sm:py-10">

Check warning on line 29 in src/pages/blog/[...slug].astro

View workflow job for this annotation

GitHub Actions / Lint + format

The classes: "sm:px-10, sm:py-10" can be simplified to "sm:p-10"

Check warning on line 29 in src/pages/blog/[...slug].astro

View workflow job for this annotation

GitHub Actions / Lint + format

The classes: "sm:px-10, sm:py-10" can be simplified to "sm:p-10"
<p class="text-xs lowercase tracking-widest opacity-70">{dateFmt(post.data.publishDate)}</p>
<!-- Full opacity on purpose — see blog/index.astro's date line. -->
<p class="text-xs lowercase tracking-widest">{dateFmt(post.data.publishDate)}</p>
<h1 class="mt-2 text-3xl font-medium tracking-tight sm:text-4xl">{post.data.title}</h1>

{
Expand Down
11 changes: 7 additions & 4 deletions src/pages/blog/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<p class="text-xs lowercase tracking-widest opacity-70">
{/* 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. */}
<p class="text-xs lowercase tracking-widest">
{dateFmt(post.data.publishDate)}
{post.data.original && ` · originally posted on ${post.data.original.source}`}
</p>
<h2 class="mt-1 text-xl font-medium">{post.data.title}</h2>
{post.data.description && (
<p class="mt-1 text-sm opacity-80">{post.data.description}</p>
)}
{post.data.description && <p class="mt-1 text-sm">{post.data.description}</p>}
</a>
</li>
))
Expand Down
Loading
Loading