diff --git a/brand/og-image.html b/brand/og-image.html new file mode 100644 index 0000000..2a6a0f7 --- /dev/null +++ b/brand/og-image.html @@ -0,0 +1,230 @@ + + + + +FastShared — OG card source (1200×630) + + + + + + + +
+
Temporary file links for Apple devices
+

+ Share anything. + Get a link. + Watch it vanish. +

+
+ fastshared. + / + iPhone · iPad · Mac + / + App Store +
+
+ +
+
+ FastShared + Transfer manifest +
+
+ File + screen-recording.mov + 284 MB +
+
+ Link + fastsha.red/s/K9H4 + Copied +
+
+ Expiry + 23 h 59 min + Scheduled +
+
+ After + 410 Gone + Deleted +
+
+ + diff --git a/brand/og-image.png b/brand/og-image.png index a3b7776..3837175 100644 Binary files a/brand/og-image.png and b/brand/og-image.png differ diff --git a/brand/og-image.sh b/brand/og-image.sh new file mode 100755 index 0000000..4f16346 --- /dev/null +++ b/brand/og-image.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Render brand/og-image.html to the 1200x630 social card, then check it against +# the design-intelligence pixel targets before letting it overwrite anything. +# +# Why headless Chrome and not ImageMagick (which export.sh uses for the icons): +# the card is typographic, and its whole point is being the same Bricolage +# Grotesque + JetBrains Mono as the site. Compositing text with `magick` would +# reproduce the layout but not the typeface, the stylistic alternates, or the +# optical size axis — so it would look adjacent to the brand instead of being it. +# +# Usage: +# ./og-image.sh # render, audit, and install into web/public + brand +# ./og-image.sh --check # render to a temp file and audit only; installs nothing +set -euo pipefail + +cd "$(dirname "$0")" + +CHECK_ONLY=0 +[[ "${1:-}" == "--check" ]] && CHECK_ONLY=1 + +CHROME=${CHROME:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome} +AUDIT=${AUDIT:-$HOME/.claude/skills/design-intelligence/scripts/pixel_audit.py} +SRC="$PWD/og-image.html" +TMP="$(mktemp -d)" +OUT="$TMP/og-image.png" +trap 'rm -rf "$TMP"' EXIT + +if [[ ! -x "$CHROME" ]]; then + echo "Google Chrome not found at: $CHROME" >&2 + echo "Override with CHROME=/path/to/chrome $0" >&2 + exit 1 +fi + +# --virtual-time-budget lets the webfonts finish loading before the capture; the +# stylesheet is requested with display=block so Chrome does not paint a fallback +# face first and bake the wrong typeface into the PNG. +"$CHROME" \ + --headless \ + --disable-gpu \ + --hide-scrollbars \ + --force-device-scale-factor=1 \ + --window-size=1200,630 \ + --virtual-time-budget=20000 \ + --screenshot="$OUT" \ + "file://$SRC" >/dev/null 2>&1 + +if [[ ! -s "$OUT" ]]; then + echo "Render produced no output." >&2 + exit 1 +fi + +# Refuse to ship a card that is not 1200x630. Every consumer assumes it, and the +# og:image:width/height meta in Base.astro states it as fact. +read -r W H < <(python3 -c " +from PIL import Image +w, h = Image.open('$OUT').size +print(w, h) +") +if [[ "$W" != "1200" || "$H" != "630" ]]; then + echo "Rendered $W x $H, expected 1200 x 630 — refusing to install." >&2 + exit 1 +fi +echo "rendered ${W}x${H}" + +if [[ -f "$AUDIT" ]]; then + echo "-- pixel audit --" + python3 "$AUDIT" "$OUT" | python3 -c " +import sys, json +d = json.loads(sys.stdin.readline()) +lum = d['luminance']['mean']; std = d['contrast']['std'] +sat = d['saturation']['mean']; dom = d['dominant_colors'][0] +ok = lambda c: 'ok ' if c else 'FAIL' + +# The saturation check is NOT a flat threshold, and the reason is measured rather +# than a matter of taste. HSV saturation is a ratio, so it is ill-conditioned in +# deep shadow: the ground token --cream #0f0f12 is (15,15,18), a 3/255 blue lift, +# which HSV reports as 0.167 saturated. With that ground covering ~85% of a card, +# the mean cannot go below ~0.14 no matter how neutral the design is — the flat +# 0.15 target from the skill is unreachable while staying faithful to the site's +# own token, and the only way to "pass" it would be to pick a ground the site does +# not use. That is exactly the drift this file was rebuilt to end. +# +# (The site itself measures 0.099 only because its screenshot pipeline shifted the +# ground to #111113, S=0.105. Capture artefact, not a different design.) +# +# So the question asked here is the one the metric was trying to ask: is the base +# neutral and the accent merely a point? If the mean sits at or below the ground's +# OWN saturation, every non-ground pixel is net-desaturating, which is a stronger +# result than any absolute number. +def own_sat(hexs): + h = hexs.lstrip('#'); r, g, b = (int(h[i:i+2], 16) for i in (0, 2, 4)) + mx, mn = max(r, g, b), min(r, g, b) + return 0.0 if mx == 0 else (mx - mn) / mx + +ground = own_sat(dom['hex']) +sat_budget = ground + 0.02 + +print(f\" {ok(lum < 60)} luminance {lum:6.2f} target < 60 (dark pole)\") +print(f\" {ok(std >= 40)} contrast std {std:6.2f} target >= 40\") +print(f\" {ok(dom['share'] >= 0.80)} dominant bg {dom['share']*100:5.1f}% target >= 80% ({dom['hex']})\") +print(f\" {ok(sat <= sat_budget)} saturation {sat:6.3f} target <= {sat_budget:.3f}\" + f\" (ground {dom['hex']} is itself {ground:.3f})\") +sys.exit(0 if (lum < 60 and std >= 40 and sat <= sat_budget and dom['share'] >= 0.80) else 1) +" || { echo "Pixel targets not met — refusing to install." >&2; exit 1; } +else + echo "pixel_audit.py not found at $AUDIT — skipping the audit gate." >&2 +fi + +if [[ "$CHECK_ONLY" == "1" ]]; then + echo "--check: nothing installed." + exit 0 +fi + +cp "$OUT" ./og-image.png +cp "$OUT" ../web/public/og-image.png +echo "installed -> brand/og-image.png and web/public/og-image.png" +cat <<'NOTE' + +NOT LIVE YET. Committing and deploying is not enough for this asset. +/og-image.png is in APP_PATH_PREFIXES, so the apex is served by the Worker out of +caches.default with max-age=86400, immutable — a new card stays invisible at +fastsha.red for up to 24h per edge, and `immutable` stops scrapers revalidating. + + # origin gets it immediately: + curl -s https://fastshared-web.pages.dev/og-image.png | shasum -a 256 + # apex may still be cached — compare, and read the headers: + curl -sI https://fastsha.red/og-image.png | grep -iE 'cf-cache-status|age|content-length' + +If they differ, purge that URL in Cloudflare, then re-scrape in the platform's own +card validator (unfurlers cache separately). See web/README.md. +NOTE diff --git a/docs/ops/email-dns-setup.md b/docs/ops/email-dns-setup.md new file mode 100644 index 0000000..49e7bcf --- /dev/null +++ b/docs/ops/email-dns-setup.md @@ -0,0 +1,274 @@ +# Email setup for `@fastsha.red` (Fastmail on Cloudflare DNS) + +The site publishes four `@fastsha.red` addresses. **None of them can receive +mail today** — the domain has no MX records at all. This runbook makes them +work via Fastmail, which is already the chosen provider. It does not re-open +that decision, and it does not propose removing the published addresses. + +Everything in section 1 was measured on 2026-08-17 and includes the commands to +re-measure it, because a runbook that asserts DNS state without showing how to +check it goes stale silently. + +## Sources, and which one wins + +DNS values in section 2 come from Fastmail's official documentation +(fastmail.help, "Setting up your domain: MX only"), read on 2026-08-17. + +> **The Fastmail UI is the final authority.** Fastmail shows the exact records +> for *your* domain inside your own account. If its screen disagrees with this +> table, **the screen wins** and this file is wrong — fix this file. DNS values +> copied from third-party write-ups (or from a stale runbook like this one may +> become) are a classic cause of mail that silently never arrives. + +The DKIM hostnames below are domain-specific: they embed `fastsha.red`. They +are correct for this domain only. + +## 1. The problem + +### Measured state — 2026-08-17 + +Queried against three independent resolvers (Cloudflare `1.1.1.1`, Google +`8.8.8.8`, Quad9 `9.9.9.9`). **All three agree**, which rules out one +resolver's stale cache as the explanation. + +| Record | Measured value | Consequence | +| --- | --- | --- | +| `MX` for `fastsha.red` | **zero records** | No mail server is designated. Note the precise mechanism, because "no MX" does not mean senders give up immediately: under RFC 5321 §5.1 an absent MX makes the sender fall back to the **implicit MX**, i.e. the domain's `A`/`AAAA`. Those exist (see below) and point at Cloudflare's proxy, which does not answer on port 25 — so delivery still fails, but it fails by timing out or being refused at the edge rather than by "no such mail domain". Either way nothing arrives. | +| `TXT` with `v=spf1` | **zero records** | No SPF policy published. | +| `TXT` at `_dmarc` | **zero records** | No DMARC policy published. | +| Cloudflare Email Routing | **absent** | It would have installed `route1/2/3.mx.cloudflare.net` MX records; there are no MX records, so it is not enabled. | +| `TXT` at apex (existing) | `google-site-verification=QP01Al_TyhTD9W628Scebded18Vui2pRC1OdQkYkVBg` | Unrelated to mail, **but it must survive** the SPF change — see section 3. | +| Nameservers | `rayden.ns.cloudflare.com`, `elly.ns.cloudflare.com` | DNS is managed at Cloudflare; that is where the records get added. | +| Apex `A` / `AAAA` | `104.21.52.51`, `172.67.195.146`, `2606:4700:3035::6815:3433`, `2606:4700:3036::ac43:c392` | Cloudflare anycast addresses — the apex is **proxied** (orange cloud). Relevant because it sets the default habit that section 3 warns about. | + +The four addresses are not "misconfigured", they are **unconfigured**. Mail +sent to them today fails; it is not sitting in a spam folder somewhere. + +### Re-measure it yourself + +```bash +for r in 1.1.1.1 8.8.8.8 9.9.9.9; do + echo "=== resolver $r ===" + echo "-- MX --"; dig +short MX fastsha.red @$r + echo "-- TXT apex --"; dig +short TXT fastsha.red @$r + echo "-- DMARC --"; dig +short TXT _dmarc.fastsha.red @$r + echo "-- NS --"; dig +short NS fastsha.red @$r +done +``` + +Empty output under `MX` and `DMARC` reproduces the broken state. Use three +resolvers here too: agreement across independent resolvers is what separates +"not published" from "published, and one cache hasn't caught up". + +### What is published, and where + +Confirmed by reading the files in this repo at the commit these paths point to. +Re-check with: + +```bash +grep -rn "mailto:" web/src/pages/ +``` + +| Address | Published at (`file:line`) | What it is | +| --- | --- | --- | +| `privacy@fastsha.red` | `web/src/pages/privacy.astro:37`, `:287`, `:372`; `web/src/pages/support.astro:82` | **The data-subject request route for LGPD / GDPR.** The privacy policy names it as the way to exercise those rights. A statutory request channel that bounces is a compliance problem, not an inconvenience. | +| `support@fastsha.red` | `web/src/pages/support.astro:27`; `web/src/pages/terms.astro:75`, `:326` | **The user support channel Apple requires**, and the one real users actually write to. Also see the escalation below. | +| `abuse@fastsha.red` | `web/src/pages/support.astro:59` | **The abuse channel for a file-sharing service.** Reports of illegal or infringing content arrive here. This is the most sensitive of the four: a file-sharing product whose abuse channel silently discards reports has no working path to act on them. | +| `press@fastsha.red` | `web/src/pages/press.astro:129` | Press kit contact. Lowest stakes of the four — a missed press email costs an opportunity, not a legal or safety obligation. | + +Two notes where reality differs from how this is often summarized: + +- `abuse@` is published in **`support.astro:59`**, not in `privacy.astro`. +- `privacy@` and `support@` each appear on **more than one page** (`support.astro` + and `terms.astro` respectively), so the blast radius is wider than the + "one address, one page" model suggests. + +### Escalation: `support@` is also the App Review contact + +```bash +grep -n "REVIEW_EMAIL" apple/.env.appstore.example +``` + +`apple/.env.appstore.example:12` sets `FASTSHARED_REVIEW_EMAIL=support@fastsha.red`, +and that file's own comment states these values **are sent to App Review**. So a +non-delivering `support@` is not only the user-facing support channel — it is +also the address Apple would use to reach the developer about a submission. +Fixing this is a release dependency, not only a website chore. + +## 2. Records to add in Cloudflare + +Literal values from Fastmail's official "MX only" documentation. Add them at +Cloudflare (the nameservers above confirm that is the authoritative place). + +| Type | Name / Host | Value / Target | Priority | Proxy | +| --- | --- | --- | --- | --- | +| `MX` | `@` | `in1-smtp.messagingengine.com` | `10` | n/a | +| `MX` | `@` | `in2-smtp.messagingengine.com` | `20` | n/a | +| `TXT` | `@` | `v=spf1 include:spf.messagingengine.com ?all` | — | n/a | +| `CNAME` | `fm1._domainkey` | `fm1.fastsha.red.dkim.fmhosted.com` | — | **DNS only (grey)** | +| `CNAME` | `fm2._domainkey` | `fm2.fastsha.red.dkim.fmhosted.com` | — | **DNS only (grey)** | +| `CNAME` | `fm3._domainkey` | `fm3.fastsha.red.dkim.fmhosted.com` | — | **DNS only (grey)** | +| `TXT` | `_dmarc` | `v=DMARC1; p=none;` | — | n/a | + +`MX` and `TXT` records are never proxied by Cloudflare — the grey/orange choice +only exists for `CNAME`/`A`/`AAAA`, which is why the Proxy column only matters +on the three DKIM rows. + +I am deliberately not naming the specific buttons or tab labels in the +Cloudflare or Fastmail dashboards, because I have not verified their current +wording and UI labels change. Work in terms of the record operations above: +**add** these records, and leave the existing apex `TXT` alone. + +### Why DKIM is not optional here + +DKIM is required if you want DMARC to mean anything for this setup. By default +Fastmail uses its own domain in the return-path, so **SPF alignment does not +pass** for messages you send — DKIM is what carries the aligned identifier that +DMARC evaluates. Skipping the three DKIM `CNAME`s and then hardening DMARC is +how you end up failing DMARC on your own outbound mail. + +## 3. Two Cloudflare-specific traps + +### Trap 1 — the DKIM `CNAME`s must be grey (DNS only) + +Cloudflare's proxy (orange cloud) answers with Cloudflare's own addresses +instead of resolving to the target. For a DKIM `CNAME` that breaks the lookup, +and **Fastmail cannot detect the record** — it reads as not set up. + +This trap is live here specifically because the apex is already proxied (the +measured Cloudflare anycast IPs in section 1). Orange is the established habit +on this zone, and Cloudflare defaults new proxyable records to proxied. All +three `fm*._domainkey` records must be **DNS only / grey**. + +### Trap 2 — do not destroy the existing apex `TXT` + +The apex already has one `TXT` record: + +``` +google-site-verification=QP01Al_TyhTD9W628Scebded18Vui2pRC1OdQkYkVBg +``` + +Two rules, and confusing them is the usual way this breaks: + +1. **Add a new `TXT` record for SPF. Do not edit the existing one.** Multiple + distinct `TXT` records at the apex are normal and correct. The + `google-site-verification` value and the SPF value belong in **two separate + `TXT` records**. Editing the existing record's value in place — instead of + creating a second record — is what silently drops the Google verification. +2. **Only one record may begin with `v=spf1`.** This constraint applies to SPF + records specifically, not to `TXT` records in general. If a second mail or + sending service ever needs SPF, do **not** add a second `v=spf1` record — + combine its `include:` into the single existing SPF record (Fastmail's + documentation notes a limit of 10 `include:` mechanisms). Two `v=spf1` + records is a permanent SPF failure, not a fallback. + +## 4. How to verify + +DNS first, then real mail. **DNS looking correct is not proof of delivery** — +the only thing that proves the four addresses work is mail arriving in them. + +### DNS checks and what "right" looks like + +```bash +# MX — expect exactly these two, with these priorities +dig +short MX fastsha.red @1.1.1.1 +# 10 in1-smtp.messagingengine.com. +# 20 in2-smtp.messagingengine.com. + +# TXT apex — expect BOTH lines to be present (order is not significant) +dig +short TXT fastsha.red @1.1.1.1 +# "google-site-verification=QP01Al_TyhTD9W628Scebded18Vui2pRC1OdQkYkVBg" +# "v=spf1 include:spf.messagingengine.com ?all" + +# Exactly ONE record may start with v=spf1 — this must print 1, never 2 +dig +short TXT fastsha.red @1.1.1.1 | grep -c "v=spf1" + +# DKIM — each must return the CNAME TARGET, not a Cloudflare IP. +# A target here means grey/DNS-only. Cloudflare IPs (or an empty result plus +# A records) means the record is still proxied — that is Trap 1. +for n in fm1 fm2 fm3; do + printf '%s: ' "$n" + dig +short CNAME "$n._domainkey.fastsha.red" @1.1.1.1 +done +# fm1: fm1.fastsha.red.dkim.fmhosted.com. +# fm2: fm2.fastsha.red.dkim.fmhosted.com. +# fm3: fm3.fastsha.red.dkim.fmhosted.com. + +# DMARC +dig +short TXT _dmarc.fastsha.red @1.1.1.1 +# "v=DMARC1; p=none;" +``` + +Re-run the MX and DMARC checks against `8.8.8.8` and `9.9.9.9` as well. Until +all three resolvers agree, you are looking at cache state, not at published +state. + +**On timing:** I am not stating a propagation figure — I have not measured one +for this zone, and any specific number would be invented. Records become +visible as caches expire, bounded by the TTL on each record. Poll the commands +above rather than waiting a quoted interval. + +### The check that actually matters + +From an external mailbox (not a `@fastsha.red` address), send one message to +each of the four: + +``` +privacy@fastsha.red +support@fastsha.red +abuse@fastsha.red +press@fastsha.red +``` + +Confirm all four **arrive** in Fastmail. Test each one individually — they may +be configured as separate users, aliases, or a catch-all, and a working +`support@` proves nothing about `abuse@`. Also confirm no bounce message comes +back for any of them. + +Then confirm Fastmail's own screen reports the domain as fully verified, +including DKIM. That screen is the authority on whether it can see the records. + +## 5. Recommended order, and why + +Do these in order. Each step is safe to leave in place before the next one +exists; the ordering exists so that no intermediate state is worse than the +current one. + +1. **`MX` first.** This is the step that puts delivery on the right path, and + the only one of the four that affects whether mail arrives at all. + ⚠️ **DNS alone does not restore delivery, and this runbook cannot tell you + when it does.** The `MX` records only hand the mail to Fastmail; Fastmail + then has to be willing to accept it, which means the domain added and + verified on their side and each of the four addresses existing there as a + real mailbox or alias. Neither half works without the other, and only the + second half is invisible to `dig`. Treat delivery as restored when a test + message actually lands — see section 4 — not when the records resolve. + Everything after this step is authentication and reputation: valuable, but + nobody's privacy request is lost while you configure it. +2. **`SPF` (`v=spf1`) next.** Cheap, one record, and it is what receivers check + first. Apply Trap 2 carefully here — this is the single step that can break + something already working (the Google verification). +3. **`DKIM` (the three grey `CNAME`s) before hardening DMARC.** Not the other + way around. Because SPF does not align for Fastmail's default return-path, + DKIM is the identifier DMARC will pass on. Publishing a strict policy while + DKIM is missing means your own outbound mail is what gets rejected. +4. **`DMARC` at `p=none` — and leave it there.** `p=none` is monitoring only: + it asks for reporting without instructing receivers to act. Only consider + `p=quarantine` and later `p=reject` after DKIM is verified in the Fastmail + UI and real messages are observed passing. Jumping to `p=quarantine` or + `p=reject` while authentication is incomplete does not block attackers, it + blocks **your own email** — including the App Review contact from the + escalation in section 1. + +Do not skip step 4's staging just because steps 1–3 look correct in `dig`. +`p=none` costs nothing to keep and is the only state that lets you see failures +before receivers start enforcing them. + +## Related, and explicitly out of scope + +All four addresses render as links with `class="text-violet-hot"`. On the light +theme (the default), `--violet-hot` (`#9d7aff`) as text on the `--cream` +background measures **2.97:1**, which is below WCAG AA — one of 10 measured +occurrences of that open defect. Fixing DNS does not affect it, and it is not +part of this runbook. Noted only so it is not mistaken for something this +change addressed. diff --git a/web/.impeccable.md b/web/.impeccable.md index 015b531..32e4ba2 100644 --- a/web/.impeccable.md +++ b/web/.impeccable.md @@ -2,7 +2,7 @@ This file is the single source of truth for design decisions on the FastShared marketing site (`web/`, deployed to `fastsha.red`). All `/impeccable` skills load this before doing any work. -Last updated: 2026-04-19 +Last updated: 2026-08-17 --- @@ -32,16 +32,20 @@ Last updated: 2026-04-19 ### Aesthetic Direction -**Theme: dark.** The product is consumed by macOS/iOS users who keep their OS in dark mode and their hero has to live next to a Slack window without burning their retinas. Dark is also where the violet accent and the ambient glow read most cinematically. Light mode for the marketing site is explicitly out of scope. +**Theme: two poles, light by default.** The site ships a neutral two-pole system. `:root` **is** the light theme and it is the default (`color-scheme: light`); dark is opt-in via `[data-theme="dark"]` on ``. There is a toggle in `src/components/Nav.astro` (~120–135), and a first-paint inline script in `src/layouts/Base.astro` (~46–59) resolves the initial pole from `localStorage['fs-theme']` (~48) first, falling back to `prefers-color-scheme` (~49) when nothing is stored. This replaced the earlier single dark ground (`--ink #070318` plus two radial glows). **Why the palette changed is not recorded here** — treat the published system as the spec and do not reconstruct a rationale for it. -**Palette (already committed in `tailwind.config.mjs` and `src/styles/global.css` — do not redefine, only consume):** +Measured on the published site (`pixel_audit.py`, 2026-08-16): dark renders at luminance 25.8, saturation 0.099, dominant `#111113` across 86% of the frame; light at luminance 238.2, saturation 0.045, dominant `#faf7ef` across 86%. Both poles are deliberately near-monochrome — those low saturation numbers are principle 2 showing up in pixels, not a deficiency to correct. -- **Background:** `--ink #070318` with two radial glows (deep purple `oklch(...)` at 18%/-10% and pink at 92%/18%) — establishes depth without gradient text or hero gradients on type. -- **Primary action:** `--violet-hot #9d7aff` — buttons, focus rings, the live ornaments inside the brand lockup. This is the only "loud" color and it must stay rare. -- **Secondary tints:** `--violet-soft #c1a9ff`, `--violet-fade #ff7ad1`, `--violet-dust #e0d4ff` — for hovers, halos, gradient SVG fills inside the Plane+Arc mark only. -- **Warm accent:** `--amber #ff9f47` and `--ember #ffc487` — section labels, step numbers (01/02/03), small icon tints. Amber is the "system text" color of the brand; violet is the "interactive" color. Do not blur this distinction. -- **Coral:** `--coral #ff4e7c` — used exactly once per page max, on the lifetime ribbon and on the "expired" Dynamic Island state. Coral signals "ending" and must keep that meaning. -- **Text:** `--milk #fafaff` (default), `--milk-dim`, `--milk-faint`, `--milk-ghost` for the descending hierarchy. Pure white is banned (already correct). +**Palette (committed in `src/styles/global.css` and `tailwind.config.mjs` — do not redefine, only consume).** Which tokens invert is the load-bearing distinction here, so it is stated explicitly rather than implied. **Inverting** (redefined under `[data-theme="dark"]`): the grounds and surfaces, the text tiers, their `-rgb` triplets, `--rule` / `--rule-soft`, `--shadow` / `--shadow-lg`, `--accent-surface`, the `--milk-dim` / `--milk-faint` / `--milk-ghost` alpha tiers (~97–99), and `color-scheme` itself (~65 light, ~101 dark). **Fixed in both poles** (declared once, never redefined): the whole accent family — `--violet-hot` and its tints, `--coral`, `--amber` / `--ember` / `--warning`, `--mint`, `--sky`, `--night`. A fixed accent is safe as a surface and dangerous as ink; that asymmetry is principle 6. + +- **Ground and surfaces:** `--cream` page ground (`#fbf8f1` light / `#0f0f12` dark), `--canvas` raised surface (`#ffffff` / `#1c1c22`), `--surface-warm` tinted band (`#f5f1e6` / `#1a1a1e`). There is no page-level gradient or glow: the only `radial-gradient` left anywhere in `src/` is the optional `ambient` halo on the brand mark itself (`PlaneArc.astro`, consumed on `/press` and `/pricing`). Do not reintroduce a background glow. +- **Text:** `--charcoal` primary (`#1d1d1f` / `#f0f0f5`), `--silver` secondary (`#6e6e73` / `#8a8a93`), `--tin` tertiary (`#a1a1a6` / `#6e6e73`). Pure white and pure black are both banned. +- **Legacy aliases:** `--ink → var(--cream)` and `--milk → var(--charcoal)` survive so existing markup keeps compiling — `text-milk` is still carrying 105 occurrences in `src/`. ⚠️ `--milk` no longer means `#fafaff`. It is now the *primary text* token and it **inverts**. Reading the old name and assuming the old value is the single easiest way to ship a light-on-light bug on this site. +- **Primary action:** `--violet-hot #9d7aff` — the one loud color, and the one token that does **not** invert. Correct as a surface, a rule, a ring, or an ornament in both poles. As **text on `--cream`** it is a known open defect — see Accessibility Baseline. It must stay rare. +- **Accent surface:** `--accent-surface` (`#3b1f86` light / `#9d7aff` dark) — inverts in lockstep with `--charcoal`. It exists because `.btn-primary:hover` animates its background off `--charcoal`, and a fixed violet there broke mid-ramp. See principle 6; that principle is the whole reason this token has a name. +- **Coral:** `--coral #ff4e7c` — unchanged, and still once per page max: the Early Access ribbon on `/pricing` and the expired/`410` state in the Dynamic Island. Coral signals "ending" and must keep that meaning. +- **Warm accent, now dormant:** `--amber #ff9f47` and `--ember #ffc487` are still declared and have **zero measured consumers** in `src/`. The roles this file used to assign them — mono section labels and the `01/02/03` step numbers — are `--violet-hot` on the published site. So the old rule ("amber is the system-text color, violet is the interactive color, do not blur this distinction") describes a system that is not shipping; it is recorded here as history, not as current law. Do not re-tint those labels amber to make it true again without an explicit decision — they are also the exact occurrences carrying the open contrast defect, so any fix there will touch them anyway. +- **Also declared, little or no measured use:** `--violet-soft`, `--violet-fade`, `--violet-dust` (zero measured consumers), plus `--mint`, `--sky`, `--night`. Live tokens are not a licence to spend them. **Typography (already loaded in `src/layouts/Base.astro`):** @@ -61,27 +65,34 @@ What we are emphatically taking from these: posture, restraint, and motion econo **Anti-references — explicitly avoid:** - The generic "indie Mac app" dark site with a centered hero, a gradient orb behind a feature grid, and a "built for the AI era" tagline. Looks the same on every YC batch page. -- Notion/Vercel marketing-template feel: rounded cards in a 3-column grid, soft shadows, Inter, faint border lines, gradient buttons. We share zero DNA with that look. -- Glass-everywhere SaaS dashboards. We have one frosted card in the hero and that is the entire glass budget for the site. +- Notion/Vercel marketing-template feel: rounded cards in a 3-column grid, soft shadows, Inter, faint border lines, gradient buttons. We share zero DNA with that look. ⚠️ Read this as an objection to the *composition*, not to its ingredients: the light pole legitimately uses soft shadows (`--shadow`, `--shadow-lg`) and hairline rules (`--rule`, 6% alpha), and the display stack falls back to `Inter Tight`. A shadow token is not the tell — the repeated card wall is. +- Glass-everywhere SaaS dashboards. The glass budget is one surface and it is already spent: the nav, which goes translucent on scroll (`.nav-scrolled` — `rgba(var(--cream-rgb), 0.72)` light, `0.65` dark, `blur(20px)`). Measured: `src/` has four `backdrop-filter` declarations across two rules, both on the nav — `.nav-scrolled` in `global.css` (~1014–1015) and `#main-nav.nav-on-dark` in `Nav.astro` (~78–79). The budget is one **live** surface: `.nav-on-dark` is **dead code**. The string `nav-on-dark` occurs exactly once in all of `src/` — its own selector at `Nav.astro:70` — nothing ever adds the class (`hero.ts` toggles `nav-scrolled`, which is a different rule), and it has zero applied occurrences in a built `dist/`. It is a leftover from the single-dark-pole era, and it is worth knowing about for one reason: it hardcodes `rgba(7, 3, 24, …)` and `#fafaff` (~71–76), the **retired** `--ink` and `--milk`, so those bytes still ship inside the inlined CSS even though the rule never activates. Deleting it is safe cleanup; it was left alone rather than removed on the pass that measured it. There is no frosted hero card any more. If you want glass somewhere new, something else gives it up. - Any AI-startup tropes: cyan-on-black, neon underglow, "agentic," "intelligence," typewriter-cursor heroes, gradient text on every other word. - Heavy feature grids with icon-over-heading cards repeated 6+ times. Our `ZeroFrictionSurface` is a 2×2 with intentional separators, not a feature wall. - Marketing analytics, cookie banners, intercom widgets, third-party chat. Nothing in the hot path. The README explicitly defers analytics; keep it that way. -**Motion direction.** CSS-only. The hero animates the Plane+Arc once on load (`pa-frame-in → pa-arc-draw → pa-plane-in → pa-particle`, ~1.5–2s total). The Dynamic Island mock cycles three states every 2s with a `cubic-bezier(0.16, 1, 0.3, 1)` cross-fade. Everything else is static. Hover transitions are 250ms on the same spring curve. `prefers-reduced-motion: reduce` freezes the Plane+Arc on its end frame and parks the Island on `completed`. Bounce, elastic, GSAP, Lottie — all banned. +**Motion direction.** CSS-only, and the budget is smaller than it used to be. Measured in `global.css` (~1022–1046): the brand mark reveals once on load with **two** keyframes — `pa-mark-in` (0.72s, opacity + scale + blur-off) and `pa-ambient-in` (0.9s, the halo scaling in) — both on `cubic-bezier(0.16, 1, 0.3, 1)`. The older four-step choreography (`pa-frame-in → pa-arc-draw → pa-plane-in → pa-particle`) no longer exists; do not reference it. The Dynamic Island mock cycles its three states every **4s** (`hero.ts`), and `.reveal` elements fade up once on intersection. Everything else is static. Hover transitions are ~180–250ms on the same spring curve. `prefers-reduced-motion: reduce` is honored globally (durations collapsed) and explicitly: the mark parks on its end frame, `.reveal` elements start visible, and the Island parks on `completed`. Bounce, elastic, GSAP, Lottie — all banned. ### Design Principles -These are the five rules every future change to `web/` must pass. If a proposed change violates one, it does not ship without an explicit decision to override. +These are the six rules every future change to `web/` must pass. If a proposed change violates one, it does not ship without an explicit decision to override. (1–5 predate the two-pole palette and were re-checked against it on 2026-08-17; principle 6 is what the two-pole system added.) 1. **Ephemerality is the brand, not a feature.** Anything new on the site — copy, motion, layout — should reinforce the idea that links die. Countdowns, decay, fade-outs, the Island lifecycle, expiry badges — these are all on-brand. Permanence cues (status pages, "join 10,000 users," "since 2020") are off-brand. 2. **Restraint over decoration.** The Plane+Arc mark and the violet-hot accent do the heavy lifting. New elements should subtract attention from them, not compete. If a new section needs three colors, a gradient, and a glow to feel right, the section is wrong, not the styling. -3. **Three typographic registers, no more.** Display Bricolage for headlines and answers. Mono JetBrains for labels, numbers, nav. Dim/faint milk for body. No serif, no script, no third weight system. Hierarchy comes from size and tracking, not from adding fonts. +3. **Three typographic registers, no more.** Display Bricolage for headlines and answers. Mono JetBrains for labels, numbers, nav. Body in the descending text tiers — `--charcoal` → `--silver` → `--tin` (this is the only part of the principle the palette change touched; it used to read "dim/faint milk"). No serif, no script, no third weight system. Hierarchy comes from size and tracking, not from adding fonts. + +4. **CSS-first motion, reduced-motion first-class.** No GSAP, no Lottie, no Framer Motion. Pure CSS animations + the single `hero.ts` island (84 lines as measured 2026-08-17, doing three jobs: scroll reveal, nav blur-on-scroll, Island state cycling) is the entire motion budget. Every animation needs a `@media (prefers-reduced-motion: reduce)` branch that lands on a meaningful frame. Intact under the two-pole palette — nothing here is theme-dependent. + +5. **Speed-first as a stated value.** No analytics, no cookie banner, no third-party widgets, no web fonts beyond the two already loaded, no JS framework runtime. The site is Astro static + minimal islands and must stay that way. If a future ask requires bundle weight, push back or ship it as a separate route that loads on intent. Intact — the theme toggle costs one inline script in `Base.astro` and a listener in `Nav.astro`, and that is the whole price of two poles. + +6. **Ink and surface invert together — and an animated surface can never carry fixed ink.** Two poles means every color decision is two decisions. Two failure modes, both already paid for once: -4. **CSS-first motion, reduced-motion first-class.** No GSAP, no Lottie, no Framer Motion. Pure CSS animations + the existing 43-line `hero.ts` for state cycling is the entire motion budget. Every animation needs a `@media (prefers-reduced-motion: reduce)` branch that lands on a meaningful frame. + - **Fixed ink beside an inverting surface.** Hardcoding a text, icon, or stroke color next to a token that flips (`--cream`, `--canvas`, `--surface-warm`, `--charcoal`) is a defect *even when it looks right in the pole you happened to be viewing*. It reads correctly in one theme and vanishes in the other. Use the paired token, or `rgba(var(---rgb), α)` so the alpha rides the inversion — `.nav-scrolled` is the reference implementation. The legitimate exception is a surface that is deliberately fixed in both poles: the Island's hardware pill is `#0d0d0d` on purpose, so the fixed light ink inside it is correct. If you claim that exception, the surface has to be fixed *by intent*, not by omission. + - **Animating a surface under fixed ink.** Worse, because it fails *mid-transition* rather than in one pole. When a background animates between two tokens, the ink cannot be fixed — **and cannot simply interpolate alongside it either**, because the ramp crosses the text color on the way, so there is a window where the two collide no matter what the two endpoints measure. The fix is never to re-tint the ink. **The surface is what has to invert.** This is exactly where `--accent-surface` came from: `.btn-primary:hover` ramps its background off `--charcoal`, and `--violet-hot` — which does not invert — took a dark-theme-correct surface to a light one while the label stayed light, measuring 2.97:1 at rest with a crossing ramp getting there. `--accent-surface` keeps both ends of the ramp on the same side of the luminance divide in each pole. -5. **Speed-first as a stated value.** No analytics, no cookie banner, no third-party widgets, no web fonts beyond the two already loaded, no JS framework runtime. The site is Astro static + minimal islands and must stay that way. If a future ask requires bundle weight, push back or ship it as a separate route that loads on intent. + Practical order of operations: pick the surface token first, take its paired ink token, then check both poles before calling it done. If a change needs a third color to survive the swap, the change is wrong, not the palette. ### Page-Specific Tone @@ -92,15 +103,18 @@ These are the five rules every future change to `web/` must pass. If a proposed ### Accessibility Baseline -- **WCAG 2.2 AA at minimum.** Text on `--ink` background uses `--milk` (`#fafaff`) which clears AA at all sizes. Dim/faint milk variants must stay above 4.5:1 for body text — verify before introducing new dim tiers. -- **Focus rings are violet-hot at 2px with a 2px offset.** Already correct in `global.css`. Never remove. +- **WCAG 2.2 AA at minimum, in both poles.** Body text is `--charcoal` on `--cream` — `#1d1d1f` on `#fbf8f1` light, `#f0f0f5` on `#0f0f12` dark — which clears AA at all sizes on both sides — measured 15.87:1 light. `--silver` and `--tin` are the descending tiers, and **only `--silver` is safe as body text**: measured on the shipped tokens, `--silver` is 4.78:1 on light `--cream`, but `--tin` is **2.43:1 light and 3.77:1 dark — it fails the 4.5:1 body floor in BOTH poles.** It is used as text today, at `global.css` (~1188 `.ft-k`, ~1191 `.ft-v.ft-off`) in the pricing feature table. **OPEN DEFECT, not fixed here.** Verify any new dim tier above 4.5:1 **in both themes**: a ratio that passes in dark can fail in light, and the token names no longer tell you which pole you are looking at. +- ⚠️ **OPEN DEFECT — `--violet-hot` as text fails AA in the light pole. This is not fixed.** Measured 2.97:1 against `--cream`, which is below the 4.5:1 body floor and below the 3:1 large-text floor. 10 occurrences were measured in the 2026-08-16 audit: `FLOW`, `01`/`02`/`03`, `APPLE-NATIVE`, `AGENTS + CLI`, `LIVE ACTIVITY`, `QUESTIONS`, and the `410 Gone`. Separately measured in `src/` on 2026-08-17: all **9** published `mailto:` links (`/privacy`, `/support`, `/terms`, `/press`) carry the same `text-violet-hot` class over the same cream ground, so the affected surface area is wider than the audit's region list. Do not record this as resolved anywhere — here, in a commit message, or in a skill report — until it is re-measured. The token itself is fine as a surface, a rule, a ring, or an ornament; the defect is specifically violet **ink** on the light ground. +- **Flagged, not measured:** that same 2.97:1 also sits just under the 3:1 non-text floor (WCAG 1.4.11) that the violet focus ring needs against `--cream`. Nobody has measured the ring per-surface — this is arithmetic on the text number, not a finding. Settle it when the defect above is settled. +- **Regional contrast, measured 2026-08-16** (standard deviation per region, floor 40): the hero passes in both poles — 43.3 dark, 42.5 light. The band (26.9) and the footer (29.5) do **not**. The falloff after the hero is real and open. Read it as flatness below the fold, a separate finding from the violet defect above, needing its own fix. +- **Focus rings are violet-hot at 2px, offset 4px.** Measured in `global.css` (~139–142): `outline: 2px solid var(--violet-hot); outline-offset: 4px; border-radius: 2px`. The earlier version of this file said "2px offset" — that was wrong, and the offset matters: at 4px the ring sits on the page ground rather than on the control, so the ratio that governs it is violet-hot against `--cream`. In the light pole that is **2.97:1**, under the 3:1 floor for non-text. Same arithmetic as the open defect below; do not treat the ring as exempt. Never remove the ring. - **`prefers-reduced-motion: reduce` is honored everywhere.** Plane+Arc skips animation, Island freezes on `completed`, hover transitions remain (they are sub-300ms and not vestibular). New animations must include a reduced-motion branch. - **Semantic HTML.** Section headings descend correctly (`h1` → `h2` → `h3`), nav uses `