Files
gstack/lib/aside-render.ts
Garry Tanandcapy-ai[bot] fa3a2400b5 v1.91.251.0 feat(design-lint): bundled free design lint (bin/gstack-design-lint + lib/design-lint/), hallmark sloplint port (#3459)
* feat(design-lint): core of the bundled free design lint (parsers, colour, tokens, reporter, waivers, CLI)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(design-lint): CHANGELOG entry, NOTICE and MIT-hallmark licence, module-size ratchet entries

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-lint): port the colour and type rule families from sloplint

rules/color.ts: gates 2a 2b 7a 7b 22 23 29 48 65 74 (check2, check7, check22,
check23, check29Glow, check48, check65, check65Tailwind). rules/type.ts: gates
1 25 37 38i 38ii 38iii 55a 55b 64 67 (check1, check25, check37, check38a,
check38c, check55, check64, check67, check67Html). Thresholds and heuristics
are hallmark's; evidence and fixes are rewritten in gstack's voice; the stamp,
genre and posture guards are gone, so every check runs in its strict form.

The clean fixture carried two real tells hallmark's own sloplint also flags:
an oklch literal on .button:hover (48) and the cream paper + Fraunces + clay
accent look (65). The hover colour is now a token and the display face is
Literata.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-lint): port the Tailwind reader and component rules

Replaces the tailwind.ts stub with sloplint's utility reader (twParse,
twInfo, twGroup, variants, stops, sizes, tracking, families, shared
project tokens) and the tells it feeds, and fills rules/component.ts
with checkTailwind and checkComponentText: layout-transition,
bounce-easing, purple-blue-gradient, gradient-text, glow-wash,
literal-outside-tokens, hover-tells, wide-tracking, kicker-above-heading,
monospace-costume, framework-palette, vh-height, template-cards,
template-punctuation and emoji-decoration over class strings in HTML,
JSX, Vue, Svelte and Astro files and cn()/clsx()/cva() calls.
test/design-lint-tailwind.test.ts holds a negative control and a planted
tell per rule plus the tailwind-fail, tailwind-pass and clean fixtures.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-lint): mark line-length, cream-palette and the caps leading rules page-only

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-lint): port the structure and motion rule families from sloplint

Structure: template-cards, nested-cards, vh-height, raw-spacing, off-scale-spacing, overflow-x-unclipped (static half), bare-fr-track, display-head-unwrapped, radio-tab-jump, double-sticky, screen-word-budget (static half), and a conservative static half of wide-container. Motion: layout-transition, spacing-transition, hover-tells, bounce-easing, focus-ring-fade, tooltip-focus-delay, autorotate-no-pause, missing-focus-visible, missing-states, missing-reduced-motion, autoplay-unmuted, lazy-lcp, video-without-poster, input-state-shift, control-height (static half), content-hidden-at-rest. Every rule has a planted positive and a negative control; the clean fixture lede drops to eleven words so the hero-paragraph budget holds.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(design-lint): the Tailwind gradient half of 2a/2b lives in the component family only

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore(design-lint): tidy the colour test imports; control-height page-only; lazy-lcp title

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore(design-lint): drop unused colour test imports

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-lint): port the chrome, assets and finish rule families from sloplint

Chrome (gates 19 42 46a 47 54 63 68 69 70 85): gate 19 split by tell onto
aphoristic-cadence, generic-hero-copy, generic-cta-copy, em-dash-overuse and
template-footer-line in rules/chrome-copy.ts (with the two-filled-buttons half
of F4 and the shared prose mask); the eyebrow in rules/chrome-eyebrow.ts; the
nav and footer shapes, invented proof, drawn chrome, placeholder services,
template punctuation, wireframe slots, repeated facts, the fake send and a
static version of the email-band close in rules/chrome.ts.

Assets (gates 30 33 40 41 49 76): emoji icons, mixed icon libraries, unlabelled
svg/canvas, contrast pairs and surface inks (suspected at ask in the static
tier), wrapping labels split into button-label-wraps and link-wraps, broken
images and stock hotlinks.

Finish (F1-F9 F15 F16 F21-F24): all polish except fake-avatar.

Tests cover every id with a negative control and a planted positive; the clean
fixture gains scrollbar-color so its committed palette themes every browser
surface.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-lint): chrome, assets and finish families merged; maskedProse moves to html.ts; isTokenRule takes a Pick; ratchet watches every lint module

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(design-lint): the eyebrow-pass and tailwind-pass fixtures carry a skip link, main id and aria-current so the copy tier passes them too; waiver fixture gets a meta description

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-lint): render tier on gstack's own browser (sloplint-render port)

Ports hallmark's sloplint-render.mjs onto lib/aside-render: one tab per
page, the in-page probes installed once and run at each viewport, a second
pass with full-page screenshots only when a contrast claim needs the painted
pixels (a small PNG reader replaces canvas sampling). Gates 23, 25, 26a, 34,
40, 41, 44a-d, 49a/b, 58, 71, 72, 73, 75, 77-83, F17-F20, plus the page that
did not render. The stamp's posture, hero-shape, photo-shape and genre
guards are gone: the strict form applies. Focus rings are read by focusing
each control in turn (programmatic focus matches :focus-visible on the
daemon with no prior pointer input; a stylesheet scan is the fallback).

lib/aside-render: an eval step takes width/height like a screenshot step
(device metrics on Aside, viewport on browse, cleared after), and spec.file
may be an http(s) URL, opened as it is with nothing served.

The clean fixture gained border-box on its hero, a centred, taller hero
visual, a wrapping actions row, a statement-sized close head and a shorter
story paragraph: the browser showed the action below the 1280x800 fold, a
dead band under the hero, a 600px image spilling past a phone screen and a
thin close section.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-lint): render tier merged; four probe modules join the ratchet; the clean hero sits at a fixed measure so text and visual stand together; CHANGELOG names the aside-render eval viewport

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(design-lint): register the lint fixtures as free fixtures and the hallmark licence texts as free-only PR files, so the PR paid lane stays narrow and test/free-fixtures.test.ts passes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(design-lint): section-gap measured in the render tier (gate 59, with the six-to-nine section count) and the hero action count; a free test over the recorded review slop page; usage, render glue, file walk, multi-page ownership and copy-tier tests; NOTICE and licences leave FREE_ONLY_PR_FILES so the paid-free boundary test stands

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: mark hallmark PAGE_ONLY gates and craft page checks pageOnly so --scope component drops them

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: ship review fixes

Waivers reach render findings; a blocking rule the lint only suspected is
waivable like any ask, a measured or absent Floor stays refused. A stylesheet
a page links from another directory belongs to that page and is not linted
again on its own; a stylesheet linted alone keeps its waivers. Root-relative
hrefs resolve against the lint root. Stylesheets are read and parsed once per
run. A rule family that throws reports internal-error at ask instead of a
silent pass; aria-labelledby and probe tag names are escaped before they
reach a RegExp; a probe result naming an unknown rule is a render finding,
not a crash; fix strings are one line. overused-font follows gstack's
catalog of display faces; one MONO_FACE list for every monospace check.
parseCss caps nesting depth; medianRgb uses histograms; glHide batches its
layout reads. Dead exports removed (isPage, openTags, over, ComponentTag,
PixelRect, the ids re-exports).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: name the tracked-caps thresholds once, string-aware brace scan, drop an unused parameter, clean up test tmpdirs

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: second review cycle

Waivers settle once per page after both tiers, so a blocking rule only the
render tier suspects is waivable and one it measures is refused; a claim in a
stylesheet answers for that stylesheet on every page that links it (no
shared-word check), so a sheet shared by two pages is no longer waived on one
and refused on the other. Every printed field is one line with control
characters stripped; the waiver regex is linear and ends at a carriage
return; a root-relative href cannot climb out of the lint root. The ancestor
map closes optional end tags implicitly and pops to the matching open tag, so
unclosed <li> no longer makes it quadratic. The static tier reports outline:
none with no ring anywhere as missing-focus-visible at its line;
literal-outside-tokens needs a --color-* or --font-* token to exist; the
banned gradient includes indigo-to-violet; a new banned-font rule reads
gstack's BANNED_FONTS; MONO_FACE matches whole words; bold keywords and
tokens count as weight 700 for the contrast threshold. stylesFor reads only
the entries that can match; glOcclusion memoises the pinned walk; the
confirm screenshot is taken at 1x; a page that throws in the render tier is a
render finding and the CLI exits 3 on an internal error. Docs name the token
conventions and what the lint does not cover.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: pair open and close tags once per document, scan markup in linear time, keep commented-out style and script dead

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: third review cycle fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: third review cycle tests; render keeps the measured horizontal-scroll when merging widths

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: document the third-cycle limits and waiver scope

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: fourth review cycle fixes

Commented-out markup is dead for every rule (Doc.src is the comment-blanked text; craft copy, the module-script check and the border+shadow scan read it). Headings, buttons, links, labels, selects, forms and an open <p> close the way the browser closes them, a tree nests at most 512 deep, and the heading, footer-column, sign-up-button and span readers walk the shared pairing instead of lazy regexes, with each element's text computed once (textOf). Tailwind frames and wireframe slots answer from treeOf. resolveVars stops on a cycle and caps its size; every parseCss at-rule branch honours the depth guard and an inline <style> that fails to parse is skipped with a warning. cwd and lint roots are read through their real paths, a skipped source file is one internal-error finding, relative refs stay inside the project, a linked stylesheet over 2 MB is skipped. overused-font and banned-font match a catalog stem to a family name's first words; width: min()/clamp() caps a container; a box-shadow focus ring beside a border-colour change is not input-state-shift; Meet and triad headings below the hero are suspected; the stylesheet-waiver key goes through oneLine; pxOf reads leading-dot lengths; check67 precomputes selector bases; the render tier reports a wrong-shaped answer per probe and keeps every measured finding, flags a bad grade as tampering, and never repeats the kept width when merging horizontal-scroll.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: fourth review cycle tests and docs

test/design-lint-fourth-cycle.test.ts pins commented-out markup dead for every rule, unclosed <li> Tailwind frames and wireframe slots, linear-time unclosed runs and component scripts, cyclic var() and nested @media, leading-dot lengths, a symlinked lint root, oversize files reported, a whitespace-named stylesheet's waiver, the overused-font stems, width: min() caps, box-shadow rings, Meet headings below the hero, wrong-shaped render answers and the never-waivable refusals by class; the settle golden no longer repeats the kept width and expects the bad grade as tampering. docs/design-lint.md describes the caps, implicit closes, skipped-file findings and the matching rules.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: fourth-cycle simplifications and the pixel pass pinned without a browser

heroClasses reads the hero through heroIndexOf, check64 uses twMonoIsMaterial, check67Html imports capsRatio from chrome-eyebrow, sideStripes loses its bare block; a settle test drives confirmPixels with the swatch fixture (a claim dropped by the pixels behind it, one rewritten, a weak ring confirmed).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: heroIndexOf carries its own doc comment

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: fifth review cycle fixes

The placeholder emoji scan reads the comment-blanked text, so markup inside <!-- --> is dead for every rule. A digit run, a long mono word and a run of scheme prefixes cost their length (FACT and UNSPLASH_URL only start at a token, twDataText bounds its text); a 2 MB sheet of 699k rules no longer blows the stack (rules are appended in loops); JsxScanner spends one failure budget across jsScan and el; the Tailwind had() lookup is a Set; accentedHeads indexes rules by class once; contexts drop their caches once their families have run; the parsed-token cache is bounded. treeOf gives a close tag its parent so a fact after an inline close is counted in its section. Waivers are keyed per rule for a page claim and per rule@sheet for a stylesheet claim, so two linked sheets each answer for their own findings. The render tier validates every page-supplied field (at.cls, ring colour and ratio, probe fg and threshold, PAGE_ERRORS entries, a malformed entry naming the render rule) and keeps its measured findings; lineOf answers from one line index. Rule judgement: a textarea's min-height is not control-height, attribute-selected inputs are read for input-state-shift, a glyph-only <i> is not an italic word, a bounce keyframe by name is bounce-easing, single-quoted inline styles are read, a project root is a package.json or .git. Housekeeping: Probes typed from PROBE_FNS, EYEBROW_* shared from chrome-eyebrow, WaiverClaim.raw and dead imports removed, evidence wording for tracked-only controls and black glass, emoji icon tiles capped like every other emoji path, unreadable files get their own fix text.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: fifth review cycle tests and docs

test/design-lint-fifth-cycle.test.ts (21 tests) pins the comment-dead placeholder, the linear-time digit run, 699k-rule sheet, unclosed JSX and tracked-label pages, per-sheet waivers and the remaining state-matrix cells, every wrong-shaped render field, and the rule judgements; the fourth-cycle shell test asserts the observable effect, the RSS assertion is structural, time bounds are widened for shared CI shards, the stale empty block is gone, and the FREE_FIXTURES inventory names both cycle files. docs/design-lint.md states the project-root bound on relative hrefs and the three narrow readings (whole-page focus ring, unconditional 4px scale, scroll-container-blind double-sticky).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: sixth review cycle fixes

line-length computes its floor only for a measure between 28 and 45ch and
answers shortProse once per class; wide-tracking resolves inherited case
through a prefix index instead of a scan of every rule; the JSX scanner
fails every ancestor of an element that ran to the end of the file at once;
the hairline-under-shadow pair reads class tokens instead of a two-sided
regex. tooltip-focus-delay names the sheet that holds the :focus delay;
the placeholder emoji scan reads the comment-blanked body for every kind;
fake-send finds its line in a component's script text. A stylesheet href is
bounded by its real path too. The LINT line's separator is kept out of
page-supplied text. Waivers: a component claim answers for its own file,
weighed against its own text; the Waiver shape is a reason string; refusals
go through Reporter. Render: a later fail replaces a warn with the same
evidence, a non-string fix is dropped, the timeout is a constant. Rules:
a meta label in mono is a costume, a leading-dot length reads as a fraction,
a percentage leading collides like a unitless one, an italic word after an
icon is still read, an italic rule on the word is not a display system, en
dashes between digits are ranges, and the catalog's two hero lines are
generic-hero-copy. The Tailwind parse cache and its eviction are gone.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: sixth review cycle tests and docs

test/design-lint-sixth-cycle.test.ts (18 tests; 13 red at 3f0c62582) pins
the per-page work, the right-place findings, the rule judgements and the
waiver and render cells the state matrix named. The emoji-tile cap is
asserted exactly, the pixel-pass test says what it checks, the recorded slop
page now names its generic hero copy. docs/design-lint.md: the real-path
bound, the separator note, page and component claim scopes, hero-fold's
measured half, two more items the lint does not cover, the heading-pattern
exemption and the digit-range dash.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: seventh review cycle fixes

A tag start the scanner finds no '>' for is spaced out of doc.src, so every
'[^>]*>' scan over a page (the lint's and the craft check's) costs the page
its length; render's line lookup reads the same text. The JSX scanner skips
a generic element's type arguments and closes every element under a close
tag that names an open ancestor, as a browser would; a closer naming nothing
open is text. The token table is read in one colour mode: the base block's
last declaration wins and a prefers-color-scheme or data-theme override only
fills tokens the base never declares; a custom property declared in any
scope is a token definition, not a literal; a token finding points at the
declaration that won. In a directory of components, under either scope,
each file's claims answer for that file alone with its own suspected rules.
The Tailwind dedupe keys files the way Reporter prints them. outline: none is
not a ring for input-state-shift; a form with a password or a sign-in button
is not an email band; one CONTROL_TRACK_MAX_EM (0.04) for both halves of
wide-tracking; one CONTRAST_SLACK; hairline-under-shadow splits layers with
splitTop; a missing linked stylesheet is warned about; TwItem.toks and the
controlHeight scope guard are gone; zero-chroma-neutrals is titled for what
it reads. aside-render sets the device override or viewport only when a
sized step's size changes and clears it before an un-sized step.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: seventh review cycle tests and docs

test/design-lint-seventh-cycle.test.ts (13 tests) pins the stray-tag tail,
the shadow-layer split, the JSX generic and ancestor-closing cases, the
one-mode token table, the per-file component claims under both scopes with
their own suspected rules, the stylesheet beside components, the Reporter
file key, outline: none, the sign-in form, the shared tracking ceiling and
the missing-stylesheet warning. docs/design-lint.md: the token block and its
mode overrides, the stray-tag note, the component-directory claim scope,
the email-band exclusions, the missing-sheet warning and zero-chroma-neutrals
as a fourth narrow reading.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: eighth review cycle fixes

Waivers follow the context's shape, not the --scope flag: one html page keeps
page semantics under either scope (its claim reaches the sheet it links) and a
lone component beside an unlinked stylesheet answers for its own file only.
Every '<' that starts no tag is a stray spaced out of doc.src (a '<!' or '<?'
with a '>' ahead stays), an unclosed comment runs to the end of the file, the
three bare /<[^>]*>/ strips use stripTags, the copy tier drops open script,
style, template and nav tags nothing closes, component tags are paired once
per document, columnCount counts repeat() arithmetically, an unclosed
@starting-style block is read once, the import scan matches import shapes
only, the render locator refuses a non-letter tag and reads the class
attribute linearly, tickers are answered from the tree, a tooltip duration is
not a delay, class tokens are appended in a loop, the emoji dedupe is keyed by
the icon, the emoji classes spell U+FE0F and U+200D as escapes, the contrast
and focus viewports are named once, and the hero-fold title matches what the
probe measures.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: eighth review cycle tests and docs

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: ninth review cycle fixes

nestSelector stops expanding a comma list nested in a comma list past an
8 KB selector, resolveVars never substitutes a token over MAX_RESOLVED and
stops growing a value past it, a stylesheet or <style> block the lint could
not parse (or a linked sheet over the size cap) is an internal-error finding
through the Skipped list, which now carries a kind; stripTags takes a visible
character limit so the emoji and label reads do not pay for an element that
runs to the end of the file; assets.ts records every quoted emoji finding
and component.ts splices the one at its line; columnCount drops named lines
inside repeat(); doc.text is built from the stray-blanked source;
Reporter.report returns the finding; the 1280 probes read their viewport
from the page; openTags loses its unused limit; the CHANGELOG hero-fold note
matches what the probe measures.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: ninth review cycle tests

The fourth-cycle unclosed-run test states its own timeout: its per-fixture
bound is 8 s and the four fixtures together pass bun's 5 s default.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: ninth review cycle performance fixes

css.ts selectorNames reads the tag and class names a selector list mentions
once, so nowrap coverage, wrapping coverage and the roman-em check answer per
element in constant time; shortProseOf reads elements by class from an index
built once per document; font-family-count keeps a Set of family keys; the
import scan gives the optional paren its own whitespace; a JSX expression that
runs to the end of the file fails every ancestor at once; the render locator
reads class and not data-class; tooltip-focus-delay reads the delay slot of
the transition shorthand, so 200ms is a duration like 0.2s.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: ninth review cycle performance tests and docs

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: tenth review cycle fixes

tooltip-focus-delay splits the transition value at top-level commas, so a
delay after cubic-bezier() or steps() is still read; nestSelector budgets one
substitution per '&' before expanding; stripTags with a limit stops reading
at the limit instead of after the next tag; accentedHeads reads each span's
text once and covers classes from one map; a JSX close tag with no '>' is
text, not a failure every ancestor rescans; selectorNames reads the tag that
opens an :is()/:where() list; a linked sheet that cannot be read is
unreadable, not unparseable; the space probes read their viewport from the
page; the internal-error title covers a page or stylesheet the lint could not
read.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: tenth review cycle tests and docs

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: eleventh review cycle fixes

A nested rule under a parent longer than the selector cap falls back to the
parent's first part cut to 256 characters, the :is() wrapper is built only on
the expanding path, and every stored selector is cut at MAX_SELECTOR; the
limited stripTags counts visibility with the whitespace class its callers
collapse on and never cuts a surrogate pair; glWords slides its window by
innerHeight.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: eleventh review cycle tests

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: twelfth review cycle fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: twelfth review cycle tests and docs

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: thirteenth review cycle fix

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: thirteenth review cycle tests

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: adversarial review fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design-lint: adversarial review tests and docs

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-26 17:35:04 +00:00

703 lines
39 KiB
TypeScript

/**
* lib/aside-render.ts — render local HTML through a browser: Aside first,
* gstack's own headless browser as the fallback.
*
* The Aside AI browser (macOS 15+, aside.com) is the primary browser for
* every skill. When it is not installed or not running (Linux, Windows, a
* closed app), the same RenderSpec runs through the `browse` daemon (gstack's
* Playwright/Chromium engine, built by ./setup) — `render()` picks the engine,
* `RenderResult.engine` says which one ran. Local-HTML jobs (make-pdf's print
* pipeline, the diagram render bundle, design previews) all come through here.
*
* How the Aside path works (every fact verified against Aside CLI 1.26):
* 1. Aside refuses `file://` URLs ("Cannot navigate to a file URL without
* local file access"), so the HTML's directory is served over loopback
* with Bun.serve on an ephemeral port for the duration of ONE render.
* An http(s) `file` is opened as it is and nothing is served.
* 2. One `aside repl` process runs ONE generated script: open the page,
* wait, run the steps in order, close the tab. Nothing persists between
* `aside repl` calls and tabs die with the script, so a render is always
* a single script.
* 3. Artifacts are written inside Aside's sandbox (`pwd` = the per-run
* session directory; the sandbox `fs` cannot write anywhere else), the
* script prints `ASIDE_DIR=<pwd>`, and this module copies them out.
* 4. PDFs go through raw CDP `Page.printToPDF` (via `page._sendToTarget`)
* so header/footer templates, tagged PDF, and document outline keep
* working — `page.pdf()` exposes only the Playwright subset.
* 5. Screenshots at a given width use CDP `Emulation.setDeviceMetricsOverride`
* (there is no `setViewportSize`).
* 6. The CLI exit code is 0 even when the script throws; truth is the
* `GSTACK_RENDER_OK` sentinel on stdout. A `[error` line means failure.
*
* How the browse path works: the same loopback server (so relative fetches
* and assets behave identically), then one daemon CLI call per action —
* `newtab --json`, `goto`, `js` polling for readiness, `pdf --from-file`,
* `viewport` + `screenshot`, `js --out` (the daemon decodes data: URLs), and
* `closetab` in a finally. Artifacts are written under /tmp (the daemon's
* safe-dirs policy) and copied to the caller's paths. The console-error
* bookkeeping is best-effort: after a `cookie-import` the daemon refuses `js`
* on other origins, and a pdf/screenshot-only spec must still print. Not
* mirrored on this path: pageRanges/scale, screenshot quality, the 2x default
* device scale, and an eval step's deviceScaleFactor and mobile (the browse
* daemon's `viewport` takes a size alone).
*
* Node builtins + Bun only (bun build --compile embeds this into make-pdf).
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
export const RENDER_SENTINEL = 'GSTACK_RENDER_OK';
const DEFAULT_TIMEOUT_MS = 120_000;
/** Slack over the script budget so the `aside repl` process can wind down before we kill it;
* it also scales runProc's kill and give-up allowances. GSTACK_RENDER_SLACK_MS lets tests shrink it. */
function processSlackMs(): number {
const ms = Number(process.env.GSTACK_RENDER_SLACK_MS);
return Number.isInteger(ms) && ms > 0 ? ms : 10_000;
}
/** Default budget for a waitFor selector/expression, on either engine. */
const DEFAULT_WAIT_MS = 30_000;
/** Default cap (chars) on an inline eval result. */
const DEFAULT_MAX_INLINE = 20_000;
/** Screenshot height when only a width is given (4:3). */
const DEFAULT_ASPECT = 0.75;
/** Widths at or below this emulate a mobile device. */
const MOBILE_MAX_WIDTH = 1024;
/** Device scale for sized screenshots on the Aside path (the daemon keeps its own scale: a change there rebuilds its context). */
const DEFAULT_DEVICE_SCALE = 2;
/** The page-number footer shared by make-pdf, gstack-render and the browse `pdf` command. */
export const PAGE_NUMBER_FOOTER = '<div style="font-size:9pt; font-family:Helvetica,Arial,sans-serif; color:#666; width:100%; text-align:center;"><span class="pageNumber"></span> of <span class="totalPages"></span></div>';
// ─── Availability ────────────────────────────────────────────────────────────
export type AsideProbe =
| { ok: true; version: string }
| { ok: false; reason: 'NEEDS_ASIDE' | 'ASIDE_NOT_RUNNING'; detail: string };
/** Same probe the skills run in BROWSER SETUP: binary present, app answering. */
export function probeAside(timeoutMs = 30_000): AsideProbe {
if (process.env.GSTACK_SKIP_ASIDE === '1') {
return { ok: false, reason: 'NEEDS_ASIDE', detail: 'GSTACK_SKIP_ASIDE=1 — Aside skipped by request' };
}
const which = spawnSync('aside', ['--version'], { encoding: 'utf8', timeout: 10_000 });
if (which.error) {
return { ok: false, reason: 'NEEDS_ASIDE', detail: 'the `aside` CLI is not on PATH — install the Aside browser (macOS 15+) from aside.com' };
}
if (which.status !== 0) {
// Present but not answering: the same class the skills' bash probe reports
// (open or repair the app), never "install it".
return { ok: false, reason: 'ASIDE_NOT_RUNNING', detail: `\`aside --version\` exited ${which.status}: ${(which.stderr || which.stdout || '').trim().slice(0, 300) || 'no output'}` };
}
const probe = spawnSync('aside', ['repl', 'console.log("ASIDE_READY " + pwd)'], { encoding: 'utf8', timeout: timeoutMs });
const out = `${probe.stdout ?? ''}${probe.stderr ?? ''}`;
if (!/^ASIDE_READY /m.test(out)) {
return { ok: false, reason: 'ASIDE_NOT_RUNNING', detail: (out.trim() || probe.error?.message || 'no answer from the Aside app').slice(0, 400) };
}
return { ok: true, version: (which.stdout ?? '').trim() };
}
// ─── Spec ────────────────────────────────────────────────────────────────────
/** CDP Page.printToPDF options, plus an optional Paged.js wait. Inches for paper/margins. */
export interface PdfStepOptions {
paperWidth?: number;
paperHeight?: number;
landscape?: boolean;
marginTop?: number;
marginRight?: number;
marginBottom?: number;
marginLeft?: number;
displayHeaderFooter?: boolean;
headerTemplate?: string;
footerTemplate?: string;
printBackground?: boolean;
preferCSSPageSize?: boolean;
generateTaggedPDF?: boolean;
generateDocumentOutline?: boolean;
pageRanges?: string;
scale?: number;
/**
* Wait (≤3s, non-fatal) for `window.__pagedjsAfterFired` before printing —
* only for pages that load Paged.js themselves (gstack-render
* --wait-pagedjs). make-pdf ships no pagination script and never sets it:
* its TOC page numbers come from make-pdf/src/toc-pages.ts.
*/
waitForPagedJs?: boolean;
}
export type RenderStep =
| { kind: 'pdf'; out: string; options?: PdfStepOptions }
| { kind: 'screenshot'; out: string; width?: number; height?: number; deviceScaleFactor?: number; mobile?: boolean; fullPage?: boolean; selector?: string; type?: 'png' | 'jpeg'; quality?: number }
/**
* Evaluate a JS expression in the page (promises are awaited). With `out`,
* the result is written to that file: strings verbatim; `data:` URLs are
* decoded to bytes; other values as JSON. Without `out`, the result comes
* back in `RenderResult.evals` (strings are truncated to `maxInline` chars).
* With `width`, the viewport is set before the eval the way a sized
* screenshot sets it, and cleared after.
*/
| { kind: 'eval'; expression: string; out?: string; maxInline?: number; width?: number; height?: number; deviceScaleFactor?: number; mobile?: boolean };
export interface RenderSpec {
/** Absolute path of the HTML file to open, or an http(s) URL (opened as it is, nothing served). */
file: string;
/** Directory served over loopback (default: the file's directory). Must contain `file`. */
serveRoot?: string;
/** Readiness: a selector that must be attached, and/or an expression that must be truthy. */
waitFor?: { selector?: string; expression?: string; timeoutMs?: number };
steps: RenderStep[];
/** Whole-script budget passed to the `aside repl` process. Aside caps a script at 120s. */
timeoutMs?: number;
}
export type RenderEngine = 'aside' | 'browse';
export interface RenderResult {
ok: boolean;
/** Which browser ran the spec (absent when none could). */
engine?: RenderEngine;
/** Files written on the caller's side, in step order (steps without `out` contribute nothing). */
outputs: string[];
/** Inline eval results keyed by step index. */
evals: Record<number, string>;
stdout: string;
error?: string;
}
// ─── Paper + margin helpers (make-pdf's option shapes → CDP inches) ──────────
const PAPER_INCHES: Record<string, [number, number]> = {
letter: [8.5, 11], legal: [8.5, 14], tabloid: [11, 17], ledger: [17, 11],
a0: [33.1, 46.8], a1: [23.4, 33.1], a2: [16.54, 23.4], a3: [11.7, 16.54], a4: [8.27, 11.7], a5: [5.83, 8.27], a6: [4.13, 5.83],
};
/** "1in" | "20mm" | "72px" | "2cm" | "12pt" | bare number (px) → inches. */
export function lengthToInches(v: string | number | undefined): number | undefined {
if (v === undefined || v === null || v === '') return undefined;
if (typeof v === 'number') return v / 96;
const m = String(v).trim().match(/^([0-9]*\.?[0-9]+)\s*(in|mm|cm|px|pt)?$/i);
if (!m) throw new Error(`unsupported length: ${v}`);
const n = parseFloat(m[1]);
switch ((m[2] || 'px').toLowerCase()) {
case 'in': return n;
case 'mm': return n / 25.4;
case 'cm': return n / 2.54;
case 'pt': return n / 72;
default: return n / 96;
}
}
/** Paper format name → [width, height] in inches; undefined for unknown names. */
export function paperInches(format: string | undefined): [number, number] | undefined {
if (!format) return undefined;
return PAPER_INCHES[format.toLowerCase()];
}
// ─── Script generation ───────────────────────────────────────────────────────
const HOOK = `(() => { window.__gstackErrs = window.__gstackErrs || []; const oe = console.error; console.error = (...a) => { window.__gstackErrs.push(a.map(String).join(" ")); oe.apply(console, a); }; window.addEventListener("error", e => window.__gstackErrs.push("uncaught: " + e.message)); })()`;
function artifactName(i: number, out: string): string {
const ext = path.extname(out) || '.bin';
return `gstack-render-${i}${ext}`;
}
export function buildRenderScript(url: string, spec: RenderSpec): string {
const L: string[] = [];
L.push(`const HOOK = ${JSON.stringify(HOOK)};`);
L.push(`const pg = await openTab("about:blank");`);
L.push(`await pg._sendToTarget("Page.addScriptToEvaluateOnNewDocument", { source: HOOK });`);
// "load", not Aside's default "interactive" readiness: a 9MB single-file
// bundle (lib/diagram-render) never satisfies the interactive heuristic and
// times out at 30s, while `load` fires in ~0.5s. Readiness is then explicit
// via waitFor (selector attached / expression truthy).
L.push(`await pg.goto(${JSON.stringify(url)}, { waitUntil: "load", timeout: ${Math.min(90_000, spec.timeoutMs ?? DEFAULT_TIMEOUT_MS)} });`);
const wait = spec.waitFor;
if (wait?.selector) {
L.push(`await pg.waitForSelector(${JSON.stringify(wait.selector)}, { state: "attached", timeout: ${wait.timeoutMs ?? DEFAULT_WAIT_MS} });`);
}
if (wait?.expression) {
L.push(`{ const deadline = Date.now() + ${wait.timeoutMs ?? DEFAULT_WAIT_MS}; let ok = false; while (Date.now() < deadline) { try { ok = !!(await pg.evaluate((src) => (0, eval)(src), ${JSON.stringify(wait.expression)})); } catch (e) {} if (ok) break; await sleep(150); } if (!ok) throw new Error("waitFor expression never became truthy: " + ${JSON.stringify(wait.expression)}); }`);
}
// The device override is set when a sized step's size differs from the one in force and cleared before an un-sized step, so consecutive evals at one width pay one override, not one per step.
let override: string | null = null;
const sized = (step: Exclude<RenderStep, { kind: 'pdf' }>): string | null => (step.width ? JSON.stringify({ width: step.width, height: step.height ?? Math.round(step.width * DEFAULT_ASPECT), deviceScaleFactor: step.deviceScaleFactor ?? DEFAULT_DEVICE_SCALE, mobile: step.mobile ?? step.width <= MOBILE_MAX_WIDTH }) : null);
const setOverride = (want: string | null) => {
if (want === override) return;
if (want) L.push(`await pg._sendToTarget("Emulation.setDeviceMetricsOverride", ${want}); await sleep(250);`);
else L.push(`await pg._sendToTarget("Emulation.clearDeviceMetricsOverride", {});`);
override = want;
};
spec.steps.forEach((step, i) => {
if (step.kind === 'pdf') {
setOverride(null);
const o = step.options ?? {};
if (o.waitForPagedJs) {
L.push(`{ const deadline = Date.now() + 3000; let ready = false; while (Date.now() < deadline) { try { ready = await pg.evaluate(() => !!window.__pagedjsAfterFired); } catch (e) {} if (ready) break; await sleep(150); } }`);
}
const cdp: Record<string, unknown> = {};
for (const k of ['paperWidth', 'paperHeight', 'landscape', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'displayHeaderFooter', 'headerTemplate', 'footerTemplate', 'printBackground', 'preferCSSPageSize', 'generateTaggedPDF', 'generateDocumentOutline', 'pageRanges', 'scale'] as const) {
if (o[k] !== undefined) cdp[k] = o[k];
}
L.push(`{ const r = await pg._sendToTarget("Page.printToPDF", ${JSON.stringify(cdp)}); await fs.writeFile(path.join(pwd, ${JSON.stringify(artifactName(i, step.out))}), Buffer.from(r.data, "base64")); console.log("STEP_OK ${i}"); }`);
} else if (step.kind === 'screenshot') {
const name = artifactName(i, step.out);
const shot: Record<string, unknown> = { path: name, fullPage: step.fullPage !== false };
if (step.type) shot.type = step.type;
if (step.quality !== undefined) shot.quality = step.quality;
setOverride(sized(step));
if (step.selector) {
const sel: Record<string, unknown> = { path: name };
if (step.type) sel.type = step.type;
L.push(`await pg.locator(${JSON.stringify(step.selector)}).screenshot(${JSON.stringify(sel)});`);
} else {
L.push(`await pg.screenshot(${JSON.stringify(shot)});`);
}
setOverride(null);
L.push(`console.log("STEP_OK ${i}");`);
} else {
// eval: promises are awaited by evaluate; write or inline the result
setOverride(sized(step));
L.push(`{ const v = await pg.evaluate((src) => (0, eval)(src), ${JSON.stringify(step.expression)});`);
if (step.out) {
L.push(` const name = ${JSON.stringify(artifactName(i, step.out))};`);
L.push(` if (typeof v === "string" && /^data:[^;]+;base64,/.test(v)) await fs.writeFile(path.join(pwd, name), Buffer.from(v.slice(v.indexOf(",") + 1), "base64"));`);
L.push(` else if (typeof v === "string") await fs.writeFile(path.join(pwd, name), v);`);
L.push(` else await fs.writeFile(path.join(pwd, name), JSON.stringify(v));`);
L.push(` console.log("STEP_OK ${i}"); }`);
} else {
const max = step.maxInline ?? DEFAULT_MAX_INLINE;
// One base64 line: the value is page-controlled text, and a newline in it
// must never be able to forge ASIDE_DIR= or the sentinel below.
L.push(` const s = typeof v === "string" ? v : JSON.stringify(v); console.log("EVAL ${i} " + Buffer.from(String(s ?? "").slice(0, ${max}), "utf8").toString("base64")); console.log("STEP_OK ${i}"); }`);
}
}
});
setOverride(null);
L.push(`console.log("PAGE_ERRORS=" + JSON.stringify(await pg.evaluate(() => window.__gstackErrs || [])));`);
L.push(`console.log("ASIDE_DIR=" + pwd);`);
L.push(`await closeTab(pg);`);
L.push(`console.log(${JSON.stringify(RENDER_SENTINEL)});`);
return L.join('\n');
}
// ─── Loopback server ─────────────────────────────────────────────────────────
/**
* Serve `root` on 127.0.0.1 for one render. The URL carries a per-render secret
* as its first path segment: a local process that does not know it gets 404 for
* everything, so the render window exposes nothing to neighbours on the box.
* Containment is checked on the REAL path (symlinks are followed only when they
* stay inside the root), and directories are never listed.
*/
export function serveDir(root: string, nonce: string = randomBytes(16).toString('hex')): { url: string; stop: () => void } {
const realRoot = fs.realpathSync(root);
const prefix = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
const inside = (p: string) => p === realRoot || p.startsWith(prefix);
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
fetch(req) {
let pathname: string;
try { pathname = decodeURIComponent(new URL(req.url).pathname); } catch { return new Response('bad request', { status: 400 }); }
if (!pathname.startsWith(`/${nonce}/`)) return new Response('not found', { status: 404 });
pathname = pathname.slice(nonce.length + 1);
const target = path.resolve(realRoot, '.' + pathname);
if (!inside(target)) return new Response('forbidden', { status: 403 });
let real: string;
try { real = fs.realpathSync(target); } catch { return new Response('not found', { status: 404 }); }
if (!inside(real)) return new Response('forbidden', { status: 403 });
if (fs.statSync(real).isDirectory()) return new Response('not found', { status: 404 });
return new Response(Bun.file(real));
},
});
return { url: `http://127.0.0.1:${server.port}/${nonce}`, stop: () => server.stop(true) };
}
// ─── Async spawn (keeps the loopback server's event loop free) ────────────────
async function runProc(cmd: string, args: string[], timeoutMs: number): Promise<{ code: number | null; stdout: string; stderr: string; error?: string }> {
let child: Bun.Subprocess<'ignore', 'pipe', 'pipe'>;
try {
child = Bun.spawn([cmd, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
} catch (e) {
return { code: null, stdout: '', stderr: '', error: (e as Error).message };
}
let timedOut = false;
// Every timer is tracked and cleared on exit: a dangling one keeps the event
// loop alive and a CLI with no explicit process.exit (gstack-render) would sit
// for up to timeoutMs after printing its result.
const timers: ReturnType<typeof setTimeout>[] = [];
const after = (ms: number, fn: () => void) => { timers.push(setTimeout(fn, ms)); };
const slackMs = processSlackMs();
after(timeoutMs, () => { timedOut = true; try { child.kill(); } catch {} });
// A child that ignores SIGTERM (a CLI blocked on its app) gets SIGKILL; a
// grandchild holding the pipes open must not hang the render either.
after(timeoutMs + slackMs / 2, () => { try { child.kill('SIGKILL'); } catch {} });
const read = Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]);
const giveUp = new Promise<[string, string]>((resolve) => after(timeoutMs + slackMs, () => resolve(['', ''])));
const [stdout, stderr] = await Promise.race([read, giveUp]);
// Pipes at EOF means the child is exiting; wait for the exit code until the
// SIGKILL above has had its turn. A flat 5s bound here once failed a CI render
// whose fake had already written its artifact — under a 6-shard load the
// reaper needed longer than that, and a null code reads as a failed command.
const code = await Promise.race([child.exited, new Promise<null>((resolve) => after(timeoutMs + slackMs * 3 / 5, () => resolve(null)))]);
for (const t of timers) clearTimeout(t);
return { code, stdout, stderr, error: timedOut ? `timed out after ${timeoutMs}ms` : undefined };
}
// ─── Render: Aside ───────────────────────────────────────────────────────────
export async function renderWithAside(spec: RenderSpec): Promise<RenderResult> {
return { ...(await asideRender(spec)), engine: 'aside' };
}
/** What the engine opens: an http(s) `file` as it is, else the file served from its root over loopback. */
type PageTarget = { kind: 'url'; url: string } | { kind: 'file'; root: string; rel: string } | { kind: 'error'; error: string };
function pageTarget(spec: RenderSpec): PageTarget {
if (/^https?:\/\//.test(spec.file)) return { kind: 'url', url: spec.file };
const file = path.resolve(spec.file);
if (!fs.existsSync(file)) return { kind: 'error', error: `HTML file not found: ${file}` };
const root = path.resolve(spec.serveRoot ?? path.dirname(file));
const rel = path.relative(root, file);
if (rel.startsWith('..')) return { kind: 'error', error: `file ${file} is outside serveRoot ${root}` };
return { kind: 'file', root, rel };
}
/** Serve a file target for one render; an http(s) target needs no server. */
function openTarget(target: Exclude<PageTarget, { kind: 'error' }>): { url: string; stop: () => void } {
if (target.kind === 'url') return { url: target.url, stop: () => {} };
const srv = serveDir(target.root);
return { url: `${srv.url}/${target.rel.split(path.sep).map(encodeURIComponent).join('/')}`, stop: srv.stop };
}
async function asideRender(spec: RenderSpec): Promise<RenderResult> {
const target = pageTarget(spec);
if (target.kind === 'error') return { ok: false, outputs: [], evals: {}, stdout: '', error: target.error };
const srv = openTarget(target);
try {
const script = buildRenderScript(srv.url, spec);
// Async spawn: a synchronous wait would block this event loop, and the
// loopback server above runs on it — Page.navigate would then time out.
const proc = await runProc('aside', ['repl', script], (spec.timeoutMs ?? DEFAULT_TIMEOUT_MS) + processSlackMs());
const stdout = `${proc.stdout}${proc.stderr}`.replace(/\x1b\[[0-9;]*m/g, '');
const evals: Record<number, string> = {};
for (const m of stdout.matchAll(/^EVAL (\d+) ([A-Za-z0-9+/=]*)$/gm)) evals[Number(m[1])] = Buffer.from(m[2], 'base64').toString('utf8');
if (proc.error) return { ok: false, outputs: [], evals, stdout, error: `aside repl did not run: ${proc.error}` };
if (!stdout.split('\n').some((l) => l.trim() === RENDER_SENTINEL)) {
const errLine = stdout.split('\n').find((l) => /^(\[error|Error:|\w*Error:)/.test(l.trim())) ?? stdout.trim().split('\n').slice(-3).join(' | ');
return { ok: false, outputs: [], evals, stdout, error: `render script did not finish: ${errLine || 'no output'} (GSTACK_SKIP_ASIDE=1 forces gstack's own browser)` };
}
// Control lines are ours alone (eval output is one base64 token, PAGE_ERRORS
// is one JSON line); still take the LAST ASIDE_DIR so nothing earlier wins.
const dirs = [...stdout.matchAll(/^ASIDE_DIR=(.+)$/gm)];
const dir = dirs.length ? dirs[dirs.length - 1][1].trim() : undefined;
if (!dir) return { ok: false, outputs: [], evals, stdout, error: 'render script printed no ASIDE_DIR' };
const outputs: string[] = [];
for (const [i, step] of spec.steps.entries()) {
if (!('out' in step) || !step.out) continue;
const src = path.join(dir, artifactName(i, step.out));
if (!fs.existsSync(src)) return { ok: false, outputs, evals, stdout, error: `step ${i} produced no artifact (${src})` };
fs.mkdirSync(path.dirname(path.resolve(step.out)), { recursive: true });
fs.copyFileSync(src, step.out);
outputs.push(step.out);
}
return { ok: true, outputs, evals, stdout };
} finally {
srv.stop();
}
}
/** Where callers may stage HTML so the loopback server can reach it. */
export function renderTmpDir(): string {
const dir = path.join(os.tmpdir(), 'gstack-render');
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
// Ours: a real directory we own. Anything else at the shared name (another
// user's directory, a planted symlink) is never staged into — fall back to a
// private mkdtemp so a neighbour on the box cannot swap files under a render.
const ours = (): boolean => {
try { const st = fs.lstatSync(dir); return st.isDirectory() && !st.isSymbolicLink() && (uid === undefined || st.uid === uid); } catch { return false; }
};
if (ours()) return dir;
try { fs.mkdirSync(dir, { mode: 0o700 }); } catch { /* exists or unwritable — decided below */ }
return ours() ? dir : fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-render-'));
}
// ─── Render: browse (gstack's own headless browser, the fallback) ────────────
/** Roots that may hold browse/dist/browse or the browse/bin/find-browse shim. */
const BROWSE_ROOTS = [
path.resolve(import.meta.dir, '..'), // repo checkout: lib/ → root
path.resolve(path.dirname(process.execPath), '../..'), // compiled make-pdf/dist/pdf → root (repo and global install alike)
path.join(os.homedir(), '.claude/skills/gstack'),
];
/** The daemon only reads/writes under its safe dirs; /tmp is always one of them. */
export const SAFE_TMP_DIR = process.platform === 'win32' ? os.tmpdir() : '/tmp';
/** A regular, executable file — probing .exe/.cmd/.bat on Windows, where X_OK degrades to an existence check. */
function executable(p: string): string | null {
for (const c of process.platform === 'win32' ? [p, `${p}.exe`, `${p}.cmd`, `${p}.bat`] : [p]) {
try {
if (fs.statSync(c).isFile()) { fs.accessSync(c, fs.constants.X_OK); return c; }
} catch { /* next candidate */ }
}
return null;
}
/**
* Locate gstack's own browse binary: $GSTACK_BROWSE_BIN → $BROWSE_BIN →
* <root>/browse/dist/browse → <root>/browse/bin/find-browse (per root, repo
* then install) → `browse` on PATH. Null when nothing resolves.
*/
export function resolveBrowseBin(env: NodeJS.ProcessEnv = process.env, roots: string[] = BROWSE_ROOTS): string | null {
const PATH = env.PATH ?? env.Path ?? '';
const override = (env.GSTACK_BROWSE_BIN ?? env.BROWSE_BIN ?? '').trim().replace(/^"(.*)"$/, '$1');
if (override) {
const found = path.isAbsolute(override) ? executable(override) : Bun.which(override, { PATH });
if (found) return found;
}
for (const root of roots) {
const built = executable(path.join(root, 'browse/dist/browse'));
if (built) return built;
const shim = executable(path.join(root, 'browse/bin/find-browse'));
if (!shim) continue;
const r = spawnSync(shim, [], { encoding: 'utf8', timeout: 10_000 });
const found = r.status === 0 ? executable((r.stdout ?? '').trim()) : null;
if (found) return found;
}
return Bun.which('browse', { PATH }) ?? null;
}
/** PdfStepOptions (CDP, inches) → the browse `pdf --from-file` payload (Playwright shapes, string lengths). */
export function browsePdfPayload(o: PdfStepOptions, output: string): Record<string, unknown> {
const p: Record<string, unknown> = { output };
let [w, h] = [o.paperWidth, o.paperHeight];
if (o.landscape) [w, h] = [h ?? 11, w ?? 8.5]; // browse has no landscape flag: swap (Letter when unset)
if (w !== undefined && h !== undefined) { p.width = `${w}in`; p.height = `${h}in`; }
for (const k of ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'] as const) {
if (o[k] !== undefined) p[k] = `${o[k]}in`;
}
if (o.displayHeaderFooter) {
p.headerTemplate = o.headerTemplate ?? '<div></div>';
p.footerTemplate = o.footerTemplate ?? '<div></div>';
}
if (o.generateTaggedPDF) p.tagged = true;
if (o.generateDocumentOutline) p.outline = true;
if (o.printBackground) p.printBackground = true;
if (o.preferCSSPageSize) p.preferCSSPageSize = true;
if (o.waitForPagedJs) p.toc = true;
return p;
}
type ScreenshotStep = Extract<RenderStep, { kind: 'screenshot' }>;
/** Screenshot step → browse `screenshot` args (the path's extension picks png/jpeg). */
export function browseScreenshotArgs(step: ScreenshotStep, output: string): string[] {
const args = ['screenshot'];
if (step.fullPage === false) args.push('--viewport');
if (step.selector) args.push('--selector', step.selector);
args.push(output);
return args;
}
function screenshotName(i: number, step: ScreenshotStep): string {
const ext = step.type === 'jpeg' ? '.jpg' : step.type === 'png' ? '.png' : (path.extname(step.out) || '.png');
return `gstack-render-${i}${ext}`;
}
/**
* Run a RenderSpec through the browse daemon. Same loopback server as the
* Aside path, one CLI call per action, artifacts staged under /tmp and copied
* to the caller's paths. The tab is closed in a finally; the daemon stays up.
*/
export async function renderWithBrowse(spec: RenderSpec, bin: string | null = resolveBrowseBin()): Promise<RenderResult> {
const outputs: string[] = [];
const evals: Record<number, string> = {};
const log: string[] = [];
const fail = (error: string): RenderResult => ({ ok: false, engine: 'browse', outputs, evals, stdout: log.join('\n'), error });
if (!bin) return fail(`${NO_BROWSER}: ${NO_BROWSER_HELP}`);
const target = pageTarget(spec);
if (target.kind === 'error') return fail(target.error);
const deadline = Date.now() + (spec.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const run = async (args: string[]): Promise<string> => {
const r = await runProc(bin, args, Math.max(1_000, Math.min(120_000, deadline - Date.now())));
log.push(`$ browse ${args.join(' ').slice(0, 300)}\n${r.stdout}${r.stderr}`.trim());
if (r.error || r.code !== 0) {
const first = (r.stderr || r.stdout || r.error || '').trim().split('\n')[0];
if (/JS execution blocked/.test(`${r.stderr}${r.stdout}`)) {
// After `$B cookie-import` the daemon refuses page JS on every other
// origin, 127.0.0.1 included; a local-HTML render cannot proceed in it.
throw new Error(`browse ${args[0]} refused: the daemon has imported cookies and blocks page JS on other origins (127.0.0.1 included) — restart it ($B stop) before rendering local HTML, or open Aside`);
}
throw new Error(`browse ${args[0]} failed: ${first}`);
}
return r.stdout;
};
const copyOut = (src: string, out: string, i: number) => {
if (!fs.existsSync(src)) throw new Error(`step ${i} produced no artifact (${src})`);
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
fs.copyFileSync(src, out);
outputs.push(out);
};
let work: string | undefined;
let srv: { url: string; stop: () => void } | undefined;
let tab: number | undefined;
try {
work = fs.mkdtempSync(path.join(SAFE_TMP_DIR, 'gstack-render-browse-'));
srv = openTarget(target);
// The first CLI call auto-starts the daemon; on a cold start it can answer
// "Unable to connect" once while the server is still coming up. One retry
// after a short pause turns that into the wait it really is.
const openTab = async () => (await run(['newtab', '--json'])).match(/\{[^\n]*"tabId"[^\n]*\}/)?.[0];
let opened: string | undefined;
try {
opened = await openTab();
} catch (e) {
if (!/Unable to connect/.test((e as Error).message)) throw e;
log.push('newtab: daemon not up yet — retrying once');
await Bun.sleep(1_500);
opened = await openTab();
}
tab = opened ? JSON.parse(opened).tabId : undefined;
if (typeof tab !== 'number') throw new Error('browse newtab --json returned no tabId');
const T = ['--tab-id', String(tab)];
const js = async (expr: string, extra: string[] = []) => (await run(['js', expr, ...extra, ...T])).replace(/\n$/, '');
// Poll until truthy. A throw inside the page (e.g. `window.later.ok` before
// `later` exists) is "not yet", exactly as the Aside script treats it —
// never a render failure. `run` still throws when the daemon itself refuses.
const until = async (expr: string, what: string, timeoutMs: number) => {
const end = Date.now() + timeoutMs;
while (Date.now() < end) {
if ((await js(`(() => { try { return !!(${expr}); } catch (e) { return false; } })()`)) === 'true') return;
await Bun.sleep(150);
}
throw new Error(`${what} (waited ${timeoutMs}ms)`);
};
await run(['goto', srv.url, ...T]);
// Best-effort: once `$B cookie-import` has run, the daemon blocks `js` on
// every other origin (127.0.0.1 included). pdf/screenshot/`js --out` steps
// must still run; a waitFor or eval step that is genuinely blocked fails
// below with the daemon's own message.
const bestEffortJs = async (expr: string, what: string) => { try { return await js(expr); } catch (e) { log.push(`${what} unavailable: ${(e as Error).message}`); return null; } };
// Known divergence from the Aside path: the daemon exposes no
// pre-navigation hook, so errors logged during load are not captured here.
await bestEffortJs(HOOK, 'console hook');
const wait = spec.waitFor;
if (wait?.selector) await until(`document.querySelector(${JSON.stringify(wait.selector)})`, `waitFor selector never attached: ${wait.selector}`, wait.timeoutMs ?? DEFAULT_WAIT_MS);
if (wait?.expression) await until(wait.expression, `waitFor expression never became truthy: ${wait.expression}`, wait.timeoutMs ?? DEFAULT_WAIT_MS);
// The viewport is set when a sized step's size differs from the one in force and reset to the daemon's default before an un-sized step, so consecutive evals at one width pay one `viewport` spawn, not two per step.
let vp: string | null = null;
const viewport = async (want: string | null) => {
if (want === vp) return;
await run(['viewport', want ?? '1280x720', ...T]);
vp = want;
};
for (const [i, step] of spec.steps.entries()) {
if (step.kind === 'pdf') {
await viewport(null);
const tmp = path.join(work, artifactName(i, step.out));
const payload = path.join(work, `pdf-${i}.json`);
fs.writeFileSync(payload, JSON.stringify(browsePdfPayload(step.options ?? {}, tmp)));
await run(['pdf', '--from-file', payload, ...T]);
copyOut(tmp, step.out, i);
} else if (step.kind === 'screenshot') {
const tmp = path.join(work, screenshotName(i, step));
if (step.width) {
const args = [`${step.width}x${step.height ?? Math.round(step.width * DEFAULT_ASPECT)}`];
// `--scale` recreates the daemon's browser context (and is refused in
// headed mode), so it is passed only when the caller asked for it; the
// 2x default stays Aside-only (see the header's "not mirrored" list).
if (step.deviceScaleFactor) args.push('--scale', String(step.deviceScaleFactor));
await run(['viewport', ...args, ...T]);
vp = args[0];
} else await viewport(null);
await run([...browseScreenshotArgs(step, tmp), ...T]);
copyOut(tmp, step.out, i);
// Aside clears its device override after each shot; restore the daemon's
// default so a later un-sized screenshot is not taken at this width.
if (step.width) await viewport(null);
} else {
await viewport(step.width ? `${step.width}x${step.height ?? Math.round(step.width * DEFAULT_ASPECT)}` : null);
if (step.out) {
const tmp = path.join(work, artifactName(i, step.out));
await js(step.expression, ['--out', tmp]); // the daemon decodes data: URLs to bytes itself
copyOut(tmp, step.out, i);
} else {
evals[i] = (await js(step.expression)).slice(0, step.maxInline ?? DEFAULT_MAX_INLINE);
}
}
}
await viewport(null);
const errs = await bestEffortJs('JSON.stringify(window.__gstackErrs || [])', 'PAGE_ERRORS');
if (errs !== null) log.push(`PAGE_ERRORS=${errs}`);
return { ok: true, engine: 'browse', outputs, evals, stdout: log.join('\n') };
} catch (e) {
return fail((e as Error).message);
} finally {
if (tab !== undefined) await runProc(bin, ['closetab', String(tab)], 15_000);
srv?.stop();
if (work) fs.rmSync(work, { recursive: true, force: true });
}
}
// ─── Engine choice ───────────────────────────────────────────────────────────
export const NO_BROWSER = 'no browser available';
export const NO_BROWSER_HELP = "open the Aside app (macOS 15+, aside.com), or run ./setup in the gstack repo to build gstack's own headless browser (or point GSTACK_BROWSE_BIN at a browse binary)";
export type EngineChoice =
| { engine: 'aside'; version: string }
| { engine: 'browse'; bin: string }
| { engine: null; probe: Extract<AsideProbe, { ok: false }>; error: string };
let chosen: EngineChoice | undefined;
/** Aside when it answers, else gstack's own browser, else neither. Cached per process (the Aside probe is a round-trip). */
export function pickEngine(fresh = false, deps: { probe?: () => AsideProbe; resolveBin?: () => string | null } = {}): EngineChoice {
if (chosen && !fresh) return chosen;
const probe = (deps.probe ?? probeAside)();
if (probe.ok) return (chosen = { engine: 'aside', version: probe.version });
const bin = (deps.resolveBin ?? resolveBrowseBin)();
if (bin) return (chosen = { engine: 'browse', bin });
return (chosen = { engine: null, probe, error: `${NO_BROWSER}: ${NO_BROWSER_HELP} (${probe.reason}: ${probe.detail})` });
}
/**
* Render through whichever browser is available; `error` starts with NO_BROWSER
* when neither is. If Aside was chosen but its process could not run (the app
* quit mid-job, the CLI hung past its budget), the same spec is retried once on
* gstack's own browser when that is built, and the choice sticks for the rest
* of the process. A script-level failure (the page itself) is NOT retried.
*/
export async function render(spec: RenderSpec): Promise<RenderResult> {
const c = pickEngine();
if (c.engine === 'aside') {
const r = await renderWithAside(spec);
// Retry on gstack's own browser when Aside could not START (spawn error,
// not a timeout of a script that was already navigating) or its private
// CDP bridge is gone (an Aside release renamed `_sendToTarget`). A page
// failure is the page's, on either engine.
if (!r.ok && /^aside repl did not run: (?!timed out)|_sendToTarget|openTab is not defined/.test(r.error ?? '')) {
const bin = resolveBrowseBin();
if (bin) {
chosen = { engine: 'browse', bin };
const fb = await renderWithBrowse(spec, bin);
return { ...fb, stdout: `[aside unavailable mid-run: ${r.error}] retried on gstack's own browser\n${fb.stdout}` };
}
}
return r;
}
if (c.engine === 'browse') return renderWithBrowse(spec, c.bin);
return { ok: false, outputs: [], evals: {}, stdout: '', error: c.error };
}