diff --git a/app/[...path]/route.ts b/app/[...path]/route.ts index 99ac094..aef4584 100644 --- a/app/[...path]/route.ts +++ b/app/[...path]/route.ts @@ -21,11 +21,13 @@ const PAGE = manPage({ title: "404 — justhtml.sh", bodyHtml: `

NOT FOUND

-
404 — there is nothing at this path.
+

404 — there is nothing at this path.

SEE ALSO

-
/              what justhtml.sh is
-/api/health    service + database health
+
+

/ — what justhtml.sh is

+

/api/health — service + database health

+
`, }); diff --git a/app/bookmarks/route.ts b/app/bookmarks/route.ts index 9764fed..f5a5646 100644 --- a/app/bookmarks/route.ts +++ b/app/bookmarks/route.ts @@ -19,12 +19,12 @@ export const dynamic = "force-dynamic"; const LIST_LIMIT = 500; -// Rows lay out as a flex line: the man-page `
` (title + dimmed tail) and a
+// Rows lay out as a flex line: the row's main text (title + dimmed tail) and a
 // quiet inline `remove` form sitting on the same baseline.
 const ROW_STYLE = `
 

fancy

`; + +function doc(html: string) { + return { id: 1, slug: "a-doc", html, is_public: true, title: null, version: 1 }; +} + +async function get(url: string) { + const res = await GET(new Request(url), { params: Promise.resolve({ slug: "a-doc" }) }); + return { status: res.status, html: await res.text() }; +} + +describe("GET /d/:slug/raw", () => { + beforeEach(() => { + mocks.findBySlug.mockReset(); + mocks.canViewSession.mockReset().mockResolvedValue(true); + mocks.getSession.mockReset().mockResolvedValue(null); + }); + + it("serves byte-pristine HTML on a direct fetch — no overlay, no injected font", async () => { + mocks.findBySlug.mockResolvedValue(doc(FONTLESS_DOC)); + const { status, html } = await get("https://justhtml.sh/d/a-doc/raw"); + expect(status).toBe(200); + expect(html).toBe(FONTLESS_DOC); + }); + + it("injects the default sans font for a fontless doc in the shell variant", async () => { + mocks.findBySlug.mockResolvedValue(doc(FONTLESS_DOC)); + const { html } = await get("https://justhtml.sh/d/a-doc/raw?overlay=1"); + expect(html).toContain('data-jh-default-font="1"'); + expect(html).toContain("-apple-system"); + expect(html.indexOf('data-jh-default-font')).toBeLessThan(html.toLowerCase().lastIndexOf("")); + }); + + it("leaves a doc that declares its own font untouched in the shell variant", async () => { + mocks.findBySlug.mockResolvedValue(doc(STYLED_DOC)); + const { html } = await get("https://justhtml.sh/d/a-doc/raw?overlay=1"); + expect(html).not.toContain("data-jh-default-font"); + expect(html).toContain("font-family:Georgia"); + }); +}); diff --git a/app/d/[slug]/raw/route.ts b/app/d/[slug]/raw/route.ts index 4f732da..370e4e1 100644 --- a/app/d/[slug]/raw/route.ts +++ b/app/d/[slug]/raw/route.ts @@ -3,6 +3,7 @@ import { canViewSession } from "@/lib/docs/access"; import { verifyViewCap } from "@/lib/docs/viewcap"; import { getSession } from "@/lib/auth/session"; import { OVERLAY_SCRIPT } from "@/lib/docs/overlay"; +import { fontFallbackStyle } from "@/lib/docs/font-fallback"; export const dynamic = "force-dynamic"; @@ -65,12 +66,16 @@ export async function GET(req: Request, ctx: Ctx): Promise { if (!(await canViewSession(doc, session, viewtoken))) return deny(404, "Not found."); } - // Inject the comment overlay only for the shell-embedded variant. We append the - // script before (or at the end if there's no body tag), leaving the - // user's HTML otherwise untouched. The CSP sandbox already permits scripts. + // Inject the comment overlay — and, for docs that set no font of their own, + // the site's default sans face (lib/docs/font-fallback.ts) — only for the + // shell-embedded variant. We append before (or at the end if there's + // no body tag), leaving the user's HTML otherwise untouched. The CSP sandbox + // already permits scripts. The font decision must be made on the pristine + // bytes: the overlay script itself mentions font-family. let html = doc.html; if (wantOverlay) { - const tag = ``; + const fontTag = fontFallbackStyle(html); + const tag = `${fontTag}`; const idx = html.toLowerCase().lastIndexOf(""); html = idx === -1 ? html + tag : html.slice(0, idx) + tag + html.slice(idx); } diff --git a/app/docs/route.ts b/app/docs/route.ts index 5e8359b..954b54d 100644 --- a/app/docs/route.ts +++ b/app/docs/route.ts @@ -107,14 +107,13 @@ function docRow(opts: { const vis = opts.isPublic ? "public" : "private"; // Comment count: a compact `Nc` suffix (variant C), only when there are any. const comments = opts.commentCount > 0 ? ` · ${opts.commentCount}c` : ""; - return `
${esc(label)}  ${esc(opts.access)} ${vis} · ${esc(fmtDate(opts.updatedAt))}${comments}
`; + return `
${esc(label)} ${esc(opts.access)} ${vis} · ${esc(fmtDate(opts.updatedAt))}${comments}
`; } function ownedSection(owned: DocListRow[]): string { if (owned.length === 0) { return `

YOUR DOCUMENTS

-
You don't own any documents yet. Your agent publishes them via the
-API (POST /api/v1/docs) using the key it holds — see /llms.txt.
`; +

You don't own any documents yet. Your agent publishes them via the API (POST /api/v1/docs) using the key it holds — see /llms.txt.

`; } const rows = owned.map((d) => docRow({ @@ -135,8 +134,7 @@ ${rows.join("\n")} function sharedSection(shared: SharedDocRow[]): string { if (shared.length === 0) { return `

SHARED WITH YOU

-
Nothing is shared with you yet. When someone grants your email (or
-your email's domain) access to a doc, it shows up here.
`; +

Nothing is shared with you yet. When someone grants your email (or your email's domain) access to a doc, it shows up here.

`; } const rows = shared.map((d) => docRow({ @@ -205,7 +203,7 @@ export async function GET(req: Request): Promise { const sections: string[] = []; sections.push(VARIANT_C_STYLE); sections.push( - `
Signed in as ${esc(email)}. See bookmarks.
` + `

Signed in as ${esc(email)}. See bookmarks.

` ); if (hasAccount) { sections.push(ownedSection(owned)); diff --git a/app/layout.tsx b/app/layout.tsx index 16ef6e0..1e45c06 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,7 +7,8 @@ import type { ReactNode } from "react"; // not add a React runtime to the man-page pages (homepage, /auth.md, /llms.txt, // /raw, the JSON discovery docs, the form POSTs). It exists solely so the two // designated React surfaces have a document shell. Styling is kept consistent with -// the man-page brand: monospace, always light mode (httpbingo.org vibe). +// the man-page brand: the system sans reading face (SANS in lib/page.ts), +// always light mode. export const metadata = { title: "justhtml.sh", }; @@ -23,7 +24,7 @@ export default function RootLayout({ children }: { children: ReactNode }) { __html: `:root{color-scheme:light} *{box-sizing:border-box} html,body{margin:0} -body{font-family:ui-monospace,"SF Mono",Menlo,Consolas,"Courier New",monospace;color:#111;background:#fff} +body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;color:#111;background:#fff} a{color:#0000ee}`, }} /> diff --git a/app/login/route.ts b/app/login/route.ts index cdac393..9c165d6 100644 --- a/app/login/route.ts +++ b/app/login/route.ts @@ -19,18 +19,14 @@ const EXPIRY_MIN = LOGIN_TOKEN_TTL_MIN; // claim ceremony — ONE flow"). We avoid the earlier "this never creates an // account" phrasing the founder found confusing; we just say what it does. function introCopy(): string { - return `Enter your email and we'll send a single-use link that signs you -in on this device. No password. - -Sign-up is agent-only: an account is created when your agent signs -you up via /auth.md. Use this to get back to docs you own or -that were shared with you.`; + return `

Enter your email and we'll send a single-use link that signs you in on this device. No password.

+

Sign-up is agent-only: an account is created when your agent signs you up via /auth.md. Use this to get back to docs you own or that were shared with you.

`; } // Variant A chrome with the LOCKED /login spacing fix: a clear vertical gap // between the email input and the "send login link" button (birthday.md: -// "add vertical space between the email input and the … button"). The blank -//
 line inside the form supplies that gap in the monospace column.
+// "add vertical space between the email input and the … button"), supplied by
+// the submit row's top margin.
 function loginForm(next: string, opts: { error?: string } = {}): string {
   const errBlock = opts.error
     ? `

${esc(opts.error)}

\n` @@ -39,16 +35,14 @@ function loginForm(next: string, opts: { error?: string } = {}): string { title: "justhtml.sh — login", bodyHtml: `

SIGN IN

-
${introCopy()}
+
${introCopy()}

EMAIL

${errBlock}
-
email: 
+

email:

-
-
-       
+

`, @@ -62,24 +56,17 @@ ${errBlock}
function dashboardPage(email: string, hasAccount: boolean): string { const body = hasAccount ? `

SIGNED IN

-
You're signed in as ${esc(email)}, and you have a justhtml.sh
-account. See your documents — owned and shared with you.
-
-Your agent publishes and manages docs with the key it received when
-it signed you up. Private docs you own (and any shared with your
-email) render right in the browser while you're signed in.
-
-See /llms.txt and /api/spec.yaml for the full API.
` +
+

You're signed in as ${esc(email)}, and you have a justhtml.sh account. See your documents — owned and shared with you.

+

Your agent publishes and manages docs with the key it received when it signed you up. Private docs you own (and any shared with your email) render right in the browser while you're signed in.

+

See /llms.txt and /api/spec.yaml for the full API.

+
` : `

NO ACCOUNT YET

-
You're signed in as ${esc(email)}, but you don't have a
-justhtml.sh account yet.
-
-Sign-up is agent-only — tell your agent to sign up at
-justhtml.sh/auth.md. It registers with this email; we email you a
-6-digit code; you read the code back to your agent. You'll end up
-with an account and your agent will hold the API key.
-
-Anything shared with your email shows up under your documents.
`; +
+

You're signed in as ${esc(email)}, but you don't have a justhtml.sh account yet.

+

Sign-up is agent-only — tell your agent to sign up at justhtml.sh/auth.md. It registers with this email; we email you a 6-digit code; you read the code back to your agent. You'll end up with an account and your agent will hold the API key.

+

Anything shared with your email shows up under your documents.

+
`; return manPage({ title: "justhtml.sh — account", bodyHtml: body, @@ -91,13 +78,11 @@ function checkEmailPage(): string { title: "justhtml.sh — check your email", bodyHtml: `

CHECK YOUR EMAIL

-
If that address can sign in, a login link is on its way.
-
-The link is single-use and expires in ${EXPIRY_MIN} minutes. Click it on
-this device to sign in.
-
-Didn't get it? It may be filtered, or the address may be one we
-don't send to. You can request another.
+
+

If that address can sign in, a login link is on its way.

+

The link is single-use and expires in ${EXPIRY_MIN} minutes. Click it on this device to sign in.

+

Didn't get it? It may be filtered, or the address may be one we don't send to. You can request another.

+
`, }); } @@ -153,7 +138,7 @@ export async function POST(req: Request): Promise { manPage({ title: "justhtml.sh — slow down", bodyHtml: `

TOO MANY REQUESTS

-
Too many login links requested. Try again in about ${mins} minute(s).
`, +

Too many login links requested. Try again in about ${mins} minute(s).

`, }), { status: 429, headers: { "Retry-After": String(tripped.retryAfter) } } ); @@ -183,7 +168,7 @@ export async function POST(req: Request): Promise { manPage({ title: "justhtml.sh — email failed", bodyHtml: `

COULDN'T SEND

-
We couldn't send the login email just now. Please try again.
`, +

We couldn't send the login email just now. Please try again.

`, }), { status: 500 } ); diff --git a/app/route.ts b/app/route.ts index b574b65..49056b5 100644 --- a/app/route.ts +++ b/app/route.ts @@ -38,18 +38,19 @@ export function GET() { margin: 0 auto; padding: 2rem 1.5rem 3rem; max-width: 760px; - font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Courier New", monospace; - font-size: 14px; - line-height: 1.55; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 16px; + line-height: 1.6; color: #111; background: #fff; } .headline { font-weight: 700; margin: 0 0 1.5rem; } h2 { - font-size: 14px; font-weight: 700; margin: 1.6rem 0 0.3rem; - text-transform: uppercase; letter-spacing: 0.03em; + font-size: 14px; font-weight: 700; margin: 1.6rem 0 0.4rem; + text-transform: uppercase; letter-spacing: 0.05em; } - pre { white-space: pre-wrap; margin: 0; } + p { margin: 0 0 0.4rem; } + pre { white-space: pre-wrap; margin: 0; font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Courier New", monospace; font-size: 14px; } .body { padding-left: 3.5ch; } a { color: #0000ee; } .promptwrap { position: relative; } @@ -73,13 +74,14 @@ export function GET() { .copy svg { width: 13px; height: 13px; } .hint { color: #666; margin: 0 0 0.5rem; } footer { margin-top: 2.5rem; color: #666; } + footer .foot { display: flex; justify-content: space-between; gap: 1rem; } -
JUSTHTML.SH(1)
+
JUSTHTML.SH(1)

NAME

-
justhtml.sh — a place for agents to publish and discuss HTML documents
+

justhtml.sh — a place for agents to publish and discuss HTML documents

SYNOPSIS

@@ -96,21 +98,19 @@ ${COPY_BTN}

DESCRIPTION

-
The agent self-onboards, gets an API key, and publishes HTML to stable
-URLs like https://justhtml.sh/d/fierce-tiger-12345. Once published,
-documents can be edited and commented on, by you (via the web UI) or an
-agent (via API). Docs are private by default. There is no build step, no
-framework — the document you publish is the document people see.
+

The agent self-onboards, gets an API key, and publishes HTML to stable URLs like https://justhtml.sh/d/fierce-tiger-12345. Once published, documents can be edited and commented on, by you (via the web UI) or an agent (via API). Docs are private by default. There is no build step, no framework — the document you publish is the document people see.

SEE ALSO

-
/auth.md        how agents sign up + authenticate
-/llms.txt       agent-facing usage
-/openapi.json   OpenAPI spec
-/docs           your documents
-/bookmarks      bookmarked docs
-github          source
+
+

/auth.md — how agents sign up + authenticate

+

/llms.txt — agent-facing usage

+

/openapi.json — OpenAPI spec

+

/docs — your documents

+

/bookmarks — bookmarked docs

+

github — source

+
-
justhtml.sh                      2026-06-13                      JUSTHTML.SH(1)
+
justhtml.sh2026-06-13JUSTHTML.SH(1)
`, }); @@ -57,14 +56,12 @@ export function confirmSignInPage(token: string, next: string): string { title: "justhtml.sh — confirm sign-in", bodyHtml: `

ALMOST THERE

-
Tap the button to finish signing in on this device. This link is
-single-use — if it's expired or already used you'll be asked to
-request a new one.
+

Tap the button to finish signing in on this device. This link is single-use — if it's expired or already used you'll be asked to request a new one.

-
+

`, }); @@ -86,14 +83,14 @@ export function deadLinkPage(next?: string): string { title: "justhtml.sh — link expired", bodyHtml: `

LINK EXPIRED OR USED

-
This login link is expired or already used.
-
-Request a new one at justhtml.sh/login${
+
+

This login link is expired or already used.

+

Request a new one at justhtml.sh/login${ dest - ? ` — signing in again -will take you straight to where this link was headed.` + ? ` — signing in again will take you straight to where this link was headed.` : "." - }

+ }

+ `, }); } diff --git a/lib/docs/bookmarks-view.ts b/lib/docs/bookmarks-view.ts index ff6ab9c..fb9193f 100644 --- a/lib/docs/bookmarks-view.ts +++ b/lib/docs/bookmarks-view.ts @@ -42,14 +42,14 @@ export function bookmarkRow(m: RowModel): string { const label = m.title && m.title.trim() ? m.title : m.slug; if (revoked) { - return `
${esc(label)}  revoked · ${esc(fmtDate(m.bookmarkedAt))}
${removeForm(m.docId)}
`; + return `
${esc(label)} revoked · ${esc(fmtDate(m.bookmarkedAt))}
${removeForm(m.docId)}
`; } const href = m.token ? `/d/${encodeURIComponent(m.slug)}?viewtoken=${encodeURIComponent(m.token)}` : `/d/${encodeURIComponent(m.slug)}`; const vis = m.isPublic ? "public" : "private"; - return `
${esc(label)}  ${esc(m.access)} ${vis} · ${esc(fmtDate(m.bookmarkedAt))}
${removeForm(m.docId)}
`; + return `
${esc(label)} ${esc(m.access)} ${vis} · ${esc(fmtDate(m.bookmarkedAt))}
${removeForm(m.docId)}
`; } // Inputs for re-resolving a bookmarked doc's access at read time, reduced to diff --git a/lib/docs/font-fallback.test.ts b/lib/docs/font-fallback.test.ts new file mode 100644 index 0000000..595eadc --- /dev/null +++ b/lib/docs/font-fallback.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { fontFallbackStyle } from "./font-fallback"; + +describe("fontFallbackStyle", () => { + it("returns the sans fallback for a doc with no styling", () => { + const tag = fontFallbackStyle("

hi

plain doc

"); + expect(tag).toContain("data-jh-default-font"); + expect(tag).toContain("-apple-system"); + expect(tag).toContain("ui-monospace"); + }); + + it("returns nothing when the doc declares a font-family", () => { + expect( + fontFallbackStyle(`

x

`) + ).toBe(""); + }); + + it("returns nothing when the doc declares a font shorthand", () => { + expect(fontFallbackStyle(``)).toBe(""); + }); + + it("returns nothing when the doc loads a webfont", () => { + expect(fontFallbackStyle(``)).toBe(""); + }); + + it("ignores unrelated CSS — a doc styled in every other way still gets the fallback", () => { + expect(fontFallbackStyle(``)).not.toBe(""); + }); + + it("is case-insensitive about author font rules", () => { + expect(fontFallbackStyle(``)).toBe(""); + }); + + it("detects a font set in an inline style attribute", () => { + expect(fontFallbackStyle(`x`)).toBe(""); + expect(fontFallbackStyle(`

x

`)).toBe(""); + }); + + it("ignores font-family mentioned in prose or code samples", () => { + expect( + fontFallbackStyle(`

This doc sets no font-family at all.

body { font-family: serif }
`) + ).not.toBe(""); + }); + + it("ignores non-font rules in a style block", () => { + expect(fontFallbackStyle(`

x

`)).not.toBe(""); + }); +}); diff --git a/lib/docs/font-fallback.ts b/lib/docs/font-fallback.ts new file mode 100644 index 0000000..aa9e8b7 --- /dev/null +++ b/lib/docs/font-fallback.ts @@ -0,0 +1,27 @@ +import { SANS, MONO } from "@/lib/page"; + +// The viewer renders each doc's own HTML in a sandboxed iframe, so a doc's +// typeface is whatever its author chose. A doc that sets no font at all falls +// back to the browser default — a serif — which reads badly next to the site's +// sans chrome. When the shell-embedded render (the /d/:slug viewer) serves a +// doc with no font declaration of its own, we inject the site-wide system sans +// stack as the default instead. Docs that set any font rule are untouched, and +// because the injected rules use :where() (zero specificity) any author CSS +// would win anyway. Direct /raw fetches stay byte-pristine — like the comment +// overlay, this only rides the shell variant. + +// An author font rule counts only in real CSS contexts: inside a `; +} diff --git a/lib/docs/overlay.ts b/lib/docs/overlay.ts index 9ac50dc..2a9c5b8 100644 --- a/lib/docs/overlay.ts +++ b/lib/docs/overlay.ts @@ -425,7 +425,7 @@ export const OVERLAY_SCRIPT = String.raw` + "html.jh-dark span[data-jh-seg].jh-focus{background:rgba(245,197,24,.2);box-shadow:0 0 0 1px rgba(245,197,24,.85)}" + "span[data-jh-chip]{display:inline-flex;align-items:center;gap:2px;font-size:11.5px;line-height:1;" + "background:#fbfbfb;border:1px solid #e0e0e0;border-radius:10px;padding:1px 6px 1px 5px;margin-left:4px;" - + "vertical-align:.12em;font-family:ui-monospace,Menlo,Consolas,monospace;cursor:pointer;user-select:none;" + + "vertical-align:.12em;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;cursor:pointer;user-select:none;" + "white-space:nowrap;transition:border-color .1s,background .1s,opacity .12s}" + "span[data-jh-chip]:hover{border-color:#bbb;background:#fff}" + "span[data-jh-chip].mine{border-color:#9db8d8;background:#eef3fb}" @@ -441,7 +441,7 @@ export const OVERLAY_SCRIPT = String.raw` + "html.jh-dark span[data-jh-chip].mine .jh-ct{color:#9db8d8}" + ".jh-pop{position:fixed;display:none;background:#fff;border:1px solid #ccc;border-radius:6px;" + "box-shadow:0 4px 16px rgba(0,0,0,.18);z-index:2147483647;padding:6px 8px;font-size:11px;max-width:280px;" - + "font-family:ui-monospace,Menlo,Consolas,monospace;color:#222}" + + "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:#222}" + ".jh-pop .jh-row{display:flex;align-items:center;gap:6px;padding:3px 4px;border-radius:4px}" + ".jh-pick .jh-row{cursor:pointer}" + ".jh-pick .jh-row:hover{background:#f3f3f3}" diff --git a/lib/page.ts b/lib/page.ts index 8a5c594..046e7ea 100644 --- a/lib/page.ts +++ b/lib/page.ts @@ -1,12 +1,23 @@ -// Shared man-page-style HTML shell. Monospace, zero JS, zero framework CSS. +// Shared man-page-style HTML shell. Zero JS, zero framework CSS. // httpbingo.org / Unix man-page vibe. This is the brand. // // Chrome system: variant A, LOCKED 2026-06-13 (birthday.md "Site-wide redesign -// decisions"). A SINGLE uppercase `JUSTHTML.SH(1)` headline — no top bar, no -// `GENERAL` center label, no doubled title. Left-aligned uppercase

section -// heads with hanging-indent (3.5ch) bodies, and ONE quiet justified footer line -// (`justhtml.sh … … JUSTHTML.SH(1)`). Always light, monospace. Matches -// the shipped homepage chrome (app/route.ts, variant B) verbatim. +// decisions"; typography updated 2026-10-28). A SINGLE uppercase +// `JUSTHTML.SH(1)` headline — no top bar, no `GENERAL` center label, no +// doubled title. Left-aligned uppercase

section heads with hanging-indent +// (3.5ch) bodies, and ONE quiet justified footer line +// (`justhtml.sh … … JUSTHTML.SH(1)`). Always light. +// +// Typography: the default reading font is the tried-and-tested system UI stack +// (the GitHub/Bootstrap lineage) — native SF on macOS/iOS, Segoe UI on Windows, +// Roboto on Android — nothing to download, and the most readable option on +// both desktop and mobile. Monospace stays where it means something: prompt +// boxes, inline code, diffs. Prose lives in

;

 is code.
+
+// The site-wide default reading face.
+export const SANS = `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`;
+// Code only: prompts, inline code, diffs.
+export const MONO = `ui-monospace, "SF Mono", Menlo, Consolas, "Courier New", monospace`;
 
 const STYLE = `
   :root { color-scheme: light; }
@@ -14,42 +25,38 @@ const STYLE = `
   body {
     margin: 0 auto;
     padding: 2rem 1.5rem 3rem;
-    font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Courier New", monospace;
-    font-size: 14px;
-    line-height: 1.55;
+    font-family: ${SANS};
+    font-size: 16px;
+    line-height: 1.6;
     color: #111;
     background: #fff;
     max-width: 760px;
   }
   .headline { font-weight: 700; margin: 0 0 1.5rem; }
   h1, h2 {
-    font-size: 14px; font-weight: 700; margin: 1.6rem 0 0.3rem;
-    text-transform: uppercase; letter-spacing: 0.03em;
+    font-size: 14px; font-weight: 700; margin: 1.6rem 0 0.4rem;
+    text-transform: uppercase; letter-spacing: 0.05em;
   }
-  pre { white-space: pre-wrap; margin: 0; }
+  p { margin: 0 0 0.4rem; }
+  pre { white-space: pre-wrap; margin: 0; font-family: ${MONO}; font-size: 14px; }
   .body { padding-left: 3.5ch; }
   a { color: #0000ee; }
   .hint { color: #666; margin: 0 0 0.5rem; }
   .err { color: #b00020; margin: 0 0 0.5rem; }
-  code { background: rgba(127,127,127,0.12); padding: 0.05rem 0.3rem; border-radius: 3px; }
+  code { font-family: ${MONO}; font-size: 0.9em; background: rgba(127,127,127,0.12); padding: 0.05rem 0.3rem; border-radius: 3px; }
   input, button { font: inherit; }
   input[type=email] { padding: 2px 6px; border: 1px solid #999; background: #fff; color: #111; }
   button { padding: 2px 12px; border: 1px solid #999; background: #f6f6f6; color: #111; cursor: pointer; border-radius: 3px; }
   button:hover { background: #ededed; }
   footer { margin-top: 2.5rem; color: #666; }
+  footer .foot { display: flex; justify-content: space-between; gap: 1rem; }
 `;
 
-// One quiet justified footer line: `justhtml.sh …  … JUSTHTML.SH(1)`.
-// Spacing is in characters so it reads true in the monospace column. Mirrors the
-// homepage footer (which hardcodes the same shape at the design's wrap width).
-function footerLine(date: string): string {
-  const left = "justhtml.sh";
-  const right = "JUSTHTML.SH(1)";
-  const width = 78;
-  const gaps = Math.max(2, width - left.length - right.length - date.length);
-  const l = Math.ceil(gaps / 2);
-  const r = gaps - l;
-  return `${left}${" ".repeat(l)}${date}${" ".repeat(r)}${right}`;
+// One quiet justified footer line: `justhtml.sh …  … JUSTHTML.SH(1)`,
+// laid out with flexbox space-between so it stays flush at any wrap width.
+// Mirrors the homepage footer.
+function footerRow(date: string): string {
+  return `
justhtml.sh${date}JUSTHTML.SH(1)
`; } export function manPage(opts: { @@ -67,9 +74,9 @@ export function manPage(opts: { -
JUSTHTML.SH(1)
+
JUSTHTML.SH(1)
${opts.bodyHtml} -
${footerLine(date)}
+ `; }