diff --git a/docs/specs/website-docs.md b/docs/specs/website-docs.md index 78244022d..f312a7e42 100644 --- a/docs/specs/website-docs.md +++ b/docs/specs/website-docs.md @@ -106,6 +106,12 @@ Source of truth: `buildDocument` in `website/scripts/generate-docs.js`; `Markdow **Every in-site link spells that served path** — `sitePath` in components, the generator's rewrites in reference prose — so no reader lands on a redirect. Exempt: `/` with its anchors, and the `/docs` entrypoint, which names no page. `checkInSiteHrefsAreServed` pins it. +## Website fonts + +**Must serve website fonts from dormouse.sh.** **Must defer initial page content until its fonts finish loading, with a bounded fallback for unavailable fonts and readable pages without JavaScript.** This applies across the website, including prerendered routes and the SPA fallback. + +Source of truth: `Layout` in `website/src/root.tsx`. + ## Reference page chrome **Each page's `linkedFrom` names every document owing it a link** — the two READMEs and the homepage — so the obligation is registry-driven, never inferred from the path. `checkRoutesToReferences` reads it. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 991a2411b..1058e9759 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -751,6 +751,12 @@ importers: website: dependencies: + '@fontsource/ubuntu-mono': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/ubuntu-sans-mono': + specifier: ^5.3.0 + version: 5.3.0 '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -1714,6 +1720,12 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@fontsource/ubuntu-mono@5.3.0': + resolution: {integrity: sha512-hLwLkeAV9YvLSOWExn0heOXy+EB4wKnt648qS5FaqsiCZDS8X9kR/ueIBzzX/vNSXjFkhLRSTKqlOfFvHdD4Bw==} + + '@fontsource/ubuntu-sans-mono@5.3.0': + resolution: {integrity: sha512-7SGXQ43Q+gq7kSU2SMaHDBP5injQ07c0hYFsO5lwYT51WQ6qs2mO+lGfzb8Y6yTUZXuQFj2X4MyMZuvumu4TAw==} + '@grpc/grpc-js@1.14.4': resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} engines: {node: '>=12.10.0'} @@ -7067,15 +7079,9 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - pg-cloudflare@1.4.0: - resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} - pg-cloudflare@1.4.1: resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} - pg-connection-string@2.14.0: - resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} - pg-connection-string@2.14.1: resolution: {integrity: sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==} @@ -7088,9 +7094,6 @@ packages: peerDependencies: pg: '>=8.0' - pg-protocol@1.16.0: - resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} - pg-protocol@1.16.1: resolution: {integrity: sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==} @@ -7098,15 +7101,6 @@ packages: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.23.0: - resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} - engines: {node: '>= 16.0.0'} - peerDependencies: - pg-native: '>=3.0.1' - peerDependenciesMeta: - pg-native: - optional: true - pg@8.23.1: resolution: {integrity: sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==} engines: {node: '>= 16.0.0'} @@ -9334,6 +9328,10 @@ snapshots: '@floating-ui/utils@0.2.12': {} + '@fontsource/ubuntu-mono@5.3.0': {} + + '@fontsource/ubuntu-sans-mono@5.3.0': {} + '@grpc/grpc-js@1.14.4': dependencies: '@grpc/proto-loader': 0.8.1 @@ -14644,14 +14642,9 @@ snapshots: pend@1.2.0: {} - pg-cloudflare@1.4.0: - optional: true - pg-cloudflare@1.4.1: optional: true - pg-connection-string@2.14.0: {} - pg-connection-string@2.14.1: {} pg-int8@1.0.1: {} @@ -14660,8 +14653,6 @@ snapshots: dependencies: pg: 8.23.1 - pg-protocol@1.16.0: {} - pg-protocol@1.16.1: {} pg-types@2.2.0: @@ -14672,16 +14663,6 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.23.0: - dependencies: - pg-connection-string: 2.14.0 - pg-pool: 3.14.0(pg@8.23.1) - pg-protocol: 1.16.0 - pg-types: 2.2.0 - pgpass: 1.0.5 - optionalDependencies: - pg-cloudflare: 1.4.0 - pg@8.23.1: dependencies: pg-connection-string: 2.14.1 diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 12004733e..d21fe7e22 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -42,5 +42,5 @@ "docs/specs/tutorial.md": 2000, "docs/specs/vscode.md": 4450, "docs/specs/webgl-text.md": 800, - "docs/specs/website-docs.md": 2850 + "docs/specs/website-docs.md": 2900 } diff --git a/website/package.json b/website/package.json index 5fa254dd4..a2401d997 100644 --- a/website/package.json +++ b/website/package.json @@ -16,6 +16,8 @@ "test": "pnpm typecheck && vitest run" }, "dependencies": { + "@fontsource/ubuntu-mono": "^5.3.0", + "@fontsource/ubuntu-sans-mono": "^5.3.0", "@phosphor-icons/react": "^2.1.10", "ascii-splash": "0.6.1", "dormouse-lib": "workspace:*", diff --git a/website/src/index.css b/website/src/index.css index a8a5996c2..10cd217e0 100644 --- a/website/src/index.css +++ b/website/src/index.css @@ -1,4 +1,9 @@ @import "tailwindcss"; +/* Served from dormouse.sh, not Google Fonts, so a visit makes no third-party + request. Regular weight only, as the Google Fonts request was; each file + carries unicode-range subsets, so a page fetches only the scripts it uses. */ +@import "@fontsource/ubuntu-mono/400.css"; +@import "@fontsource/ubuntu-sans-mono/400.css"; /* Library components compile from source here (the `dormouse-lib` alias in vite.config.ts), so Tailwind must scan them or their utilities are never diff --git a/website/src/lib/font-ready.test.ts b/website/src/lib/font-ready.test.ts new file mode 100644 index 000000000..3f515c212 --- /dev/null +++ b/website/src/lib/font-ready.test.ts @@ -0,0 +1,79 @@ +import { runInNewContext } from "node:vm"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FONT_READY_SCRIPT } from "./font-ready"; + +function boot(fontsAvailable = true) { + vi.useFakeTimers(); + const attributes = new Map(); + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); + const ready = { promise, resolve, reject }; + const onReady = vi.fn(); + const layout = vi.fn(); + const document = { + documentElement: { + setAttribute: (key: string, value: string) => attributes.set(key, value), + removeAttribute: (key: string) => attributes.delete(key), + }, + body: { get offsetHeight() { layout(); return 100; } }, + fonts: fontsAvailable ? { + get ready() { + onReady(); + expect(layout).toHaveBeenCalledOnce(); + return ready.promise; + }, + } : undefined, + addEventListener: vi.fn(), + }; + runInNewContext(FONT_READY_SCRIPT, { document, setTimeout, clearTimeout }); + return { + hidden: () => attributes.has("data-fonts-pending"), + parsed: () => document.addEventListener.mock.calls[0][1](), + ready, + onReady, + }; +} + +afterEach(() => vi.useRealTimers()); + +describe("initial font paint", () => { + it("hides before parsing and reveals only after the page's font layout is ready", async () => { + const page = boot(); + expect(page.hidden()).toBe(true); + expect(page.onReady).not.toHaveBeenCalled(); + page.parsed(); + expect(page.hidden()).toBe(true); + page.ready.resolve(); + await page.ready.promise; + expect(page.hidden()).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it("reveals when fonts fail", async () => { + const page = boot(); + page.parsed(); + page.ready.reject(new Error("font unavailable")); + await Promise.resolve(); + expect(page.hidden()).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each([false, true])("bounds the wait even if parsing completed: %s", async (parsed) => { + const page = boot(); + if (parsed) page.parsed(); + vi.advanceTimersByTime(3000); + expect(page.hidden()).toBe(false); + // Late completion must not hide the page again. + if (!parsed) page.parsed(); + page.ready.resolve(); + await page.ready.promise; + expect(page.hidden()).toBe(false); + }); + + it("leaves content visible without the Font Loading API", () => { + const page = boot(false); + expect(page.hidden()).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/website/src/lib/font-ready.ts b/website/src/lib/font-ready.ts new file mode 100644 index 000000000..8884067e3 --- /dev/null +++ b/website/src/lib/font-ready.ts @@ -0,0 +1,24 @@ +/** + * Runs inline in the shared head, before prerendered content can paint; waiting + * in entry.client would let fallback text appear while the JS bundle downloads. + * Only JS-enabled visits get the attribute, so static/no-JS pages stay readable. + * Keep this independent of the application bundle and hydration. + */ +export const FONT_READY_STYLE = 'html[data-fonts-pending] body { visibility: hidden; }'; +export const FONT_READY_SCRIPT = `(() => { + if (!document.fonts) return; + const root = document.documentElement; + const reveal = () => { + root.removeAttribute('data-fonts-pending'); + clearTimeout(timeout); + }; + // A failed or stalled font request must never leave the site blank. + const timeout = setTimeout(reveal, 3000); + root.setAttribute('data-fonts-pending', ''); + document.addEventListener('DOMContentLoaded', () => { + // Discover the fonts used by the parsed page before reading ready. Hidden + // content still participates in layout and requests its font subsets. + void document.body.offsetHeight; + document.fonts.ready.then(reveal, reveal); + }, { once: true }); +})();`; diff --git a/website/src/pages/Privacy.tsx b/website/src/pages/Privacy.tsx index ec3d3beb0..8acbb5256 100644 --- a/website/src/pages/Privacy.tsx +++ b/website/src/pages/Privacy.tsx @@ -17,7 +17,7 @@ export const PRIVACY_SECTIONS: PolicySection[] = [

DiffPlug LLC operates Dormouse Hosted and is the controller of the personal information described here: the account service at hosted.dormouse.sh, the managed Relay and Pocket at relay.dormouse.sh, and managed voices at voice.dormouse.sh. This policy covers those services, the paid subscription that unlocks them, and related support. Contact us at support@dormouse.sh about privacy or your account.

Our business address is DiffPlug LLC, 447 Sutter St Ste 405, San Francisco, CA 94108, United States.

Dormouse itself runs on your computer and sends no usage telemetry. An update check fetches a version file from dormouse.sh without sending terminal contents or a Hosted account identifier. Automatic update checks run only if enabled. "Check now" makes a request when you click it. Creating an account does not upload your terminal contents, commands, files, or audio.

-

This website, dormouse.sh, is served by Cloudflare, which processes visitors’ IP addresses and request information to deliver it. The site currently loads fonts from Google Fonts, so Google receives your IP address and browser information when you visit.

+

This website, dormouse.sh, is served by Cloudflare, which processes visitors’ IP addresses and request information to deliver it.

This policy does not describe a Relay you operate yourself or third-party services you choose to open. Those services have their own operators and policies.

Hosted is not directed to children under 13, and we do not knowingly collect their personal information. If you believe a child under 13 has given us personal information, contact us and we will delete it.

}, diff --git a/website/src/root.tsx b/website/src/root.tsx index dc5408d72..1cdae74db 100644 --- a/website/src/root.tsx +++ b/website/src/root.tsx @@ -8,6 +8,9 @@ import { type MetaArgs, } from "react-router"; import { siteMeta } from "./lib/site-meta"; +import { FONT_READY_SCRIPT, FONT_READY_STYLE } from "./lib/font-ready"; +import bodyFont from "@fontsource/ubuntu-mono/files/ubuntu-mono-latin-400-normal.woff2?url"; +import displayFont from "@fontsource/ubuntu-sans-mono/files/ubuntu-sans-mono-latin-400-normal.woff2?url"; /** * Every page's title, description, canonical, and social cards. @@ -22,8 +25,9 @@ export function meta({ location }: MetaArgs) { } export function Layout({ children }: { children: ReactNode }) { + // The inline font gate can still own an html attribute when hydration starts. return ( - + @@ -32,11 +36,12 @@ export function Layout({ children }: { children: ReactNode }) { - - - + + + +