mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-10 15:09:00 +02:00
* feat(aside): browser-driver contract, cookbook, research and fallback resolvers
{{ASIDE_SETUP}} (readiness probe + ten rules for driving the user's real browser), {{ASIDE_COOKBOOK}} (script shapes verified live against Aside CLI 1.26: one flow per aside repl script, CDP console hook before navigation, evidence lines, session-directory artifact handoff, GSTACK_STEP_OK sentinel), {{ASIDE_RESEARCH}} (research through aside exec, WebSearch when Aside is absent, knowledge otherwise) and {{BROWSE_FALLBACK}} (the fifteen-row Aside-step to $B-command table plus the rules that differ, so every browsing skill keeps working on gstack's own headless browser). test/aside-driver.test.ts pins the sentences and asserts every browsing skill carries the Aside block followed by the fallback; test/helpers/aside-available.ts is the shared live-Aside probe.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(render): Aside-first local-HTML renderer with the bundled browser as fallback
lib/aside-render.ts serves the HTML's directory on loopback (Aside refuses file:// URLs), opens it with waitUntil load, prints through CDP Page.printToPDF so tagged output, outlines, header/footer templates and page numbers survive, emulates device metrics for sized screenshots, and writes in-page evaluations to files; when Aside is absent it runs the same spec through the browse daemon (newtab, load, js, pdf, screenshot, closetab) and reports ENGINE=aside|browse. bin/gstack-render.ts is the CLI skill templates call. lib/claude-bin.ts and lib/error-handling.ts become the canonical copies (browse/src re-exports them).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(browse): /browse drives Aside first, with the $B reference behind the fallback
Contract, cookbook, mode choice (aside repl by default, aside exec for reading), report format, the fallback section, and the full command reference carved on demand.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(qa): /qa and /qa-only drive Aside, fall back to $B
QA_METHODOLOGY runs every phase as Aside scripts (orient, explore, document, re-test, mobile viewport via CDP emulation, links via HEAD fetch); the authenticate phase is 'you are already signed in'; a 13th rule requires consent before mutating actions on non-local targets; the fallback section translates each step onto $B. The qa E2E tests run on whichever engine is present.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(design): design-review, design-consultation, design-shotgun, plan-design-review, design-html drive Aside
Design-system extraction is one script printing FONTS/COLORS/HEADINGS/TOUCH_TARGETS/NAV; competitor research confirms the exact URLs before opening them in the real browser and runs on the bundled browser when Aside is absent; design-html's viewport screenshots, sketches and comparison boards render through gstack-render.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(deploy): benchmark, canary, land-and-deploy Step 7, devex-review drive Aside
One aside repl script per page prints NAV/PAINT/LCP/RESOURCES/SCRIPTS/CSS/SUMMARY (benchmark), CONSOLE_ERRORS/NAV/TEXT + screenshot (canary, re-run every 60s), and the post-deploy check reads responseStatus from the navigation entry; each carries the $B fallback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(third-party-actions): Aside is the recommended driver; gstack's visible browser stays the fallback
The readiness probe is lifted from {{ASIDE_SETUP}} at gen time (byte-identity pinned) and rule 3 points at browse/SKILL.md for how to drive; the consent question offers Aside first and gstack's own visible browser (handoff/resume for sign-in) as the fallback, as v1.72 framed it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(scrape): /scrape reads pages through Aside; the browser-skills runtime rides the fallback
Look-then-extract scripts build the JSON inside the page and print it between JSON_START/JSON_END; aside exec for fuzzy intents; on the $B fallback the browser-skills match/prototype flow and /skillify apply as before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(make-pdf): print through Aside first, the bundled browser otherwise
asideClient.ts replaces the direct $B client with one render() call per PDF (the exact option mapping the browse pdf command had: paper, margins, header/footer/page numbers, tagged, outline, printBackground, preferCSSPageSize, Paged.js wait); the diagram pre-pass, oversized-image downscale and DOCX rasters each run as one render script with per-fence try/catch; exit 4 now means no browser is available and names both remedies; $P setup reports which engine it found. The e2e gates run on whichever engine is present, so the Linux lane exercises the fallback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(diagram): the triplet is one gstack-render call
SVG, PNG and excalidraw from one invocation over the content-addressed bundle staged under /tmp/gstack-render; every diagram type gets an excalidraw export; gstack-render picks the engine and prints ENGINE=; the diagram E2E gates on either engine.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(research): web research runs in Aside first, WebSearch second
The planning, review, design, security and investigate skills research through {{ASIDE_RESEARCH}}; WebSearch stays in allowed-tools as the fallback; testing.ts's bootstrap step follows; skeleton ceilings ratcheted for the research block.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(setup,gen-skill-docs): prune renders of skills that no longer exist
setup gains _prune_stale_generated for every host tree and the doc generator removes gstack-* output dirs it did not write, so a skill removed from the source tree can never linger in an install.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test: registries, budgets and suite reconciled for Aside-first with the $B fallback
Touchfiles + E2E tiers gain the Aside keys, coverage matrix and eval baselines updated, size budget re-baselined to parity-baseline-v1.80.0.0.json (the contract plus fallback ride in every browsing skill), parity ceilings ratcheted with measured values, LLM-judge prompts and the E2E fixtures speak Aside-first, browse-fallback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs: Aside first, gstack browser fallback
README, BROWSER.md, docs/, CONTRIBUTING, CLAUDE.md, ARCHITECTURE, AGENTS.md, TODOS and the root router describe the one product story: Aside is the browser gstack drives first; the bundled headless browser is the automatic fallback (Linux, Windows, app closed) where cookie import, GStack Browser, pair-agent and browser-skills still apply.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* chore: regenerate SKILL.md docs, llms.txt, agents digest, ship goldens, context-budget fixture
bun run gen:skill-docs over the templates; goldens re-rendered; context-budget ceilings recaptured.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* v1.80.0.0: Aside is the browser gstack drives first; the bundled browser is the fallback
MINOR: new capability across ten skills, the renderer and research; nothing removed. CHANGELOG release summary + itemized changes; VERSION 1.80.0.0; package.json 1.80.0.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs(todos): file non-Claude host ownership-gate and version-heading pin follow-ups
Two follow-ups from the /plan-ceo-review + /plan-eng-review pass on merging
PR #2804 with main's v1.80.0.0 ownership gate: bring the Codex/Factory/
OpenCode/Cursor/Kiro copy loops and the stale-render prune under the
.gstack-owned marker rule, and a free test pinning that the CHANGELOG top
heading equals VERSION (the collision that git cannot see).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: pre-landing review fixes for the Aside-first branch
Review army + adversarial passes (Claude and Codex) on the merged branch:
setup
- _prune_stale_generated scans the host dirs too (the generator already
removed the render before setup ran, so the host branch was dead), skips
symlinks in the render tree (rm -rf on a slash-terminated link empties its
target), removes a host symlink only when it resolves into gstack, cleans a
bannered real dir through _cleanup_weak_dir, recognizes frontmatter-renamed
skills, and logs through log. The always-run codex render passes every host
dir that may link to it.
- NEEDS_BUILD checks all three binaries (with $_EXE) and lib/ sources; the
browser hint and the bootstrap summary honor GSTACK_SKIP_ASIDE, treat a
requested skip as a request, and derive one skill list.
lib/aside-render.ts + bin/gstack-render.ts
- The loopback server carries a per-render secret path, checks containment on
the real path (symlink escapes are 403), and rejects malformed encoding.
- Inline eval results are one base64 line, so page text cannot forge
ASIDE_DIR= or the sentinel; the last ASIDE_DIR wins.
- runProc escalates SIGTERM to SIGKILL, bounds every wait, and clears every
timer (an uncleared one kept gstack-render alive after printing OK).
- renderTmpDir refuses a shared /tmp name owned by someone else; the work dir
and server are created inside try; goto's budget follows the render budget.
- probeAside classifies a present-but-failing CLI as ASIDE_NOT_RUNNING like
the skills' bash probe; render() retries on gstack's own browser when Aside
could not start or its private CDP bridge is gone (never on a page error
or a timeout of a running script); the CLI reports the engine that actually
rendered, exits 0 on --help, rejects non-numeric flags, documents
--wait-timeout, fences EVAL/PAGE_ERRORS as untrusted content, and names the
daemon's cookie-import JS lock remedy.
- The browse path passes --scale only when asked (a scale change rebuilds
the daemon context) and restores the viewport after a sized screenshot.
resolvers / templates
- The bash probe honors GSTACK_SKIP_ASIDE and has a perl deadline on stock
macOS; .local is no longer LOCAL (mDNS); same-origin filters compare parsed
origins; link status is HEAD-checked only on LOCAL targets; every
aside exec goes through the receipted _aside_exec prelude
({{ASIDE_EXEC_PRELUDE}}), including nine template blocks that called it
bare; the design sketch and diagram staging use private directories.
- The generator prunes only bannered renders and never a host whose
generation failed.
Docs, stale comments and dead code cleaned; goldens re-rendered; tests
updated and added for every behavior above.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test: coverage for the render CLI, setup rebuild check, make-pdf exit codes, and prose $B spans
New free tests from the ship coverage audit: test/gstack-render-cli.test.ts
(argv guards, --help, output contract with a fake daemon, failure and
serve-root paths, no-browser case, prompt exit), test/setup-needs-build.test.ts
(every binary and source set flips NEEDS_BUILD, Windows suffixes),
make-pdf/test/cli-exit-codes.test.ts and setup-smoke.test.ts (error to exit
code mapping, runSetup stages, renderPdf's engine), and prose-span cases for
extractBrowseCommands in test/skill-parser.test.ts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs: CHANGELOG and TODOS cover the review fixes (v1.81.0.0)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs: sync project docs with the v1.81.0.0 review fixes
BROWSER.md, ARCHITECTURE.md, CONTRIBUTING.md, README.md, CLAUDE.md,
docs/TESTING_INTERNALS.md and docs/PROJECT_STRUCTURE.md now describe the
shipped renderer and setup: the loopback render server's per-render secret
path and real-path containment, ENGINE= naming the engine that actually
rendered (mid-run retry on gstack's own browser), EVAL/PAGE_ERRORS fenced as
untrusted content, --wait-timeout and the CLI's argv guards, the receipted
_aside_exec prelude ({{ASIDE_EXEC_PRELUDE}} in the placeholder table), the
LOCAL host rule without .local, LOCAL-only HEAD checks in the links script,
GSTACK_SKIP_ASIDE across probe/renderer/setup, the ownership-gated
retired-skill prune, the widened NEEDS_BUILD check, and the new free tests
(gstack-render-cli, setup-prune-stale-generated, setup-browser-hint,
setup-needs-build, make-pdf cli-exit-codes and setup-smoke).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs: CHANGELOG states the precise mid-run retry rule
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(test): skill-e2e-bws slices the $B setup block from the Browser fallback section
browse/SKILL.md no longer has '## SETUP' / '## Core QA Patterns' (Aside is the
primary driver; the $B block moved under 'Browser fallback'), so the gate test
sliced an empty block and handed the agent nothing to run. Anchor on
'### Find the `$B` binary' up to the next heading. 7/7 pass.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(test): gate POSIX-only fixtures off Windows
windows-free-tests: the gstack-render CLI tests drive a shebang fake browse
that CreateProcess cannot exec, and two NEEDS_BUILD cases assert an execute
bit and a bare-name miss that MSYS bash does not have (test -x ignores mode
bits and resolves design -> design.exe). Those describes and cases now
self-skip on win32; argument guards, --help, the no-browser case, and every
other rebuild-check case still run there.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(render): runProc waits for the exit code until the kill deadline; newtab retries once on a cold daemon
A process whose pipes have reached EOF is exiting, but runProc gave the exit
code only five seconds to arrive and then returned null, which run() reports
as a failed command. Under CI's six-shard load one such render failed with the
artifact already written. The SIGTERM/SIGKILL timers already bound the wait,
so the exit race now runs to the kill deadline.
The first CLI call auto-starts the browse daemon; on a cold start it can
answer 'Unable to connect' once while the server is still coming up. That
single case is retried after 1.5s; every other newtab failure is not.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(aside-render): warm the daemon before live fallback cases; failures name the render error
- Live fallback cases run 'goto about:blank' up to twice before asserting and
skip (never fail) when the daemon cannot come up.
- expectOk() puts r.error and the browse transcript into the assertion so a
failed render is diagnosable from the CI log.
- The argv-contract cases dump the fake's log on a miss.
- File default timeout is 30s: the subject is the CLI contract, not latency.
- Two cases pin the cold-daemon newtab retry and that other errors are not
retried.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs: CHANGELOG notes the cold-start tolerance of the bundled-browser renderer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Sina <sdroid674+github@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
453 lines
17 KiB
TypeScript
453 lines
17 KiB
TypeScript
/**
|
|
* Print stylesheet generator.
|
|
*
|
|
* Source of truth: .context/designs/make-pdf-print-reference.html and siblings.
|
|
* Mirror those CSS rules here. The HTML references were approved via
|
|
* /plan-design-review with explicit design decisions locked in the plan:
|
|
*
|
|
* - Helvetica first, with Liberation Sans as a metric-compatible Linux
|
|
* fallback (Helvetica and Arial aren't installed on most Linux distros;
|
|
* Liberation Sans ships via the fonts-liberation package). No bundled
|
|
* webfonts — dodges the per-glyph Tj bug that
|
|
* breaks copy-paste extraction.
|
|
* - All paragraphs flush-left. No first-line indent, no justify, no
|
|
* p+p indent. text-align: left everywhere. 12pt margin-bottom.
|
|
* - Cover page (v1.58.0.0 poster revision, user-directed): 56pt title,
|
|
* 13pt meta, padding-top 1.4in for poster placement. Still no flexbox
|
|
* and no vertical centering; the inset is a deliberate top-third drop.
|
|
* (Supersedes the original "no inset padding" lock from the first
|
|
* /plan-design-review — the 32pt cover read as too small in print.)
|
|
* - `@page :first` suppresses running header/footer but does NOT override
|
|
* the 1in margin.
|
|
* - No <link>, no external CSS/fonts — everything inlined.
|
|
* - CJK fallback: Helvetica, Liberation Sans, Arial, Hiragino Kaku Gothic
|
|
* ProN, Noto Sans CJK JP, Microsoft YaHei, sans-serif.
|
|
* - Emoji fallback: the body and @top-center running-header stacks end in an
|
|
* emoji family group ("Apple Color Emoji", "Segoe UI Emoji", "Noto Color
|
|
* Emoji"), placed BEFORE the generic `sans-serif` so Chromium has a glyph
|
|
* source for emoji code points instead of emitting .notdef tofu (▯). The
|
|
* @bottom-* margin boxes hold only counters / a fixed "CONFIDENTIAL"
|
|
* string, so they get no emoji families. On Linux this requires an
|
|
* installed color-emoji font — `setup` installs fonts-noto-color-emoji.
|
|
*
|
|
* Font stacks are composed from the constants below so each family list has a
|
|
* single source of truth (DRY) and every stack stays in sync.
|
|
*/
|
|
|
|
// Metric-compatible sans stack: Helvetica (macOS), Liberation Sans (Linux,
|
|
// ships via fonts-liberation), Arial (Windows). Shared by every text surface.
|
|
const SANS_STACK = `Helvetica, "Liberation Sans", Arial`;
|
|
// CJK fallback families (Simplified-Chinese first), appended to the body stack only.
|
|
const CJK_STACK = `"PingFang SC", "Heiti SC", "Noto Sans CJK SC", "Source Han Sans SC", "Microsoft YaHei", "Hiragino Kaku Gothic ProN", "Noto Sans CJK JP"`;
|
|
// Color-emoji families: Apple (macOS), Segoe (Windows), Noto (Linux).
|
|
const EMOJI_FAMILIES = `"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"`;
|
|
|
|
export interface PrintCssOptions {
|
|
// Document structure
|
|
cover?: boolean;
|
|
toc?: boolean;
|
|
noChapterBreaks?: boolean;
|
|
|
|
// Branding
|
|
watermark?: string;
|
|
confidential?: boolean;
|
|
|
|
// Header (running title, top of page)
|
|
runningHeader?: string;
|
|
|
|
// Page size (in CSS `@page size:` terms)
|
|
pageSize?: "letter" | "a4" | "legal" | "tabloid";
|
|
|
|
// Margins (default 1in)
|
|
margins?: string;
|
|
|
|
// Whether to render "N of M" page numbers in the @page @bottom-center rule.
|
|
// Default true. Set false to suppress CSS numbering (used when the caller
|
|
// supplies a custom Chromium footerTemplate, or when --no-page-numbers).
|
|
pageNumbers?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Produce a CSS block (no <style> wrapper) for inline injection.
|
|
*/
|
|
export function printCss(opts: PrintCssOptions = {}): string {
|
|
const size = opts.pageSize ?? "letter";
|
|
const margin = opts.margins ?? "1in";
|
|
const hasWatermark = typeof opts.watermark === "string" && opts.watermark.length > 0;
|
|
|
|
return [
|
|
pageRules(size, margin, opts),
|
|
rootTypography(),
|
|
coverRules(opts.cover === true),
|
|
tocRules(opts.toc === true),
|
|
chapterRules(opts.noChapterBreaks === true),
|
|
blockRules(),
|
|
inlineRules(),
|
|
codeRules(),
|
|
quoteRules(),
|
|
figureRules(),
|
|
tableRules(),
|
|
listRules(),
|
|
footnoteRules(),
|
|
hasWatermark ? watermarkRules() : "",
|
|
breakAvoidRules(),
|
|
].filter(Boolean).join("\n\n");
|
|
}
|
|
|
|
function pageRules(size: string, margin: string, opts: PrintCssOptions): string {
|
|
const runningHeader = escapeCssString(opts.runningHeader ?? "");
|
|
const showConfidential = opts.confidential !== false;
|
|
const showPageNumbers = opts.pageNumbers !== false;
|
|
|
|
return [
|
|
`@page {`,
|
|
` size: ${size};`,
|
|
` margin: ${margin};`,
|
|
runningHeader
|
|
? ` @top-center { content: "${runningHeader}"; font-family: ${SANS_STACK}, ${EMOJI_FAMILIES}, sans-serif; font-size: 9pt; color: #666; }`
|
|
: ``,
|
|
showPageNumbers
|
|
? ` @bottom-center { content: counter(page) " of " counter(pages); font-family: ${SANS_STACK}, sans-serif; font-size: 9pt; color: #666; }`
|
|
: ``,
|
|
showConfidential
|
|
? ` @bottom-right { content: "CONFIDENTIAL"; font-family: ${SANS_STACK}, sans-serif; font-size: 8pt; color: #aaa; letter-spacing: 0.05em; }`
|
|
: ``,
|
|
`}`,
|
|
``,
|
|
// Cover page: suppress running header/footer but keep margins.
|
|
`@page :first {`,
|
|
` @top-center { content: none; }`,
|
|
` @bottom-center { content: none; }`,
|
|
` @bottom-right { content: none; }`,
|
|
`}`,
|
|
``,
|
|
// Landscape named page for promoted wide diagrams/images (image-policy).
|
|
// Chromium-only — exactly the engine this pipeline always prints with.
|
|
// Honored only when the print call passes preferCSSPageSize (orchestrator
|
|
// sets it when a promotion exists). Vertical centering is NOT done here —
|
|
// image-policy emits a computed inline margin-top instead (see the
|
|
// .page-wide comment below for why).
|
|
`@page wide {`,
|
|
` size: ${size} landscape;`,
|
|
` margin: ${margin};`,
|
|
`}`,
|
|
// No explicit break-before/after (the page-name CHANGE already forces a
|
|
// break on both sides) and NO height/flex centering: a flex .page-wide
|
|
// with min-height fragments into a phantom empty landscape page in
|
|
// Chromium (landscape-gate counted 5 pages for 3 promotions; bisected to
|
|
// min-height at any value). Vertical centering is done by image-policy
|
|
// instead — it knows each promoted block's aspect ratio and emits an
|
|
// inline margin-top, which fragmentation handles fine.
|
|
`.page-wide {`,
|
|
` page: wide;`,
|
|
` text-align: center;`,
|
|
`}`,
|
|
// width: 100% stretch is intentional for promoted content: auto-promoted
|
|
// rasters are >=~1600px (≈190dpi at the 9in landscape box — prints fine),
|
|
// and a directive-forced small image is the user's explicit call.
|
|
`.page-wide img, .page-wide svg { width: 100%; height: auto; max-width: none; }`,
|
|
`.page-wide figure.diagram > svg { max-width: none; }`,
|
|
].filter(line => line !== "").join("\n");
|
|
}
|
|
|
|
/**
|
|
* Screen layer appended for `--to html` exports. The print CSS stays the
|
|
* source of truth; this only makes the same document readable in a browser
|
|
* (centered measure, padding, no print-only chapter breaks forcing scroll
|
|
* gaps). Print output is unaffected — media-scoped.
|
|
*/
|
|
export function screenCss(): string {
|
|
return [
|
|
`@media screen {`,
|
|
// ~42em at 12pt ≈ 70-75 characters per line — the readable ceiling.
|
|
` body { max-width: 42em; margin: 0 auto; padding: 2.5em 1.5em; }`,
|
|
` .chapter { break-before: auto; }`,
|
|
` .watermark { display: none; }`,
|
|
` figure.diagram { overflow-x: auto; }`,
|
|
// Page numbers only exist in print; hide the empty spans + dot leaders.
|
|
` .toc li .toc-page, .toc li .toc-dots { display: none; }`,
|
|
`}`,
|
|
].join("\n");
|
|
}
|
|
|
|
function rootTypography(): string {
|
|
return [
|
|
`html { lang: en; }`,
|
|
// Zero image truncation, ever: every image caps at the content box,
|
|
// whatever element it lives in. Markdown images render as <p><img> (no
|
|
// figure), so a figure-scoped cap alone lets a 1900px screenshot run off
|
|
// the page edge. .page-wide deliberately overrides to fill its landscape
|
|
// box — still bounded, never clipped.
|
|
`img { max-width: 100%; height: auto; }`,
|
|
`body {`,
|
|
` font-family: ${SANS_STACK}, ${CJK_STACK}, ${EMOJI_FAMILIES}, sans-serif;`,
|
|
` font-size: 12pt;`,
|
|
` line-height: 1.5;`,
|
|
` color: #111;`,
|
|
` background: white;`,
|
|
// No auto-hyphenation: it puts real "dif-\nferent" breaks into the PDF
|
|
// text layer, and clean copy-paste is the product contract (the
|
|
// combined-gate caught this the moment 12pt body made lines wrap).
|
|
// Left-aligned rag doesn't need hyphenation.
|
|
` hyphens: manual;`,
|
|
` font-variant-ligatures: common-ligatures;`,
|
|
` font-kerning: normal;`,
|
|
` text-rendering: geometricPrecision;`,
|
|
` margin: 0;`,
|
|
` padding: 0;`,
|
|
`}`,
|
|
].join("\n");
|
|
}
|
|
|
|
function coverRules(enabled: boolean): string {
|
|
if (!enabled) return "";
|
|
return [
|
|
// Poster scale: the cover is the one page where type should feel huge.
|
|
`.cover {`,
|
|
` page: first;`,
|
|
` page-break-after: always;`,
|
|
` break-after: page;`,
|
|
` text-align: left;`,
|
|
` padding-top: 1.4in;`,
|
|
`}`,
|
|
`.cover .eyebrow {`,
|
|
` font-size: 11pt;`,
|
|
` letter-spacing: 0.2em;`,
|
|
` text-transform: uppercase;`,
|
|
` color: #666;`,
|
|
` margin: 0 0 36pt;`,
|
|
`}`,
|
|
`.cover h1.cover-title {`,
|
|
` font-size: 56pt;`,
|
|
` line-height: 1.08;`,
|
|
` font-weight: 700;`,
|
|
` letter-spacing: -0.02em;`,
|
|
` margin: 0 0 24pt;`,
|
|
` max-width: 6in;`,
|
|
` text-align: left;`,
|
|
`}`,
|
|
`.cover .cover-subtitle {`,
|
|
` font-size: 18pt;`,
|
|
` line-height: 1.35;`,
|
|
` font-weight: 400;`,
|
|
` color: #333;`,
|
|
` margin: 0 0 36pt;`,
|
|
` max-width: 5.5in;`,
|
|
` text-align: left;`,
|
|
`}`,
|
|
`.cover hr.rule {`,
|
|
` width: 2.5in;`,
|
|
` height: 0;`,
|
|
` border: 0;`,
|
|
` border-top: 1.5px solid #111;`,
|
|
` margin: 0 0 24pt 0;`,
|
|
`}`,
|
|
`.cover .cover-meta { font-size: 13pt; line-height: 1.6; color: #333; text-align: left; }`,
|
|
`.cover .cover-meta strong { font-weight: 700; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function tocRules(enabled: boolean): string {
|
|
if (!enabled) return "";
|
|
return [
|
|
`.toc { page-break-after: always; break-after: page; }`,
|
|
`.toc h2 {`,
|
|
` font-size: 16pt;`,
|
|
` text-transform: uppercase;`,
|
|
` letter-spacing: 0.15em;`,
|
|
` color: #444;`,
|
|
` font-weight: 700;`,
|
|
` margin: 0 0 0.4in;`,
|
|
`}`,
|
|
`.toc ol {`,
|
|
` list-style: none;`,
|
|
` padding: 0;`,
|
|
` margin: 0;`,
|
|
`}`,
|
|
`.toc li {`,
|
|
` display: flex;`,
|
|
` align-items: baseline;`,
|
|
` gap: 0.25in;`,
|
|
` font-size: 12pt;`,
|
|
` line-height: 1.7;`,
|
|
` padding: 3pt 0;`,
|
|
`}`,
|
|
`.toc li .toc-title { flex: 0 0 auto; }`,
|
|
`.toc li .toc-dots { flex: 1 1 auto; border-bottom: 1px dotted #aaa; margin: 0 6pt; transform: translateY(-4pt); }`,
|
|
`.toc li .toc-page { flex: 0 0 auto; color: #666; font-variant-numeric: tabular-nums; }`,
|
|
`.toc li.level-2 { padding-left: 0.35in; font-size: 11pt; }`,
|
|
`.toc li a { color: inherit; text-decoration: none; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function chapterRules(noChapterBreaks: boolean): string {
|
|
const breakRule = noChapterBreaks
|
|
? `/* chapter breaks disabled */`
|
|
: [
|
|
`.chapter { break-before: page; page-break-before: always; }`,
|
|
`.chapter:first-of-type { break-before: auto; page-break-before: auto; }`,
|
|
].join("\n");
|
|
return [
|
|
breakRule,
|
|
`h1 {`,
|
|
` font-size: 26pt;`,
|
|
` line-height: 1.2;`,
|
|
` font-weight: 700;`,
|
|
` letter-spacing: -0.01em;`,
|
|
` margin: 0 0 0.25in;`,
|
|
` break-after: avoid;`,
|
|
` page-break-after: avoid;`,
|
|
`}`,
|
|
`h2 { font-size: 18pt; line-height: 1.3; font-weight: 700; margin: 26pt 0 8pt; break-after: avoid; page-break-after: avoid; }`,
|
|
`h3 { font-size: 13.5pt; line-height: 1.4; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #333; margin: 20pt 0 5pt; break-after: avoid; page-break-after: avoid; }`,
|
|
`h4 { font-size: 12pt; font-weight: 700; margin: 14pt 0 5pt; break-after: avoid; page-break-after: avoid; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function blockRules(): string {
|
|
// Flush-left paragraphs, no indent, 12pt gap. No justify.
|
|
// Rule from the plan's "Body paragraph rule (post-review fix)".
|
|
return [
|
|
`p {`,
|
|
` margin: 0 0 12pt;`,
|
|
` text-align: left;`,
|
|
` widows: 3;`,
|
|
` orphans: 3;`,
|
|
`}`,
|
|
`p:first-child { margin-top: 0; }`,
|
|
`p.lead { font-size: 14pt; line-height: 1.45; color: #222; margin: 0 0 18pt; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function inlineRules(): string {
|
|
return [
|
|
`a {`,
|
|
` color: #0055cc;`,
|
|
` text-decoration: underline;`,
|
|
` text-decoration-thickness: 0.5pt;`,
|
|
` text-underline-offset: 1.5pt;`,
|
|
`}`,
|
|
`strong { font-weight: 700; }`,
|
|
`em { font-style: italic; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function codeRules(): string {
|
|
return [
|
|
`code {`,
|
|
` font-family: "SF Mono", Menlo, Consolas, monospace;`,
|
|
` font-size: 10.5pt;`,
|
|
` background: #f4f4f4;`,
|
|
` padding: 1pt 3pt;`,
|
|
` border-radius: 2pt;`,
|
|
` border: 0.5pt solid #e4e4e4;`,
|
|
`}`,
|
|
`pre {`,
|
|
` font-family: "SF Mono", Menlo, Consolas, monospace;`,
|
|
` font-size: 10pt;`,
|
|
` line-height: 1.4;`,
|
|
` background: #f7f7f5;`,
|
|
` padding: 10pt 12pt;`,
|
|
` border: 0.5pt solid #e0e0e0;`,
|
|
` border-radius: 3pt;`,
|
|
` margin: 12pt 0;`,
|
|
` overflow: hidden;`,
|
|
` white-space: pre-wrap;`,
|
|
`}`,
|
|
`pre code { background: none; border: 0; padding: 0; font-size: inherit; }`,
|
|
// highlight.js minimal palette (kept neutral, prints well)
|
|
`.hljs-keyword { color: #8b0000; font-weight: 500; }`,
|
|
`.hljs-string { color: #0d6608; }`,
|
|
`.hljs-comment { color: #888; font-style: italic; }`,
|
|
`.hljs-function, .hljs-title { color: #0044aa; }`,
|
|
`.hljs-number { color: #a64d00; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function quoteRules(): string {
|
|
return [
|
|
`blockquote {`,
|
|
` margin: 12pt 0;`,
|
|
` padding: 0 0 0 18pt;`,
|
|
` border-left: 2pt solid #111;`,
|
|
` color: #333;`,
|
|
` font-size: 12pt;`,
|
|
` line-height: 1.5;`,
|
|
`}`,
|
|
`blockquote p { margin-bottom: 6pt; text-align: left; }`,
|
|
`blockquote cite { display: block; margin-top: 6pt; font-style: normal; font-size: 10pt; color: #666; letter-spacing: 0.02em; }`,
|
|
`blockquote cite::before { content: "— "; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function figureRules(): string {
|
|
return [
|
|
`figure { margin: 12pt 0; }`,
|
|
`figure img { display: block; max-width: 100%; height: auto; }`,
|
|
`figcaption { font-size: 10pt; color: #666; margin-top: 6pt; font-style: italic; }`,
|
|
// Diagram figures (diagram-prepass): rendered mermaid/excalidraw SVG.
|
|
// SVGs scale to the content box and never split across pages.
|
|
`figure.diagram { break-inside: avoid; text-align: center; }`,
|
|
`figure.diagram > svg { max-width: 100%; height: auto; }`,
|
|
`figure.diagram .diagram-caption { text-align: center; }`,
|
|
// Diagnostic block for a fence that failed to render — loud, boxed,
|
|
// unmistakably an error (never silent raw code).
|
|
`figure.diagram-error { border: 1.5pt solid #b00020; padding: 8pt 10pt; text-align: left; }`,
|
|
`figure.diagram-error .diagram-error-title { font-weight: 700; color: #b00020; font-style: normal; margin: 0 0 6pt; }`,
|
|
`figure.diagram-error .diagram-error-detail { font-size: 8.5pt; white-space: pre-wrap; margin: 0; }`,
|
|
// Missing local image placeholder (non-strict mode).
|
|
`.image-missing { display: inline-block; border: 1pt dashed #b00020; color: #b00020; padding: 4pt 8pt; font-size: 9pt; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function tableRules(): string {
|
|
return [
|
|
`table { width: 100%; border-collapse: collapse; margin: 12pt 0; font-size: 11pt; }`,
|
|
`th, td { border-bottom: 0.5pt solid #ccc; padding: 5pt 8pt; text-align: left; vertical-align: top; }`,
|
|
`th { font-weight: 700; border-bottom: 1pt solid #111; background: transparent; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function listRules(): string {
|
|
return [
|
|
`ul, ol { margin: 0 0 12pt 0; padding-left: 20pt; }`,
|
|
`li { margin-bottom: 3pt; line-height: 1.45; }`,
|
|
`li > ul, li > ol { margin-top: 3pt; margin-bottom: 0; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function footnoteRules(): string {
|
|
return [
|
|
`.footnote-ref { font-size: 0.75em; vertical-align: super; line-height: 0; text-decoration: none; color: #0055cc; }`,
|
|
`.footnotes { margin-top: 24pt; padding-top: 12pt; border-top: 0.5pt solid #ccc; font-size: 10pt; line-height: 1.4; }`,
|
|
`.footnotes ol { padding-left: 18pt; }`,
|
|
].join("\n");
|
|
}
|
|
|
|
function watermarkRules(): string {
|
|
return [
|
|
`.watermark {`,
|
|
` position: fixed;`,
|
|
` top: 50%;`,
|
|
` left: 50%;`,
|
|
` transform: translate(-50%, -50%) rotate(-30deg);`,
|
|
` font-size: 140pt;`,
|
|
` font-weight: 700;`,
|
|
` color: rgba(200, 0, 0, 0.06);`,
|
|
` letter-spacing: 0.08em;`,
|
|
` pointer-events: none;`,
|
|
` z-index: 9999;`,
|
|
` user-select: none;`,
|
|
` white-space: nowrap;`,
|
|
`}`,
|
|
].join("\n");
|
|
}
|
|
|
|
function breakAvoidRules(): string {
|
|
return `blockquote, pre, code, table, figure, li, .keep-together { break-inside: avoid; page-break-inside: avoid; }`;
|
|
}
|
|
|
|
function escapeCssString(s: string): string {
|
|
return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
}
|