ReviewOS

stacks/stx

Agent guide for writing stx (12 chapters, audited against a real app)

#1791
Closed glennmichael123 opened this 10 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
10 days ago

Agent guide for writing stx

A prescriptive guide for AI coding agents writing .stx files, produced by auditing a real production app (bughq, stx 0.2.113) against the framework surface it was failing to use.

The twelve chapters are posted as comments below. Each rule is written as RULE → WHY (with framework file:line) → WRONG (real code) → RIGHT → CHECK (a runnable command).

Why this exists

bughq is ~6,500 lines of stx written largely by AI agents. It works. It is also, structurally, an app that never switched the framework on:

Pages carrying their own <!DOCTYPE>24
Internal plain <a href="/…">276 (264 after the 12 deliberate data-no-router opt-outs)
<StxLink> elements29, in 11 files — none in SiteNav.stx (35 anchors) or SiteFooter.stx (26)
Files with an inline <style> block23 under views/partials/components (6 more are SVG assets, where it is legitimate)
Stores0
Uses of @stacksjs/components (45 primitives installed)0
useSeoMeta calls0
onMount / onDestroy in any .stx0
Prohibited DOM calls68 across 10 files

The root cause is one line of the framework:

// process.js:311
const hasDoctype = /<!DOCTYPE\s/i.test(output), hasSections = /@section\s*\(/.test(output)
if (!hasDoctype || hasSections) {   // ← a doctype opts the page out of layouts entirely

Every page that writes its own document silently disables layout resolution, which disables runtime head config, which is why useHead/useSeoMeta cannot work on those pages, which is why the shell is duplicated 24 times, which is why the app/marketing boundary exists, which is why interceptAllLinks: true is load-bearing. One regex, the whole architecture downstream of it.

A companion RFC covering the framework-side changes is filed separately.

The rules

#Rule
1Never write <!DOCTYPE>, <html>, <head> or <body> in a page or component
2Never write vanilla DOM code
3Never write vanilla CSS — Crosswind only
4Use StxLink for every internal navigation
5Use useHead / useSeoMeta — never hand-write <title> or meta in a page
6Use @stacksjs/components before hand-rolling any UI primitive
7Cross-page state goes in a store, not duplicated per page
8Use directives, not JS that builds markup
9Never hand-import an auto-imported symbol
10Annotate every parameter, every return, and all server data
10bShared types live in types/ as ambient declarations
11At most two script blocks per file; client, setup and lang="ts" are redundant
12Never let user data reach a script block unencoded

Rule 10 needs saying out loud

A .stx script block is compiled as TypeScript whether or not you type it — shouldTranspileTypeScript() (utils.js:28-33) returns true unless the block is tagged lang="js", and server blocks go through new Bun.Transpiler({ loader: 'ts' }) (variable-extractor.js:218).

So there is no "JavaScript phase" of an stx codebase and no migration to schedule. There is only typed TypeScript and untyped TypeScript — and untyped TypeScript is strictly worse than JavaScript, because it pays the cost of a compiler and delivers none of the guarantee. Annotate as you write, not in a later pass.

The audited app had 12 annotations across 123 functions, leaving ~58 of 70 parameters implicitly any. It was described as TypeScript throughout.

Know what annotations are worth right now: nothing verifies them. tsc --noEmit cannot see inside .stx files. Verified by putting const x: number = 'a string' in a client block — tsc silent, page renders 200, linter silent. Three gates, none caught it. That is an argument for fixing the toolchain, not for skipping types, but it does mean a wrong annotation is a real defect rather than something a compiler catches. The framework's own useLocalStorage declares : void while returning a ref (#1795) — that is what unchecked types decay into.

Two auto-import consequences that will bite:

  • Only import type is ever acceptable, and only for types. Auto-imported symbols have ambient declarations in dist/stx.d.ts.
  • Never write the generic form at a client call site. Detection matches \b<symbol>\s*\( (client-script.js:315), so useLocalStorage<string>(…) puts a < where the ( must be — the symbol is never detected and you get a ReferenceError. Annotate the destination instead.

Verification

CheckResult
file:line citations resolved and in range1,843 / 1,850 (99.6%)
Citations to a non-existent file1
Citations overshooting EOF4, by 1–7 lines
Findings behind the guide60 raised → 33 confirmed, 27 refuted under adversarial review

Errata: several chapters say @extends/@section/@yield are used zero times. Precisely: zero times in pages. The two layout files do define slots (layouts/default.stx:39,42,44, layouts/marketing.stx:6,7,11,12,13) that nothing fills.

Chapters

  1. Project topology and configuration
  2. Page anatomy — never write a document
  3. Layouts and the application shell
  4. Head, SEO and structured data
  5. Server blocks and the server-to-client bridge
  6. State: signals, stores and composables
  7. Templates: directives and bindings
  8. No vanilla JS — the strict-mode mandate
  9. Navigation and routing
  10. Components
  11. Styling with Crosswind, and TypeScript
  12. Verification and enforcement

Correction (post-filing)

Three figures in the table above were wrong when this issue was first published, and have been fixed:

FigurePublishedCorrectCause
StxLink uses5729counted <StxLink and </StxLink as separate uses
Internal anchors299276grep 'href="/' also matched 23 <link rel=stylesheet href="/…">
Files with <style>29236 of the 29 are SVGs under resources/assets/, where <style> is normal

Commands the corrected numbers come from:

grep -rho '<StxLink' resources/ | wc -l                    # 29
grep -rhoE '<a [^>]*href="/' resources/ | wc -l            # 276
grep -rl '<style>' resources/views resources/partials resources/components | wc -l   # 23

The conclusions are unchanged — if anything StxLink adoption is half what was reported. The chapter comments below were independently checked: 1,843 of 1,850 file:line citations resolve and are in range (99.6%).

10 days ago

1. Project topology and configuration

There is no index.html in this project and there must never be one. config/ui.ts is the application root: it declares where pages, layouts, components, partials and stores live, and app.head is the only document <head> stx generates (process.js:205-233 → document-shell.js:101-105). Every rule in this chapter is about making that declaration true — because today three of the five keys config/ui.ts sets resolve to directories that do not exist, and the app only boots because two different servers pass overrides on top.

The two config loaders (read this before touching config/ui.ts)

config/ui.ts is loaded twice, by two different loaders, with different semantics. Every topology bug in bughq is a consequence of not knowing this.

Loader A — loadStxConfig()Loader B — raw bunfig
Entry pointnode_modules/@stacksjs/stx/dist/config.js:387node_modules/bun-plugin-stx/dist/serve.js:8912-8919
Defaults mergedstx defaultConfig (config.js:16-24), ssr: falsedefaultStxConfig (serve.js:8828-8835), ssr: true
Applies resolveStxRootYes (config.js:408)No
Prefixes partialsDir/componentsDir/layoutsDir with rootYes (config.js:411-419)No
Loads plugins → _pluginComponentDirsYes (config.js:420-468)No (imports A's result at serve.js:8922-8930)
Used bystx build (build.js:4), SSG, production-builder.js:46-50, store-loader.js:11, Crosswind config discoverythe dev server (bun scripts/dev.ts) and buddy serve request path

A relative directory value cannot be correct in both loaders unless root is '.' or the value is absolute. Verified by running both against this repo:

config/ui.ts value: componentsDir: 'resources/components'
  Loader B (serve): "resources/components"           EXISTS
  Loader A (build): "resources/resources/components" MISSING

Directory option inventory

Every path-bearing key in StxConfig, its default, what it resolves against, and bughq's current state.

Keyconfig-types.d.tsDefaultResolved againstPrimary consumerbughq today
root:260inferred by resolveStxRoot (config.js:363-374)cwdconfig.js:411, utils.js:321, store-loader.js:11inferred 'resources'
pagesDir:262'pages'rootbuild.js:10, stx-router Router (file-router.js:38-40)inferred 'views'
componentsDir:273'components' (config.js:20)root (config.js:415-416)utils.js:327, utils.js:397'resources/components' → resources/resources/components (missing)
layoutsDir:274'layouts' (config.js:21)root (config.js:417-418)process.js:329-331'resources/layouts' → resources/resources/layouts (missing)
partialsDir:272'partials' (config.js:19)root (config.js:413-414)includes.js:245-247, includes.js:480-508'resources/partials' → resources/resources/partials (missing)
storesDir:275'stores'root (store-loader.js:11)process.js:245unset — no stores dir exists
publicDir:278'public' (serve.js:8938)cwdserve static handlerunset → public/
stateDir:261'.stx' (state-dir.js:2)cwdroute codegen, HTML cacheunset → .stx/
cachePath:283'.stx/cache'cwdcaching.jsunset
defaultLayout:276'default' (config.js:22)layoutsDirprocess.js:329unset — and inert in serve, see 1.6
templatesDir:263'.'cwddocs.js:559 onlyunset — no runtime effect
build.pagesDir / build.outputDirBuildConfig'pages' / 'dist' (config.js:29-30)cwdssg.jsunset → wrote dist/
plugins:277—package resolutionconfig.js:420-468unset (see the components chapter)
css:281—cwdCrosswind loaderunset (see the Crosswind chapter)

Non-configurable, hardcoded paths you cannot move: resources/assets (serve.js:9305), config/stx.ts for loadStxPartialsDir (@stacksjs/buddy/dist/production-server.js:70-75 — bughq's file is config/ui.ts, so this lookup always returns undefined), and the components directory the dev/prod servers force (@stacksjs/actions/dist/dev/views.js:71, production-server.js:121).


1.1 MUST pin root and pagesDir explicitly

RULE. config/ui.ts must declare root: '.' and pagesDir: 'resources/views'. Never let stx infer them.

WHY. resolveStxRoot (config.js:363-374) returns early only when you supply root:

// config.js:363-374
function resolveStxRoot(configRoot, configPagesDir) {
  const defaultPagesDir = configPagesDir || "pages";
  if (configRoot) return { root: configRoot, pagesDir: defaultPagesDir };
  const resourcesViews = path.join(process.cwd(), "resources", "views"),
        resourcesLayouts = path.join(process.cwd(), "resources", "layouts");
  if (fs.existsSync(resourcesViews) && fs.existsSync(resourcesLayouts))
    return { root: "resources", pagesDir: "views" };
  ...
  return { root: ".", pagesDir: defaultPagesDir };
}

Otherwise the shape of the project is decided by fs.existsSync on two directories. In bughq, resources/layouts/ is empty (0 files, created 23 Jul) and is the sole reason root resolves to 'resources' rather than '.'. root then propagates into: the three-directory prefixing at config.js:411-419, the SSG entry pages dir at build.js:10 (path.join(config.root || '.', config.pagesDir || ...)), the store directory at store-loader.js:11, and the component convention fallbacks at utils.js:334-338. Deleting one empty directory silently changes all four.

WRONG — config/ui.ts:8-16. No root, no pagesDir:

export default {
  // Components directory - for user-defined components
  componentsDir: 'resources/components',
  // Layouts directory - for layout templates
  layoutsDir: 'resources/layouts',
  // Partials directory - for partial templates
  partialsDir: 'resources/partials',

RIGHT:

export default {
  root: '.',
  pagesDir: 'resources/views',

CHECK.

grep -qE "^\s*root:\s*'\.'" config/ui.ts && grep -qE "^\s*pagesDir:" config/ui.ts \
  || echo "VIOLATION: root/pagesDir are being inferred from directory existence"

1.2 MUST write the three template directories project-root-relative under root: '.'

RULE. With root: '.', write componentsDir, layoutsDir and partialsDir as paths from the project root (resources/components, …). Do not set root: 'resources' and shorten them, and do not leave root inferred.

WHY. config.js:411-419 prefixes all three with root unless the value is absolute:

if (loaded.root && loaded.root !== ".") {
  const rootPrefix = loaded.root;
  if (loaded.partialsDir && !path.isAbsolute(loaded.partialsDir))
    loaded.partialsDir = path.join(rootPrefix, loaded.partialsDir);
  ...
}

Loader B does none of this. root: '.' makes the if false, so both loaders see identical, existing paths — the only relative arrangement that satisfies both (verified: root: 'resources' + layoutsDir: 'layouts' gives Loader A resources/layouts EXISTS and Loader B layouts MISSING).

The current values break real code paths, not hypothetical ones: includes.js:480-508 resolvePath() has no fallback, so /var/www/reviewos-reviewos/releases/7ee24c5/resources/components/IssueThread.stx:107:568: include error: Error loading include file SiteNav: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/7ee24c5/resources/views/partials/SiteNav.stx' ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: IssueThread.stx (line 107, col 568) Message: Error loading include file SiteNav: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/7ee24c5/resources/views/partials/SiteNav.stx' Context:  105: loaded.partialsDir = path.join(rootPrefix, loaded.partialsDir); 106: ... > 107: }

Loader B does none of this. root: '.' makes the if false, so both loaders see identical, existing paths — the only relative arrangement that satisfies both (verified: root: 'resources' + layoutsDir: 'layouts' gives Loader A resources/layouts EXISTS and Loader B layouts MISSING).

The current values break real code paths, not hypothetical ones: includes.js:480-508 resolvePath() has no fallback, so /var/www/reviewos-reviewos/releases/7ee24c5/resources/components/IssueThread.stx:113:632: include error: Error loading include file SiteNav: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/7ee24c5/resources/views/partials/SiteNav.stx' ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: IssueThread.stx (line 113, col 632) Message: Error loading include file SiteNav: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/7ee24c5/resources/views/partials/SiteNav.stx' Context:  111: ENOENT: no such file or directory, open '.../resources/resources/partials/SiteNav.stx'

That absolute path and the ANSI escape codes that follow it are page content in the built artifact.

WRONG — config/ui.ts:10,13,16 (values above). RIGHT:

  componentsDir: 'resources/components',
  112:   layoutsDir: 'resources/layouts',
> 113:   partialsDir: 'resources/partials',

— identical text, but now correct because root: '.' is pinned (1.1). The values are only meaningful together with root.

CHECK. Save as scripts/check-stx-topology.ts and wire it into CI:

import fs from 'node:fs'
  114: import path from 'node:path'
  115: 
 resolves against resources/resources/partials and returns an error string; process.js:328-331 looks for resources/resources/layouts/default.stx, does not find it, and applies no layout at all — no <main>, so stx-router/dist/client.js:796 (if (!getContainer()) return false) makes the SPA router inert. The evidence is on disk in dist/:

$ grep -rl 'include error' dist/ | wc -l
  108: 24
  109: $ head -c 260 dist/layouts/marketing.html | tail -c 120
 resolves against resources/resources/partials and returns an error string; process.js:328-331 looks for resources/resources/layouts/default.stx, does not find it, and applies no layout at all — no <main>, so stx-router/dist/client.js:796 (if (!getContainer()) return false) makes the SPA router inert. The evidence is on disk in dist/:

$ grep -rl 'include error' dist/ | wc -l
24
$ head -c 260 dist/layouts/marketing.html | tail -c 120
<body> .../resources/views/layouts/marketing.stx:10:5: include error: Error loading include file SiteNav:
ENOENT: no such file or directory, open '.../resources/resources/partials/SiteNav.stx'

That absolute path and the ANSI escape codes that follow it are page content in the built artifact.

WRONG — config/ui.ts:10,13,16 (values above). RIGHT:

  componentsDir: 'resources/components',
  layoutsDir: 'resources/layouts',
  partialsDir: 'resources/partials',

— identical text, but now correct because root: '.' is pinned (1.1). The values are only meaningful together with root.

CHECK. Save as scripts/check-stx-topology.ts and wire it into CI:

import fs from 'node:fs'
import path from 'node:path'

const KEYS = ['componentsDir', 'layoutsDir', 'partialsDir'] as const

const { loadConfig } = await import('bunfig')
const raw: any = await loadConfig({ name: 'stx', alias: ['ui'], cwd: process.cwd(), defaultConfig: {}, checkEnv: false, verbose: false })
const served = Object.fromEntries(KEYS.map(k => [k, raw[k]])) as Record<string, string>

const { loadStxConfig } = await import('@stacksjs/stx')
const res: any = await loadStxConfig()

let bad = 0
for (const k of KEYS) {
  if (served[k] !== res[k]) { console.error(`${k}: serve sees ${JSON.stringify(served[k])}, build sees ${JSON.stringify(res[k])}`); bad++ }
  else if (!fs.existsSync(res[k])) { console.error(`${k} -> ${res[k]} does not exist`); bad++ }
}
const pages = path.resolve(res.root === '.' ? '.' : res.root, res.pagesDir)
if (!fs.existsSync(pages)) { console.error(`pagesDir -> ${pages} does not exist`); bad++ }
for (const d of ['layouts', 'components', 'partials', 'stores'])
  if (fs.existsSync(path.join(pages, d))) { console.error(`${d}/ is inside pagesDir and therefore routable`); bad++ }
process.exit(bad ? 1 : 0)

Copying the served values before calling loadStxConfig is required — loadStxConfig mutates the object bunfig caches, so reading raw[k] afterwards silently reports the mutated value. Current output:

componentsDir: serve sees "resources/components", build sees "resources/resources/components"
layoutsDir:    serve sees "resources/layouts",    build sees "resources/resources/layouts"
partialsDir:   serve sees "resources/partials",   build sees "resources/resources/partials"
layouts/ is inside pagesDir and therefore routable
components/ is inside pagesDir and therefore routable

1.3 MUST NOT let an empty directory carry meaning

RULE. Every declared directory must contain the files it claims to contain. An empty directory in resources/ is a defect, not a placeholder.

WHY. resources/layouts/ is empty and is load-bearing in two opposite directions at once. Its existence flips resolveStxRoot to root: 'resources' (config.js:367-369), which causes 1.2's double prefix. Its emptiness is why the dev server picks the other directory: @stacksjs/actions/dist/dev/views.js:59-62 calls firstExistingPath(['resources/views/layouts', 'resources/layouts']), and views.js:115-126 prefers whichever candidate actually globs **/*.stx. Drop one .stx file into resources/layouts/ and both candidates qualify; the resolution then depends on array order. Production disagrees with dev already: @stacksjs/buddy/dist/production-server.js:114 uses existsSync('resources/views/layouts') ? 'resources/views/layouts' : 'resources/layouts' — presence, not content.

WRONG:

$ ls -la resources/layouts/
total 0
drwxr-xr-x  2 ...  64 Jul 23 20:31 .

RIGHT. Move the real layouts in and delete the old directory, so firstExistingPath, existsSync and config/ui.ts all name the same place:

git mv resources/views/layouts/default.stx   resources/layouts/default.stx
git mv resources/views/layouts/marketing.stx resources/layouts/marketing.stx
rmdir resources/views/layouts

CHECK.

find resources -type d -empty -not -path '*/node_modules/*' | grep . \
  && echo "VIOLATION: empty directory in resources/ — it is deciding config resolution"

1.4 MUST keep layouts, components, partials and stores outside pagesDir

RULE. Nothing under pagesDir may be a non-page. A file under resources/views/ is a public URL.

WHY. Route discovery is directory-driven. The exclusion of components/layouts/partials exists in three places and is missing from three others:

PathExcludes non-page dirs?Evidence
dev/prod serve file discoveryYesserve.js:9263-9273
.stx/routes.ts manifestYesstx-router/dist/codegen.js:24
buildForProductionYesproduction-builder.js:62
.stx/route-types.d.tsNocodegen.js:3-22 — no filter
stx-router Router constructorNofile-router.js:41-58
SSG generateStaticSiteNossg.d.ts:24-43 — SSGConfig has no componentsDir/layoutsDir/partialsDir key at all

Consequences already shipped into the build output and the generated types:

$ ls dist/layouts dist/components
dist/components:  FeatureCard.html
dist/layouts:     default.html  marketing.html
$ grep -o '<loc>[^<]*</loc>' dist/sitemap.xml | grep -E 'layouts|components'
<loc>http://localhost/components/FeatureCard</loc>
<loc>http://localhost/layouts/default</loc>
<loc>http://localhost/layouts/marketing</loc>
$ grep -n 'layouts\|components' .stx/route-types.d.ts
15:    '/components/FeatureCard': {  }
25:    '/layouts/default': {  }
26:    '/layouts/marketing': {  }

/layouts/marketing is the page whose <body> is the absolute-filesystem-path dump quoted in 1.2. It is sitemapped.

WRONG: resources/views/layouts/default.stx, resources/views/layouts/marketing.stx, resources/views/components/FeatureCard.stx.

RIGHT: resources/layouts/default.stx, resources/layouts/marketing.stx, resources/components/FeatureCard.stx (or delete FeatureCard.stx — it has zero references in resources/ or routes/).

Related, and worth knowing before you add a page: the dev server mounts the framework defaults as a second pages root — views.js:69 passes patterns: ['resources/views', 'storage/framework/defaults/resources/views']. Nine routes you did not write are live and in .stx/routes.ts: /cart, /coming-soon, /checkout/contact, /checkout/payment, /checkout/shipping, /dashboard/custom-page, /emails/welcome, /errors/tester, /orders/:id. Creating resources/views/cart.stx shadows the default (first pattern wins, file-router.js:45-48); creating a page that collides with one you did not intend to own is a silent behaviour change.

CHECK. Part of the 1.2 script; standalone form:

ls resources/views/layouts resources/views/components resources/views/partials resources/views/stores 2>/dev/null \
  && echo "VIOLATION: non-page directory inside pagesDir"
grep -nE "'/(layouts|components|partials)/" .stx/route-types.d.ts \
  && echo "VIOLATION: non-page templates typed as routes"

1.5 MUST only put load-bearing config in keys the serve path forwards

RULE. Before adding a key to config/ui.ts, confirm it reaches the renderer. The dev and production request paths forward a fixed allow-list and drop everything else.

WHY. serve.js:9508-9528 (and its twin at serve.js:9690-9708) rebuilds the render options from scratch:

const config5 = {
  ...defaultConfig4,                 // stx defaultConfig — config.js:16-24
  root: process19.cwd(),             // your `root` is discarded here
  ...componentsDir && { componentsDir },   // from options (views.js:71) — hardcoded
  ...layoutsDir && { layoutsDir },         // from options (views.js:72)
  ...partialsDir && { partialsDir },       // from options (views.js:73)
  autoShell: true,
  ssr: stxConfig.ssr ?? defaultStxConfig.ssr ?? true,
  app: stxConfig.app || {},
  ..."strict" in stxConfig && { strict: stxConfig.strict },
  ..."router" in stxConfig && { router: stxConfig.router },
  ..."debug" in stxConfig && { debug: stxConfig.debug },
  ..."skipDefaultSeoTags" in stxConfig && { skipDefaultSeoTags: stxConfig.skipDefaultSeoTags },
  ..."defaultTitle" / "defaultDescription" / "defaultImage" / "seo" / "analytics" ...,
  ..."_pluginComponentDirs" / "_pluginPageDirs" ...
};

Directory precedence is options.X ?? stxConfig.X ?? defaultStxConfig.X (serve.js:8935-8937) — options always win, and views.js:68-73 / production-server.js:121-123 always pass them. The three keys config/ui.ts sets today are exactly the three that are always overridden. The two that survive (router, app) are the two the file spends 60 lines documenting, which is correct; the ~30 that also survive are all unset.

Reaches the rendererSilently dropped by the serve path
app, ssr, strict, router, debug, skipDefaultSeoTags, defaultTitle, defaultDescription, defaultImage, seodefaultLayout, customDirectives, middleware, markdown, i18n, a11y, animation, loops, forms, csp, components, hydration, routeMiddleware, media, heatmap, pwa, story, streaming, webComponents, broadcasting, build, docs, cache, cachePath, templatesDir, skipSignalsRuntime, skipEventDirectives
analytics (also re-read via Loader A at serve.js:8929-8930)
publicDir (separate read, serve.js:8938)
plugins → _pluginComponentDirs / _pluginPageDirs (serve.js:8922-8928)
storesDir, css, root, pagesDir (via Loader A, not config5)

WRONG. Adding customDirectives or i18n to config/ui.ts and expecting it to apply in bun run dev.

RIGHT. Directive registration goes through plugins (config.js:445-460, addDirective), which is propagated.

CHECK.

grep -oE "^  [a-zA-Z_]+:" config/ui.ts | tr -d ' :' | grep -vxE \
 'root|pagesDir|componentsDir|layoutsDir|partialsDir|storesDir|publicDir|css|plugins|app|ssr|strict|router|debug|skipDefaultSeoTags|defaultTitle|defaultDescription|defaultImage|seo|analytics' \
 && echo "VIOLATION: key above is dropped by serve.js:9508-9528 — it has no runtime effect"

1.6 MUST NOT rely on defaultLayout, templatesDir, or shell

RULE. These three keys look configurable and are not. Do not set them; do not reason from them.

WHY.

  • defaultLayout (config-types.d.ts:276) appears nowhere in bun-plugin-stx/dist/serve.js. In the serve path it comes only from the spread of stx's defaultConfig (config.js:22 = "default"). Setting defaultLayout: 'app' in config/ui.ts changes stx build and changes nothing in dev or buddy serve. The layout file must be named default.stx.
  • templatesDir (:263) is read at exactly one site, docs.js:559, for doc generation. It has no rendering effect.
  • shell (: — StxConfig.shell?: string | false) drives detectShell (app-shell.js:17-30), which looks for app.stx in the project root. app-shell.d.ts marks it @deprecated Use layouts/default.stx instead, and neither bun-plugin-stx/dist/serve.js nor @stacksjs/actions/dist/dev/views.js calls detectShell at all. bughq has no app.stx and must not gain one.

CHECK.

grep -nE "^\s*(defaultLayout|templatesDir|shell):" config/ui.ts && echo "VIOLATION: inert key"
test -e app.stx && echo "VIOLATION: deprecated app.stx shell — use resources/layouts/default.stx"
test -e resources/layouts/default.stx || echo "VIOLATION: default layout must be named default.stx"

1.7 MUST put shared reactive state in storesDir. stx has no composablesDir

RULE. There is no composablesDir, functionsDir or composables/ convention anywhere in stx 0.2.113. Verified: grep -rn "composablesDir" node_modules/@stacksjs/ node_modules/bun-plugin-stx/ returns nothing. Composables (useDark, useCookie, useFetch, …) are auto-imported globals injected by the runtime — you never declare a directory for them and you never hand-import them (see the auto-imports chapter). Cross-page reactive state belongs in storesDir (config-types.d.ts:275), which is real and which bughq does not use.

WHY. store-loader.js:5-11 is the only resolver:

export async function getStoreScript(storesDir) {
  let resolvedDir;
  if (storesDir) resolvedDir = path.resolve(storesDir);
  else {
    const config = await loadStxConfig();
    resolvedDir = path.resolve(config.root || process.cwd(), config.storesDir || "stores");
  }

Note storesDir is resolved against root here, not prefixed at config.js:411-419 like the other three — a different mechanism for the same convention. In the serve path options.storesDir is undefined (it is not in the 1.5 allow-list), so the else branch always runs. With root: '.' and storesDir: 'resources/stores', that is <project>/resources/stores. The directory contract is strict: Bun.Glob("*.ts") — top level only, not recursive (store-loader.js:16); index.ts and types.ts are skipped (:19-20); every import line is stripped before transpile (:36), so a store must be self-contained. The result is inlined as <script data-stx-stores> by process.js:245-257.

WRONG — resources/functions/counter.ts:2 and resources/functions/dark.ts:4-5. These are stores in everything but location:

// resources/functions/counter.ts
export const count = state(0)
export function increment() { count.update((n: number) => n + 1) }
// resources/functions/dark.ts
export const isDark = useDark()
export const preferredDark = usePreferredDark()

resources/functions/ matches no stx convention, is referenced by zero .stx files (grep -rn "resources/functions" resources/ config/ → nothing), and is never loaded. Meanwhile the theme state it models is hand-reimplemented across the app.

RIGHT — move to resources/stores/ and declare it:

// resources/stores/theme.ts   (no imports — they are stripped)
export const isDark = useDark()
export const preferredDark = usePreferredDark()
export function toggleDark() { isDark.set(!isDark()) }
// config/ui.ts
  storesDir: 'resources/stores',

Verified end to end against getStoreScript(undefined) with this exact config: LOADED (105 bytes) → ;(function(){ // Store: theme … })();.

CHECK.

test -d resources/functions && echo "VIOLATION: resources/functions matches no stx convention — move to resources/stores"
grep -qE "^\s*storesDir:" config/ui.ts || echo "VIOLATION: storesDir undeclared"
find resources/stores -mindepth 2 -name '*.ts' | grep . && echo "VIOLATION: store-loader globs *.ts non-recursively"
grep -rn "^import " resources/stores/*.ts 2>/dev/null && echo "VIOLATION: imports are stripped at store-loader.js:36"

1.8 MUST make config/ui.ts typechecked

RULE. config/ui.ts ends in satisfies UiOptions (config/ui.ts:79) and that assertion is never evaluated. Fix tsconfig.json include so the config, the stores and the generated route types are covered.

WHY. stx is a TypeScript-first framework, and the config file is the one place where a typo costs you an entire subsystem. bun run typecheck (package.json → bun x tsc --noEmit) currently visits ten project files:

$ bun x tsc --listFilesOnly | grep -v node_modules
site.config.ts
types/stacks-app.d.ts
types/ts-cloud-augment.d.ts
app/Models/{AlertChannel,ErrorEvent,Issue,Project,Subscription,User}.ts
app/Support/urls.ts

tsconfig.json include is ["*.ts", "*.d.ts", "types/**/*.d.ts", "app/Models/**/*.ts", "app/Services/**/*.ts", "app/Support/**/*.ts"] — config/ and resources/ match nothing. .stx/route-types.d.ts (which declares module "stx/routes" with the full RouteMap) is likewise never loaded, so route patterns are untyped at every call site.

RIGHT — tsconfig.json:

  "include": [
    "*.ts",
    "*.d.ts",
    "types/**/*.d.ts",
    "config/**/*.ts",
    "resources/stores/**/*.ts",
    ".stx/route-types.d.ts",
    "app/Models/**/*.ts",
    "app/Services/**/*.ts",
    "app/Support/**/*.ts"
  ],

CHECK.

bun x tsc --listFilesOnly | grep -q 'config/ui.ts' || echo "VIOLATION: config/ui.ts is not typechecked"

1.9 MAY use _layout.stx co-location, but know it outranks the configured layout

RULE. A file named _layout.stx in any directory under pagesDir becomes the layout for every page at or below it. Use it for a route group; never add one casually.

WHY. process.js:313-327 walks up from the page's directory (max 20 levels) looking for _layout.stx before it ever consults layoutsDir/defaultLayout:

for (let i = 0; i < 20; i++) {
  const candidate = path.join(searchDir, "_layout.stx");
  if (await fileExists(candidate)) { autoLayoutPath = candidate; break }
  ...
}
if (autoLayoutPath) layoutPath = autoLayoutPath;
else if (opts.layoutsDir) { /* layoutsDir/${defaultLayout}.stx */ }

stx-router/dist/nested-layouts.js:3-14 builds the same chain for route metadata. Adding resources/views/compare/_layout.stx silently re-parents all eight /compare/* pages and no error is emitted.

CHECK. Manual review of every hit:

find resources/views -name '_layout.stx'

Each result must be intentional and documented in a comment at the top of the file naming the routes it captures.

1.10 The corrected config/ui.ts

Apply this verbatim after the file moves in 1.3/1.4. The existing comment blocks on router (config/ui.ts:18-40) and app.head (:45-73) are correct and must be preserved — they are elided here with /* … */ only for length; do not delete them.

import type { StxOptions as UiOptions } from '@stacksjs/stx'

/**
 * stx configuration — the root of this application. There is no index.html.
 *
 * This file is loaded TWICE, by loaders with different semantics:
 *
 *   A. @stacksjs/stx loadStxConfig()            (dist/config.js:387)
 *      → applies resolveStxRoot (config.js:363-374) and prefixes
 *        componentsDir/layoutsDir/partialsDir with `root` (config.js:411-419).
 *      → used by `stx build`, the SSG, production-builder, store-loader,
 *        and Crosswind config discovery.
 *
 *   B. bun-plugin-stx serve()                   (dist/serve.js:8912-8919)
 *      → raw bunfig load, NO root prefixing, defaults ssr:true.
 *      → used by `bun scripts/dev.ts` and `buddy serve` on every request.
 *
 * A relative directory value cannot be correct in both unless `root` is '.'.
 * That is why `root: '.'` is pinned below and every directory is written from
 * the project root. Verify with scripts/check-stx-topology.ts — it fails if the
 * two loaders disagree or any resolved path is missing.
 */
export default {
  // --- Project shape -------------------------------------------------------
  // Pinned, not inferred. Without `root`, resolveStxRoot (config.js:363-374)
  // decides the whole topology from `fs.existsSync('resources/layouts')` — an
  // empty directory would flip root to 'resources' and double-prefix the three
  // directories below into non-existent resources/resources/*.
  root: '.',

  // Every .stx under here is a public URL (stx-router file-router.js:41-58).
  // Nothing that is not a page may live in this tree: the SSG (ssg.d.ts:24-43)
  // and the route-type codegen (stx-router codegen.js:3-22) have no exclusion,
  // so a layout parked here is built to HTML and sitemapped.
  pagesDir: 'resources/views',

  // --- Template directories ------------------------------------------------
  // Root-relative because `root` is '.', which makes the config.js:411-419
  // prefix a no-op and both loaders agree.
  //
  // Caveat, verified not assumed: all three are OVERRIDDEN on the served path.
  // Dev passes them at @stacksjs/actions/dist/dev/views.js:68-73, prod at
  // @stacksjs/buddy/dist/production-server.js:116-123, and options win at
  // bun-plugin-stx/dist/serve.js:8935-8937. They are still declared here
  // because `stx build`, the SSG and production-builder read them directly.
  //
  // componentsDir in particular is force-set to the framework defaults tree by
  // views.js:71; bughq's own resources/components resolves only via the
  // convention fallback at @stacksjs/stx/dist/utils.js:334-338.
  componentsDir: 'resources/components',
  layoutsDir: 'resources/layouts',
  partialsDir: 'resources/partials',

  // Module-scope reactive state, inlined into every page as
  // <script data-stx-stores> (process.js:245-257). Resolved against `root` by
  // store-loader.js:11 — a different mechanism from the three keys above.
  // Contract: top-level *.ts only (non-recursive glob, store-loader.js:16),
  // index.ts/types.ts skipped, and all `import` lines stripped before
  // transpile (store-loader.js:36) — stores must be self-contained.
  storesDir: 'resources/stores',

  // Static root. Not passed by views.js, so this value actually reaches the
  // server (serve.js:8938). Declared so it stops being a hidden default.
  publicDir: 'public',

  // --- Rendering -----------------------------------------------------------
  // Explicit because the two loaders default it differently: stx's own
  // defaultConfig says false (config.js:18) while bun-plugin-stx says true
  // (serve.js:8833). The false side is what makes `stx build` pick SSG over SSR
  // (build.js:4-5). bughq is SSR; say so once, here.
  ssr: true,

  // Registers the ~35 shipped UI components by tag name — the ONLY code path
  // that fills _pluginComponentDirs (config.js:420-468). See the components
  // chapter. Boot log prints "[stx] Plugin loaded: @stacksjs/components".
  plugins: ['@stacksjs/components/stx-plugin'],

  // stx's built-in prohibited-DOM-API validator for client <script> blocks.
  // See the no-vanilla-JS chapter. Forwarded at serve.js:9517.
  strict: true,

  /* … existing router comment block, config/ui.ts:18-40, unchanged … */
  router: {
    container: 'main',
    interceptAllLinks: true,
  },

  /* … existing app.head comment block, config/ui.ts:45-73, unchanged … */
  app: {
    head: {
      title: 'bughq',
    },
  },
} satisfies UiOptions

satisfies UiOptions only does work once 1.8 lands. Two keys deliberately omitted here because they belong to later chapters but must eventually be set in this file: skipDefaultSeoTags (stx currently injects stx Project / A website built with stx templating engine into every page — see dist/compare/sentry.html:1) and analytics (config/analytics.ts:17-18 has a Fathom site ID that reaches nothing).

1.11 Apply order

The moves and the config edit are one atomic change; doing either half alone breaks the other.

  1. git mv resources/views/layouts/*.stx resources/layouts/ and rmdir resources/views/layouts — satisfies 1.3 and 1.4 for layouts, and makes views.js:59-62 / production-server.js:114 agree with the config.
  2. git mv resources/views/components/FeatureCard.stx resources/components/ (or git rm it — zero references) and rmdir resources/views/components.
  3. Replace config/ui.ts with 1.10.
  4. Extend tsconfig.json include per 1.8.
  5. Add scripts/check-stx-topology.ts from 1.2 and run it — must exit 0.
  6. rm -rf dist .stx && bun run dev, then re-run step 5 and confirm grep -rn "'/\(layouts\|components\)/" .stx/route-types.d.ts is empty and grep -rl 'include error' dist/ returns nothing.
10 days ago

2. Page anatomy — never write a document

A .stx file under resources/views/ is a fragment. It contributes markup to a document that stx assembles. It never is the document. Every rule in this chapter follows from one line of the framework:

// node_modules/@stacksjs/stx/dist/process.js:310-312
if (!layoutPath && !hasNoLayout && opts.defaultLayout) {
  const hasDoctype = /<!DOCTYPE\s/i.test(output), hasSections = /@section\s*\(/.test(output);
  if (!hasDoctype || hasSections) {

Writing <!DOCTYPE html> in a page turns off layout resolution for that page. Silently. No warning, no log line. bughq has 24 files that do this (grep -rl '<!DOCTYPE' resources/views → 24), and the entire marketing site's structural debt — 357 lines of duplicated shell, a dead layout, 8 data-no-router escape hatches, zero useSeoMeta calls — is downstream of that one regex.

The pipeline you are writing into

StageFramework locationWhat happens
stx comments strippedprocess.js:288 removed before anything reads the template
HTML comments maskedprocess.js:289-291<!-- … --> replaced by \x00STX_HTML_COMMENT_n\x00 placeholders
@push/@prepend harvestedprocess.js:299 → includes.js:682-724Content moved into a shared stacks object, removed from the page
@nolayout detected & strippedprocess.js:302-304Explicit opt-out
@extends/@layout matched & strippedprocess.js:305-309layoutPath = layoutMatch[1]
Doctype gateprocess.js:310-312A doctype with no @section skips everything below
_layout.stx auto-discoveryprocess.js:313-327Walks up ≤20 dirs from the page file
defaultLayout fallbackprocess.js:328-331<layoutsDir>/<defaultLayout>.stx, default name "default" (config.js:22)
Implicit whole-page wrapprocess.js:333-336Only when a layout was auto-resolved AND the page has no @section
@section collectedprocess.js:339-362Into sections; @show is an alias for @endsection; @parent supported
@yield/@slot filledprocess.js:364-370Unfilled yields resolve to their default or '' — never an error
@stack filled (page)process.js:371includes.js:726-732
Orphan <script>/<style> hoistedprocess.js:372-385Only when a layout applies; lands in sections.content, i.e. the body
Layout read, @push merged, @yield filled, @stack filledprocess.js:403-444Same stacks object as the page
Layout body processedprocess.js:446 → processOtherDirectives :511<script server> in the layout runs here (:483-499); is the supported way for a fragment to add raw <head> content: head.js:199-206 strips the block and returns its inner text, process.js:560-563 accumulates it into context.__stx_head_raw, process.js:220 folds it into headConfig.headRaw, and document-shell.js:44 emits it inside <head>. It runs on the layout because processOtherDirectives (process.js:446) is what processes the composed layout body.

@stack('styles') inside

process.js:208-211
3@section('title', 'Foo')process.js:212 reads context.__sections.title
4config/ui.ts app.head.title ('bughq')process.js:206, config/ui.ts:74-78
5"stx App"document-shell.js:13

Note that priority 3 makes layouts/marketing.stx:7's <title>@yield('title')</title> redundant even on its own terms — @section('title', '…') already feeds the shell. Delete the string-slot head; do not port it.

WRONG — resources/views/compare/sentry.stx:6-7 (and 23 siblings):

    <title>bughq vs Sentry - bughq</title>
    <meta name="description" content="How bughq, an open-source error tracker you self-host on Postgres, compares to Sentry, the incumbent error platform.">

RIGHT:

<script server>
useSeoMeta({
  title: 'bughq vs Sentry - bughq',
  description: 'How bughq, an open-source error tracker you self-host on Postgres, compares to Sentry, the incumbent error platform.',
  canonical: 'https://bughq.org/compare/sentry',
})
</script>

CHECK.

grep -rn '<title>\|<meta name="description"' resources/views   # must return nothing
grep -rc 'link rel="stylesheet"' resources/views | grep -v ':0' # must return nothing

Currently 24 files declare <title>; 34 declare a stylesheet <link>.

2.7 MUST route page-local CSS through @push('styles') → @stack('styles'), never a bare <style>

WHY. There is a safety net and it puts your CSS in the wrong place. process.js:372-385 hoists orphan <script>/<style> tags into sections.content when a layout applies — i.e. into <body>, after the content, in a fragment the SPA router will swap away. @push avoids this entirely: process.js:299 harvests pushes into stacks before sections are collected (so @push may sit anywhere, including inside @section), process.js:423 merges the layout's own pushes into the same object, and process.js:444 fills @stack. Because the layout's @stack('styles') sits inside raw block (context.__stx_head_raw, head.js:199-206) → @section('title', '…') (context.__sections.title) → config app.head.title (config/ui.ts:76) → 'stx App' (document-shell.js:13)

link and meta arrays are concatenated, not replaced (process.js:216-218), and injectConfigHeadTags dedupes links by exact href= (document-shell.js:79-80) and metas by name/property/charset/http-equiv (document-shell.js:73-75). So config-level assets are safe to declare unconditionally.

Page-local <style> must go through @push/@stack (includes.js:722-732), not sit loose in the template: when a layout applies, process.js:373-384 sweeps every orphan <script>/<style> out of the page and appends it to sections.content — i.e. into the body, after the content, not the head.

WRONG — the same four <link> tags appear in 34 of 36 page files. resources/views/index.stx:37-40, compare/sentry.stx:8-11, pricing.stx:38-40, and 31 more:

    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk…">
    <link rel="stylesheet" href="/marketing.css">

…while config/ui.ts:74-78 declares only { title: 'bughq' }.

RIGHT — config/ui.ts:

app: {
  head: {
    title: 'bughq',
    lang: 'en',
    link: [
      { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
      { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
      { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap' },
    ],
  },
},

and the page:

<script server>
useHead({ title: 'bughq vs Sentry - bughq' })
</script>
@extends('marketing')

CHECK.

grep -rn "fonts.googleapis.com\|<link rel=" resources/views --include="*.stx"   # must be empty
grep -rn "<style" resources/views --include="*.stx" | grep -v "@push"           # must be empty

Currently 92 duplicated <link> lines across 23 heads, and 20 files with a loose <style>.


3.7 MUST: one layout file per chrome group, and the filename is the routing group

WHY. deriveLayoutGroup (node_modules/stx-router/dist/layout-metadata.js:1-6) takes the path from the <!-- stx-layout: … --> marker, finds the last layouts segment, and returns the next segment with .stx stripped. client.js:230-237 reimplements the identical function browser-side. That string drives two decisions:

  • checkLayoutChange (client.js:248-266): different group → return true at :255-258 → client.js:350-360 refetches the full document and client.js:596-607 swaps document.body.innerHTML, replacing nav, footer and all chrome. Different name within the same group → same full-body swap (client.js:261-263).
  • client.js:568-580: the stylesheet/preconnect <link> reconciliation runs only on that full-document path, resolving hrefs against the target document's base URL (client.js:566-567).

So default.stx → group default, marketing.stx → group marketing. App→marketing and marketing→app both become correct full-body swaps. Today both surfaces report default (marketing by the serve.js:10121 fallback), so the router misclassifies the crossing as a same-layout fragment swap — and the asymmetric case is worse: marketing→app resolves curGroup via defaultLayoutGroup('') → 'app' (client.js:231) against the app page's 'default', firing a second full-page fetch on every marketing→/pricing, /login, /register click.

WRONG. Two document shells, zero layout metadata, one reported group.

RIGHT. Two sibling layouts under resources/layouts/. resources/layouts/marketing.stx, complete — note it has no DOCTYPE, no <head>, and calls @include for chrome exactly the way all 23 pages do today:

{{-- Marketing chrome. Group 'marketing': crossing to/from the app shell makes
     client.js:255 fire a full-document swap so nav and footer are replaced. --}}
@include('SiteNav')

<main>
@yield('content')
</main>

@include('SiteFooter')

@stack('scripts')

and resources/layouts/default.stx keeps its current body (resources/views/layouts/default.stx:38-44) with @yield('footer') deleted per rule 3.9.

CHECK.

# server-rendered metadata, dev server on :3100
curl -s localhost:3100/compare/sentry | grep -o 'name="stx-layout-group" content="[^"]*"'  # → marketing
curl -s localhost:3100/dashboard     | grep -o 'name="stx-layout-group" content="[^"]*"'  # → default
# fragment headers
curl -s -D- -o/dev/null -H 'X-STX-Router: true' localhost:3100/compare/sentry | grep -i x-stx-layout

3.8 MUST NOT: nest layouts (@extends inside a layout file) while relying on layout groups

WHY. This is a trap, not a style preference. process.js:405-421 detects an @extends in the layout file, merges the intermediate layout's sections, and recurses via processDirectives(nestedTemplate, …, layoutFullPath, …). That recursive call matches the base layout at process.js:305 and stamps <!-- stx-layout: base --> at process.js:447. The intermediate layout's name is discarded. So marketing.stx @extends('base') and default.stx @extends('base') both report group base — checkLayoutChange (client.js:255) sees no change, and you are back to unstyled fragment swaps with extra indirection.

Share the document through app.head (rule 3.6) and share markup through @include partials. Never through layout inheritance.

CHECK.

grep -rn "@extends(\|@layout(" resources/layouts/*.stx   # must return nothing

WHY. client.js:793 honours data-stx-no-router and data-no-router, but only inside shouldIntercept, reachable only from the interceptAllLinks branch at client.js:809 (client.js:808 matches [data-stx-link] first and unconditionally — so data-no-router on an <StxLink> is silently ignored). There are exactly two legitimate reasons to opt out, and "my target page has a different <head>" is not one of them once rule 3.7 holds.

bughq's 12 occurrences split cleanly:

SiteTargetAfter a unified shell
login.stx:113, register.stx:126, pricing.stx:88, account.stx:119, account.stx:183, reset-password.stx:117, forgot-password.stx:96, projects/new.stx:120/Delete. Layout-group change drives a full-document swap; client.js:568-580 carries any stylesheet the target needs.
login.stx:146, login.stx:152, register.stx:158, register.stx:164/api/auth/{github,google}/redirectKeep, permanently. These are 302s. Intercepting one fetches the redirect instead of following it, recovering only via the error path at client.js:371-373.

WRONG — resources/views/login.stx:113:

<a href="/" data-no-router class="wordmark text-2xl">bughq</a>

RIGHT.

<StxLink to="/" class="wordmark text-2xl">bughq</StxLink>

and, on the four that stay:

{{-- Server 302. Unconditional opt-out, not a shell artefact. --}}
<a href="/api/auth/github/redirect" data-no-router class="oauth-btn …">

Once every internal anchor is an <StxLink> (client.js:808 matches [data-stx-link] without interceptAllLinks), config/ui.ts:43 SHOULD flip to interceptAllLinks: false. Do not flip it before then: resources/partials/SiteNav.stx and SiteFooter.stx carry 64 plain anchors that depend on the flag.

CHECK.

grep -rn "data-no-router" resources/views --include="*.stx" | grep -v "/api/"   # must be empty

Currently 8 hits.


3.10 SHOULD: use _layout.stx for subtree chrome, and @nolayout for a page that genuinely owns its document

WHY. process.js:313-327 walks up to 20 parent directories from the page file looking for _layout.stx, bounded by path.dirname(opts.layoutsDir), and prefers it over the configured default layout (process.js:326-332). That is the Nuxt-style co-located shell: resources/views/issue/_layout.stx would wrap issue/[id].stx only. bughq has none. Use it when a subtree needs distinct chrome; do not use it to duplicate default.stx.

@nolayout (process.js:302-304, gated at :310) is the explicit opt-out. bughq uses it zero times; its 24 DOCTYPE pages opt out implicitly through process.js:311, which is why adding one @section( to any of them silently produces a nested document (rule 3.4).

CHECK. Manual review. A _layout.stx is correct only if it differs structurally from its nearest ancestor layout; diff them. grep -rn "@nolayout" resources should return only files that are deliberately standalone (an embed, an OG image renderer) — and per rule 3.1 such a page still must not write a DOCTYPE; it must let ensureDocumentShell build one.


3.11 MUST: every @yield and @stack in a layout is filled by at least one page

WHY. Unfilled slots fail silently. process.js:364-366 replaces an unmatched @yield with its default argument or the empty string; includes.js:727-729 renders an empty @stack as ''. No warning, no build error.

WRONG — resources/views/layouts/default.stx:42 @yield('footer') and :44 @stack('scripts'); resources/views/layouts/marketing.stx:12-13 the same pair. Not one page in bughq fills any of them — all 23 marketing pages call /var/www/reviewos-reviewos/releases/7ee24c5/resources/components/IssueThread.stx:452:404: include error: Error loading include file SiteFooter: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/7ee24c5/resources/views/partials/SiteFooter.stx' ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: IssueThread.stx (line 452, col 404) Message: Error loading include file SiteFooter: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/7ee24c5/resources/views/partials/SiteFooter.stx' Context:  450: projects/index.stx @extends('default') 451: projects/new.stx @extends('default') > 452: issue/[id].stx @extends('default')

Deleted: resources/views/layouts/, resources/views/components/FeatureCard.stx (zero references), and 7 of the 8 /compare/* files — they are byte-identical in tag+class skeleton and the canonical alternatives array already exists in resources/views/compare.stx:1-13, rendered with @foreach at compare.stx:52.

The matching config. All three directory keys are root-relative, because config.js:411-419 joins loaded.root onto each of partialsDir, componentsDir, layoutsDir — which is why the current absolute-looking values in config/ui.ts:10,13,16 become resources/resources/* and produce the include-error dump in dist/layouts/marketing.html:

export default {
  453:   root: 'resources',        // explicit — do not let config.js:363-374 infer it
  454:   pagesDir: 'views',
 inline instead. marketing.stx additionally offers no @stack('styles') despite 9 pages carrying a page-local <style> block.

RIGHT. Delete @yield('footer') and call /var/www/reviewos-reviewos/releases/7ee24c5/resources/components/IssueThread.stx:460:142: include error: Error loading include file SiteFooter: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/7ee24c5/resources/views/partials/SiteFooter.stx' ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: IssueThread.stx (line 460, col 142) Message: Error loading include file SiteFooter: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/7ee24c5/resources/views/partials/SiteFooter.stx' Context:  458: defaultLayout: 'default', 459: app: { head: { /* rule 3.6 */ } }, > 460: router: { container: 'main', interceptAllLinks: false /* after rule 3.9 */ }, 461: } satisfies UiOptions

CHECK — one command that must pass before any .stx change is committed:

grep -rn "DOCTYPE\|<html[ >]" resources --include="*.stx"                       # empty
  462: find resources/views -name '*.stx' -exec grep -L "@extends(" {} +               # empty
 from the layout (rule 3.7). Add @stack('styles') to the layout only once a page uses @push('styles').

CHECK.

# every yield/stack name a layout declares must appear as a section/push in some page
comm -23 \
  <(grep -rhoE "@(yield|stack)\('([^']+)'" resources/layouts | grep -oE "'[^']+'" | tr -d "'" | sort -u) \
  <(grep -rhoE "@(section|push|prepend)\('([^']+)'" resources/views | grep -oE "'[^']+'" | tr -d "'" | sort -u)
# must print nothing

3.12 The file tree bughq must have

config/
  ui.ts                     # declares every directory below — the Nuxt-style manifest
resources/
  layouts/
    default.stx             # app + auth chrome → group 'default'
    marketing.stx           # marketing chrome  → group 'marketing'
  partials/
    SiteNav.stx             # 
/var/www/reviewos-reviewos/releases/7ee24c5/resources/components/IssueThread.stx:534:545: include error: Error loading include file SiteNav: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/7ee24c5/resources/views/partials/SiteNav.stx'

╭──────────────────────────────────────────────────────────────╮
│  ERROR  Include Error                                    │
╰──────────────────────────────────────────────────────────────╯

File: IssueThread.stx (line 534, col 545)
Message: Error loading include file SiteNav: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/7ee24c5/resources/views/partials/SiteNav.stx'

Context:
  532:         
  533: 
> 534:         

4. Head, SEO and structured data

Everything a page says about itself — <title>, description, canonical, og/twitter, robots, JSON-LD — is produced by exactly two mechanisms in stx 0.2.113: runtime head calls in a <script server> block, and app.head in config/ui.ts. Nothing else. bughq currently uses a third (24 hand-written <head> blocks) and a fourth (site.config.ts pages), and the result is 24 built pages that ship <title>stx Project</title>.

4.0 The title precedence chain (read this before writing any rule-breaking code)

process.js lines 205–226 are the entire mechanism. runtimeHead is context.__stx_runtime_head (what useHead/useSeoMeta write), pageHeadRaw is what captured, sectionTitle is @section('title'), and baseHeadConfig is config/ui.ts app.head:

// node_modules/@stacksjs/stx/dist/process.js:206-215
  535: const baseHeadConfig = options.app?.head || {}, runtimeHead = (context.__stx_runtime_head || null) ?? getHeadStatic();
  536: let pageHeadRaw = (context.__stx_head_raw || "").trim(), pageHeadTitle;
   — already correct location
    SiteFooter.stx          # 
/var/www/reviewos-reviewos/releases/7ee24c5/resources/components/IssueThread.stx:538:197: include error: Error loading include file SiteFooter: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/7ee24c5/resources/views/partials/SiteFooter.stx'

╭──────────────────────────────────────────────────────────────╮
│  ERROR  Include Error                                    │
╰──────────────────────────────────────────────────────────────╯

File: IssueThread.stx (line 538, col 197)
Message: Error loading include file SiteFooter: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/7ee24c5/resources/views/partials/SiteFooter.stx'

Context:
  536: let pageHeadRaw = (context.__stx_head_raw || "").trim(), pageHeadTitle;
  537: const rawTitleMatch = pageHeadRaw.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
> 538: if (rawTitleMatch) { pageHeadTitle = rawTitleMatch[1].trim(); pageHeadRaw = pageHeadRaw.replace(rawTitleMatch[0], "").trim(); }
  539: const sectionTitle = context.__sections?.title,
  540:       pageTitle = runtimeHead.title || pageHeadTitle || sectionTitle, headConfig = { ...baseHeadConfig, ...pageTitle && { title: pageTitle }, ... }
#SourceWritten byFramework lineVerified render
1context.__stx_runtime_head.titleuseHead / useSeoMeta / in a page with no server headprocess.js:206, :213; bridge variable-extractor.js:312-323FROM-USEHEAD beats @title, processHeadDirective, head.js:199-206 → process.js:559-564process.js:207-212FROM-HEAD wins only when 1 is absent
3@section('title')context.__sections.titleprocess.js:213Section Title wins only when 1 and 2 are absent
4config/ui.ts app.head.titlebaseHeadConfig spreadprocess.js:206, :214bare page renders <title>bughq</title>
5"stx App"generateDocumentShell defaultdocument-shell.js:13, :21, :39dist/dashboard.html ships <title>stx App</title>

Note the trap at tier 1: writes only the module-global head (head.js:210 calls the static useHead), so if a <script server> already called useHead, process.js:206 reads context.__stx_runtime_head and @title is silently discarded. Two mechanisms, one winner, no warning.

4.0.1 API status table — stx 0.2.113

Half of the head API documented in head.d.ts is inert in a <script server> block. Do not use anything marked MUST NOT; it fails silently.

APIImplementationBehaviour in <script server>Verdict
useSeoMeta(config)head.js:31-91works for title, description, keywords, author, robots, all og:*, all twitter:*, all article:*MUST — the default
useSeoMeta({ canonical })head.js:82-84dropped. The bridge at variable-extractor.js:326-331 copies only title and meta into context.__stx_runtime_head; link is never copied, and process.js:216 reads runtimeHead.linkMUST NOT — see 4.5
useHead(config)head.js:15-17, bridge variable-extractor.js:312-323works, and is the only call that carries link/script throughMUST for canonical/preload
definePageMeta(config)head.js:92-99no-op. The server scope binds definePageMeta = (_meta) => {} at variable-extractor.js:300MUST NOT
usePageTitle / useTitlehead.js:100-103, head.js:222-224not bound. Absent from the injected parameter list at variable-extractor.js:466MUST NOT
titleTemplatehead.js:20-21, applied head.js:108-109never applied. Neither bridge copies titleTemplate into context.__stx_runtime_head, and generateDocumentShell renders <title>${pageTitle}</title> raw (document-shell.js:39)MUST NOT
: the expression could not be evaluated: Invalid character '\u2026' -->seo.js:61-97, wired process.js:586works; balanced-paren parse, auto-injects @context, emits <script type="application/ld+json">MUST for JSON-LD
@structuredData … @endstructuredDataseo.js:321-339, needs registerSeoDirectives() (seo.js:340-345)dead. registerSeoDirectives is exported but called nowhere in dist; the block renders as literal text into the pageMUST NOT
: the expression could not be evaluated: Invalid character '\u2026' -->seo.js:98-242, wired process.js:587works, but emits <title> + all meta inline at the call site, i.e. inside <body>, while the shell keeps its own wrong <title>MUST NOT
@head, @title, @metahead.js:199-220 → process.js:559-566; second @meta impl seo.js:3-17 → process.js:585work, but lose to useHead and duplicate its jobMAY — layouts/partials only
app.head.meta/link/scriptdocument-shell.js:11-51 (shell) and :67-99 (injectConfigHeadTags, deduped)works on both page shapesMUST for anything shared

Reproduce any row from the repo root:

bun -e '

  components/               # user components, resolvable by tag name
  views/                    # pagesDir — ONLY routable pages, nothing else
    index.stx               @extends('marketing')
    pricing.stx             @extends('marketing')
    docs.stx                @extends('marketing')
    compare.stx             @extends('marketing')
    use-cases.stx           @extends('marketing')
    compare/[slug].stx      @extends('marketing')   # replaces 8 × 153 identical files
    features/*.stx          @extends('marketing')
    use-cases/*.stx         @extends('marketing')
    login.stx               @extends('default')
    register.stx            @extends('default')
    forgot-password.stx     @extends('default')
    reset-password.stx      @extends('default')
    dashboard.stx           @extends('default')
    settings.stx            @extends('default')
    account.stx             @extends('default')
    projects/index.stx      @extends('default')
    projects/new.stx        @extends('default')
    issue/[id].stx          @extends('default')

Deleted: resources/views/layouts/, resources/views/components/FeatureCard.stx (zero references), and 7 of the 8 /compare/* files — they are byte-identical in tag+class skeleton and the canonical alternatives array already exists in resources/views/compare.stx:1-13, rendered with @foreach at compare.stx:52.

The matching config. All three directory keys are root-relative, because config.js:411-419 joins loaded.root onto each of partialsDir, componentsDir, layoutsDir — which is why the current absolute-looking values in config/ui.ts:10,13,16 become resources/resources/* and produce the include-error dump in dist/layouts/marketing.html:

export default {
  root: 'resources',        // explicit — do not let config.js:363-374 infer it
  pagesDir: 'views',
  layoutsDir: 'layouts',    // → resources/layouts
  componentsDir: 'components',
  partialsDir: 'partials',
  defaultLayout: 'default',
  app: { head: { /* rule 3.6 */ } },
  router: { container: 'main', interceptAllLinks: false /* after rule 3.9 */ },
} satisfies UiOptions

CHECK — one command that must pass before any .stx change is committed:

grep -rn "DOCTYPE\|<html[ >]" resources --include="*.stx"                       # empty
find resources/views -name '*.stx' -exec grep -L "@extends(" {} +               # empty
grep -rn "<main" resources/views --include="*.stx" | grep -v "{{--\|<!--"       # empty
grep -rn "data-no-router" resources/views --include="*.stx" | grep -v "/api/"   # empty
grep -rn "@extends(\|@layout(" resources/layouts/*.stx                          # empty
test ! -d resources/views/layouts && test ! -e app.stx
10 days ago

4. Head, SEO and structured data

Everything a page says about itself — <title>, description, canonical, og/twitter, robots, JSON-LD — is produced by exactly two mechanisms in stx 0.2.113: runtime head calls in a <script server> block, and app.head in config/ui.ts. Nothing else. bughq currently uses a third (24 hand-written <head> blocks) and a fourth (site.config.ts pages), and the result is 24 built pages that ship <title>stx Project</title>.

4.0 The title precedence chain (read this before writing any rule-breaking code)

process.js lines 205–226 are the entire mechanism. runtimeHead is context.__stx_runtime_head (what useHead/useSeoMeta write), pageHeadRaw is what captured, sectionTitle is @section('title'), and baseHeadConfig is config/ui.ts app.head:

// node_modules/@stacksjs/stx/dist/process.js:206-215
const baseHeadConfig = options.app?.head || {}, runtimeHead = (context.__stx_runtime_head || null) ?? getHeadStatic();
let pageHeadRaw = (context.__stx_head_raw || "").trim(), pageHeadTitle;
const rawTitleMatch = pageHeadRaw.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (rawTitleMatch) { pageHeadTitle = rawTitleMatch[1].trim(); pageHeadRaw = pageHeadRaw.replace(rawTitleMatch[0], "").trim(); }
const sectionTitle = context.__sections?.title,
      pageTitle = runtimeHead.title || pageHeadTitle || sectionTitle, headConfig = { ...baseHeadConfig, ...pageTitle && { title: pageTitle }, ... }
#SourceWritten byFramework lineVerified render
1context.__stx_runtime_head.titleuseHead / useSeoMeta / in a page with no server headprocess.js:206, :213; bridge variable-extractor.js:312-323FROM-USEHEAD beats @title, processHeadDirective, head.js:199-206 → process.js:559-564process.js:207-212FROM-HEAD wins only when 1 is absent
3@section('title')context.__sections.titleprocess.js:213Section Title wins only when 1 and 2 are absent
4config/ui.ts app.head.titlebaseHeadConfig spreadprocess.js:206, :214bare page renders <title>bughq</title>
5"stx App"generateDocumentShell defaultdocument-shell.js:13, :21, :39dist/dashboard.html ships <title>stx App</title>

Note the trap at tier 1: writes only the module-global head (head.js:210 calls the static useHead), so if a <script server> already called useHead, process.js:206 reads context.__stx_runtime_head and @title is silently discarded. Two mechanisms, one winner, no warning.

4.0.1 API status table — stx 0.2.113

Half of the head API documented in head.d.ts is inert in a <script server> block. Do not use anything marked MUST NOT; it fails silently.

APIImplementationBehaviour in <script server>Verdict
useSeoMeta(config)head.js:31-91works for title, description, keywords, author, robots, all og:*, all twitter:*, all article:*MUST — the default
useSeoMeta({ canonical })head.js:82-84dropped. The bridge at variable-extractor.js:326-331 copies only title and meta into context.__stx_runtime_head; link is never copied, and process.js:216 reads runtimeHead.linkMUST NOT — see 4.5
useHead(config)head.js:15-17, bridge variable-extractor.js:312-323works, and is the only call that carries link/script throughMUST for canonical/preload
definePageMeta(config)head.js:92-99no-op. The server scope binds definePageMeta = (_meta) => {} at variable-extractor.js:300MUST NOT
usePageTitle / useTitlehead.js:100-103, head.js:222-224not bound. Absent from the injected parameter list at variable-extractor.js:466MUST NOT
titleTemplatehead.js:20-21, applied head.js:108-109never applied. Neither bridge copies titleTemplate into context.__stx_runtime_head, and generateDocumentShell renders <title>${pageTitle}</title> raw (document-shell.js:39)MUST NOT
: the expression could not be evaluated: Invalid character '\u2026' -->seo.js:61-97, wired process.js:586works; balanced-paren parse, auto-injects @context, emits <script type="application/ld+json">MUST for JSON-LD
@structuredData … @endstructuredDataseo.js:321-339, needs registerSeoDirectives() (seo.js:340-345)dead. registerSeoDirectives is exported but called nowhere in dist; the block renders as literal text into the pageMUST NOT
: the expression could not be evaluated: Invalid character '\u2026' -->seo.js:98-242, wired process.js:587works, but emits <title> + all meta inline at the call site, i.e. inside <body>, while the shell keeps its own wrong <title>MUST NOT
@head, @title, @metahead.js:199-220 → process.js:559-566; second @meta impl seo.js:3-17 → process.js:585work, but lose to useHead and duplicate its jobMAY — layouts/partials only
app.head.meta/link/scriptdocument-shell.js:11-51 (shell) and :67-99 (injectConfigHeadTags, deduped)works on both page shapesMUST for anything shared

Reproduce any row from the repo root:

bun -e '
const { processDirectives, resetHead } = await import("@stacksjs/stx")
const ui = (await import("./config/ui.ts")).default
for (const f of ["resources/views/pricing.stx", "resources/views/features/alerts.stx"]) {
  resetHead()
  const out = await processDirectives(await Bun.file(f).text(), {}, f, { ...ui, autoShell: true, seo: { enabled: true } }, new Set())
  console.log(f, "->", (out.match(/<title[^>]*>[^<]*<\/title>/gi) || []).join(" | "),
    "| desc:", /name="description"/.test(out), "| stxProject:", /stx Project/.test(out))
}'
# resources/views/pricing.stx        -> <title>Pricing - bughq</title>          | desc: false | stxProject: false
# resources/views/features/alerts.stx -> <title>Alerts and triage - bughq</title> | desc: true  | stxProject: true

That one command already fails two rules below: /pricing has no description at all, and /features/alerts's only description is the stx Project placeholder.


4.1 MUST — a page never writes <!DOCTYPE>, <html>, <head> or <title>

WHY. AppHeadConfig is documented as "auto-generates the document shell. No .stx file needs to write <!DOCTYPE>, <html>, <head>, or <body>" (config-types.d.ts:235-236). hasDocumentShell (document-shell.js:64-66) tests /<!DOCTYPE\b/i || /<html[\s>]/i; when it matches, ensureDocumentShell returns the HTML untouched (document-shell.js:101-105) and stx falls back to string-splicing the runtime head in via injectIntoHead (process.js:618-638). That splice appends a second <title> rather than replacing the first — verified:

<head><meta charset="utf-8"><title>HANDWRITTEN-TITLE</title><meta name="keywords" content="MARKER">
<title>RUNTIME-TITLE</title>          <-- injected by process.js:634-637
<style data-stx-cloak>…</style></head>

Two titles is not cosmetic. serve.js:10189-10190 takes the first <title> match and ships it as X-STX-Title; stx-router/dist/client.js:347 assigns that to document.title on every SPA navigation. A page with a stale hand-written title and a correct runtime title serves the stale one on arrival.

WRONG — resources/views/features/alerts.stx:1-11 (identical in all 23 marketing pages):

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Alerts and triage - bughq</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk…">
    <link rel="stylesheet" href="/marketing.css">
  </head>
  <body>
    
/var/www/reviewos-reviewos/releases/7ee24c5/resources/components/IssueThread.stx:612:963: include error: Error loading include file SiteNav: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/7ee24c5/resources/views/partials/SiteNav.stx'

╭──────────────────────────────────────────────────────────────╮
│  ERROR  Include Error                                    │
╰──────────────────────────────────────────────────────────────╯

File: IssueThread.stx (line 612, col 963)
Message: Error loading include file SiteNav: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/7ee24c5/resources/views/partials/SiteNav.stx'

Context:
  610:   headUseSeoMeta(meta);
  611:   const head = headGetHead();
> 612:   context.__stx_runtime_head = { ...context.__stx_runtime_head || {}, ...head.title && { title: head.title }, meta: head.meta ? [...head.meta] : [] };
  613: }

process.js:216 then reads link: [...baseHeadConfig.link || [], ...runtimeHead.link || []] and finds nothing. Verified: a page calling useSeoMeta({ canonical: 'https://bughq.org/pricing' }) renders description, og:* and twitter:* but no <link rel="canonical">. The useHead bridge (variable-extractor.js:312-323) does merge link, and order does not matter because useSeoMeta spreads the existing context object.

WRONG.

useSeoMeta({ title: 'Pricing - bughq', description: '…', canonical: 'https://bughq.org/pricing' })

RIGHT.

useSeoMeta({ title: 'Pricing - bughq', description: '…', ogUrl: 'https://bughq.org/pricing' })
  614: useHead({ link: [{ rel: 'canonical', href: 'https://bughq.org/pricing' }] })

CHECK.

grep -rn "canonical:" resources/ --include='*.stx'   # must print nothing — the key is silently dropped

RIGHT — the file starts with a server script and a <main>; the shell is config/ui.ts's job:

<script server>
useSeoMeta({
  title: 'Alerts and triage - bughq',
  description: 'Get alerted when an issue is new or spiking, and stay quiet for the known and handled. Tune the threshold, not the noise.',
  ogImage: 'https://bughq.org/og.png',
})
useHead({ link: [{ rel: 'canonical', href: 'https://bughq.org/features/alerts' }] })
</script>

@include('SiteNav')
<main>
  …

CHECK.

grep -rln '<!DOCTYPE\|</head>' resources/views --include='*.stx'   # must print nothing; today prints 24

resources/views/layouts/marketing.stx:1-15 is one of the 24 and is dead code besides — grep -rn '@extends(\|@layout(' resources/ --include='*.stx' returns nothing, so nothing extends it. Delete it rather than migrate it.

4.2 MUST — exactly one title source per page: useSeoMeta({ title }) in <script server>

WHY. Tiers 2–5 of 4.0 exist for templates stx cannot introspect. bughq pages all have a server script, so tier 1 always wins; any title written at tier 2/3 is dead text that will drift. config/ui.ts:74-78 app.head.title: 'bughq' is the last-resort tier and MUST stay a single word — its job is to make a missing title read as "bughq", not to be a title.

WRONG — the same string typed in two files, with the sync burden written into a comment instead of removed (site.config.ts:9-11: "So keep a pages title in step with the page's own useHead title"). Ten exact duplicates:

site.config.tspagestring
:30resources/views/index.stx:36bughq - Error tracking for people who ship
:34resources/views/dashboard.stx:6Issues - bughq
:38resources/views/account.stx:2Account - bughq
:42resources/views/use-cases.stx:6Use cases - bughq
:46resources/views/features/capture.stx:6Automatic error capture - bughq
:50resources/views/features/grouping.stx:6Fingerprint grouping - bughq
:54resources/views/features/releases.stx:6Releases and environments - bughq
:58resources/views/features/stack-traces.stx:6Readable stack traces - bughq
:62resources/views/features/alerts.stx:6Alerts and triage - bughq
:66resources/views/features/self-host.stx:6Self-hosting - bughq

RIGHT. The page owns the title; site.config.ts pages loses its title keys entirely (see 4.7). Do not reach for titleTemplate: '%s - bughq' to de-duplicate the suffix — it is inert (4.0.1); write the full string.

CHECK.

# more than one title source in a single view
for f in $(find resources/views -name '*.stx'); do
  n=$(grep -c "useHead({ *title\|useSeoMeta({\|<title>\|" "$f")
  [ "$n" -gt 1 ] && echo "$f has $n title sources"
done
# titles duplicated between site.config.ts and a view
grep -c "^      title:" site.config.ts    # must be 0; today 10

4.3 MUST — every public page declares a description; useHead({ title }) alone is a bug

WHY. useSeoMeta derives og:title from config.ogTitle ?? config.title and og:description from config.ogDescription ?? config.description (head.js:43), and twitter:title/twitter:description from the same fallbacks (head.js:58). One call therefore makes <title>, description, og:* and twitter:* provably identical. A bare useHead({ title }) writes exactly one tag and leaves the rest to whatever site.config.ts happens to say — which for /pricing, /login, /register, /forgot-password and /reset-password is nothing at all, because those five routes are absent from site.config.ts:28-69.

WRONG — all 11 useHead call sites in the repo pass a bare { title }:

file:linecall
resources/views/pricing.stx:2useHead({ title: 'Pricing - bughq' })
resources/views/login.stx:10useHead({ title: 'Sign in - bughq' })
resources/views/register.stx:10useHead({ title: 'Create your account - bughq' })
resources/views/forgot-password.stx:2useHead({ title: 'Reset your password - bughq' })
resources/views/reset-password.stx:2useHead({ title: 'Choose a new password - bughq' })
resources/views/dashboard.stx:6useHead({ title: 'Issues - bughq' })
resources/views/settings.stx:8useHead({ title: 'Settings - bughq' })
resources/views/account.stx:2useHead({ title: 'Account - bughq' })
resources/views/projects/index.stx:5useHead({ title: 'Your apps - bughq' })
resources/views/projects/new.stx:2useHead({ title: 'New app - bughq' })
resources/views/issue/[id].stx:107useHead({ title: issueHeadline ? … })

Seven marketing pages ship no <meta name="description"> either — resources/views/index.stx:33-49 and all six resources/views/features/*.stx:3-11 have charset, viewport, title and four links and nothing else. Their copy exists only at site.config.ts:31,:47,:51,:55,:59,:63,:67.

RIGHT — resources/views/pricing.stx:1-3:

<script server>
useSeoMeta({
  title: 'Pricing - bughq',
  description: 'Self-host bughq free forever, or start on the hosted plan. Per-project pricing with unlimited errors on every tier.',
  ogImage: 'https://bughq.org/og.png',
})
useHead({ link: [{ rel: 'canonical', href: 'https://bughq.org/pricing' }] })
</script>

CHECK.

grep -rn "useHead({ title:" resources/views --include='*.stx'   # must print nothing; today 11
# and per page, assert the rendered output carries a description that is not the placeholder
bun -e 'const {processDirectives,resetHead}=await import("@stacksjs/stx");const ui=(await import("./config/ui.ts")).default;
for (const f of (await Array.fromAsync(new Bun.Glob("resources/views/**/*.stx").scan()))) { resetHead();
  const o=await processDirectives(await Bun.file(f).text(),{},f,{...ui,autoShell:true,seo:{enabled:true}},new Set());
  const m=o.match(/<meta name="description" content="([^"]*)"/);
  if (!m || m[1].startsWith("A website built with stx")) console.log("NO DESCRIPTION:", f) }'

4.4 MUST — authenticated routes set robots: 'noindex, nofollow', and never appear in site.config.ts pages

WHY. useSeoMeta({ robots }) emits <meta name="robots"> at head.js:41-42. Nothing else in the app can: grep -rn robots resources/ config/ site.config.ts returns exactly one hit, config/docs.ts:175, which is the docs site. Meanwhile site-builder/seo.js:7 unconditionally pushes <link rel="canonical">, og:url, og:title, og:description, og:site_name and the twitter card for whatever path is requested, and site.config.ts:33-40 hands it real titles and descriptions for /dashboard and /account. bughq is actively advertising auth-walled URLs as indexable, with no robots.txt to contradict it — ls public/ shows assets audio favicon.svg images marketing.css og.png README.md svgs videos, and dist/robots.txt does not exist. The generator is real (site-builder/robots.js:1-26, reading site.robots and emitting a Sitemap: line from site.url) but is only ever invoked from site-builder/build.js:94-96, a path bughq does not run.

WRONG — resources/views/dashboard.stx:6, settings.stx:8, account.stx:2, projects/index.stx:5, projects/new.stx:2, issue/[id].stx:107 set a title and nothing else, while site.config.ts:33-40 gives two of those routes SEO metadata.

RIGHT — every view under an auth wall:

<script server>
useSeoMeta({ title: 'Issues - bughq', robots: 'noindex, nofollow' })
</script>

plus delete site.config.ts:33-40, and ship public/robots.txt:

User-agent: *
Disallow: /dashboard
Disallow: /settings
Disallow: /account
Disallow: /projects
Disallow: /issue

Sitemap: https://bughq.org/sitemap.xml

CHECK.

for f in resources/views/dashboard.stx resources/views/settings.stx resources/views/account.stx \
         resources/views/projects/index.stx resources/views/projects/new.stx 'resources/views/issue/[id].stx'; do
  grep -q "robots:" "$f" || echo "INDEXABLE AUTH ROUTE: $f"
done
test -f public/robots.txt || echo "MISSING public/robots.txt"
grep -n "'/dashboard'\|'/account'" site.config.ts   # must print nothing

dist/sitemap.xml:3-8 is separately broken — it emits http://localhost/... instead of site.config.ts:16's https://bughq.org, and lists /layouts/default and /components/FeatureCard as pages. Do not ship it. Manual review: a sitemap must list only routed pages.

WHY. useSeoMeta pushes canonical onto link (head.js:82-84) and forwards it to useHead (head.js:85-90) — but the server-script bridge that actually reaches the shell copies only title and meta:

// node_modules/@stacksjs/stx/dist/variable-extractor.js:324-331
}, useSeoMeta = (meta) => {
  headUseSeoMeta(meta);
  const head = headGetHead();
  context.__stx_runtime_head = { ...context.__stx_runtime_head || {}, ...head.title && { title: head.title }, meta: head.meta ? [...head.meta] : [] };
}

process.js:216 then reads link: [...baseHeadConfig.link || [], ...runtimeHead.link || []] and finds nothing. Verified: a page calling useSeoMeta({ canonical: 'https://bughq.org/pricing' }) renders description, og:* and twitter:* but no <link rel="canonical">. The useHead bridge (variable-extractor.js:312-323) does merge link, and order does not matter because useSeoMeta spreads the existing context object.

WRONG.

useSeoMeta({ title: 'Pricing - bughq', description: '…', canonical: 'https://bughq.org/pricing' })

RIGHT.

useSeoMeta({ title: 'Pricing - bughq', description: '…', ogUrl: 'https://bughq.org/pricing' })
useHead({ link: [{ rel: 'canonical', href: 'https://bughq.org/pricing' }] })

CHECK.

grep -rn "canonical:" resources/ --include='*.stx'   # must print nothing — the key is silently dropped
grep -rn "rel: 'canonical'" resources/views --include='*.stx' | wc -l   # should equal the number of public pages

4.6 MUST NOT — definePageMeta, usePageTitle/useTitle, titleTemplate, : the expression could not be evaluated: Invalid character '\u2026' -->, @structuredData … @endstructuredData

WHY. Each is documented in head.d.ts/seo.js and each fails silently in a <script server> block on 0.2.113. Verified renders, all with app.head.title: 'bughq' as the fallback:

InputRendered <title>Cause
definePageMeta({ title: 'X', description: 'Y' })bughqvariable-extractor.js:300 binds it to (_meta) => {}
usePageTitle('X')bughqnot in the injected parameter list, variable-extractor.js:466
useSeoMeta({ title: 'Pricing', titleTemplate: '%s - bughq' })PricingtitleTemplate never copied into context.__stx_runtime_head
@structuredData\n{"@type":"…"}\n@endstructuredDatabughq, and the literal directive text renders into <body>registerSeoDirectives() (seo.js:340-345) is never called
Alerts… bughq — plus a stray <title>Alerts…</title> inside <body>seo.js:139-232 returns tags as inline HTML at the call site

RIGHT. Everything on that list has a working equivalent: useSeoMeta for the first three and @seo; : the expression could not be evaluated: Invalid character '\u2026' --> (expression form) for the fifth.

CHECK.

grep -rn "definePageMeta\|usePageTitle\|useTitle(\|titleTemplate\|@seo(\|@endstructuredData" resources/ config/ site.config.ts
# must print nothing; today 0 — keep it that way

4.7 MUST — site.config.ts pages holds only what the page cannot know

WHY. injectSeo (site-builder/seo.js:1-45) is an exact-key lookup — serve.js:9794 does (siteConfig.pages ?? {})[normalizedPath] ?? {}, no wildcards, no params. seo.js:2 then resolves title = page.title ?? seo.title ?? site.name and description = page.description ?? seo.description, and declared() (seo.js:21-30) suppresses only tags the head already has — <title>, canonical, and any <meta> with a matching name/property. So a page's own <title> and description survive while og:title/og:description/twitter:title/twitter:description are minted fresh from the generic site blurb.

WRONG. site.config.ts:28-69 declares 10 keys. Sixteen routed marketing pages are missing: /docs, /pricing, /compare, the eight /compare/* pages, and the five /use-cases/* pages. /pricing and /docs are top-nav routes (resources/partials/SiteNav.stx:39-40, :67-68). Net effect on /compare/sentry: the page renders <title>bughq vs Sentry - bughq</title> (resources/views/compare/sentry.stx:6) and a precise description (:7) and then shares as "bughq - Error tracking for people who ship" from site.config.ts:20. The divergence exists even where the key is present — site.config.ts:43's /use-cases description is a different sentence from resources/views/use-cases.stx:7, so that one URL serves one string in <meta name="description"> and another in og:description.

RIGHT. Once 4.1–4.3 land, the page owns title/description/og/twitter and pages shrinks to per-route facts the page cannot compute:

pages: {
  '/': { image: 'https://bughq.org/og-home.png' },
  '/compare/sentry': { image: 'https://bughq.org/og-compare-sentry.png' },
},

CHECK.

# every routed view must either be in `pages` or (better) own its own og tags
comm -23 <(find resources/views -name '*.stx' ! -path '*/layouts/*' ! -path '*/components/*' \
            | sed 's|resources/views||;s|\.stx$||;s|/index$|/|' | sort) \
         <(grep -o "^    '/[^']*'" site.config.ts | tr -d " '" | sort)
grep -c "^      title:\|^      description:" site.config.ts   # target 0

4.8 MUST — config/ui.ts sets skipDefaultSeoTags: true and owns every shared head tag

WHY (placeholder tags). injectSeoTags (seo.js:243-308) runs from process.js:616-617 and early-returns only if options.seo.enabled === false, options.skipDefaultSeoTags === true, the <!-- stx SEO Tags --> marker is present, the runtime head already has a title/og:title/description, or the HTML already has og:title/twitter:title. bughq's marketing pages satisfy none, so seo.js:261-273 falls through to options.defaultTitle || 'stx Project' and options.defaultDescription || 'A website built with stx templating engine'. config/ui.ts:8-79 sets none of seo, skipDefaultSeoTags, defaultTitle, defaultDescription, defaultImage — all five are forwarded from project config at serve.js:9701-9705. The result is in the build output:

$ grep -o "<title>[^<]*</title>" dist/features/alerts.html
<title>stx Project</title>
$ grep -rl 'stx Project' dist --include='*.html' | wc -l
24

site-builder/seo.js:20 scrubs six of those literals by exact string match on the served path but leaves og:type and twitter:card behind — a band-aid over a config that should never have produced them.

WHY (shared tags). AppHeadConfig (config-types.d.ts:237-246) takes meta, link, script, headRaw, bodyClass, bodyAttrs, not just title. generateDocumentShell renders them for fragment pages (document-shell.js:22-36) and injectConfigHeadTags splices them into a page that already owns a head (document-shell.js:67-99), skipping any <meta> whose name/property/charset is already present (:72-77) and any <link> whose exact href is already present (:78-82). The dedup makes centralising safe before deleting the per-page copies. Today config/ui.ts:74-78 is app: { head: { title: 'bughq' } } and the same tags are re-typed: meta charset in 24 files, name="viewport" in 24, the Google Fonts preconnect+stylesheet in 34, /marketing.css in 23. The 11 fragment pages emit those <link>s inside the swapped body — resources/views/dashboard.stx:294-296, resources/views/pricing.stx:38-40.

RIGHT — config/ui.ts:

export default {
  componentsDir: 'resources/components',
  layoutsDir: 'resources/layouts',
  partialsDir: 'resources/partials',
  router: { container: 'main', interceptAllLinks: true },

  // The page and site.config own every SEO tag. Without this, seo.js:261-273
  // injects <title>stx Project</title> into any page that forgets one.
  skipDefaultSeoTags: true,

  app: {
    head: {
      title: 'bughq', // last resort only — see the precedence chain
      meta: [
        { charset: 'utf-8' },
        { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      ],
      link: [
        { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
        { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
        { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
        { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap' },
        { rel: 'stylesheet', href: '/marketing.css' },
      ],
    },
  },
} satisfies UiOptions

resources/views/login.stx:56 and resources/views/dashboard.stx:296 request different font weight sets — unify on the superset (the line above) before deleting the inline copies.

CHECK.

grep -n "skipDefaultSeoTags" config/ui.ts                       # must match; today 0
grep -rl 'stx Project' dist --include='*.html' | wc -l          # must be 0; today 24
grep -rc 'rel="preconnect" href="https://fonts.googleapis.com"' resources/views --include='*.stx' | grep -v ':0'   # must be empty; today 34 files
grep -rn 'charset=\|name="viewport"\|href="/marketing.css"' resources/views --include='*.stx'                       # must be empty

4.9 MUST — JSON-LD uses : the expression could not be evaluated: Invalid character '\u2026' -->, driven by the same array that renders the markup

WHY. processStructuredData (seo.js:61-97) does a balanced-paren scan, evaluates the object against the template context via safeEvaluateObject, injects "@context": "https://schema.org" if absent (seo.js:85-86) and emits <script type="application/ld+json"> with </ escaped (seo.js:87). It is wired at process.js:586. Because it evaluates against context, a <script server> array drives both the JSON-LD and the visible markup — one source, no drift. Never hand-write <script type="application/ld+json">.

WRONG. grep -rn 'ld+json\|structuredData\|schema.org' resources/ config/ public/ site.config.ts returns 0, while 22 pages hand-write a breadcrumb trail as bare anchors with no BreadcrumbList — resources/views/features/alerts.stx:16-20, resources/views/compare/sentry.stx:23-27, resources/views/docs.stx:17-20, resources/views/use-cases.stx:17-20, and 18 siblings:

<nav class="subnav shell" aria-label="Breadcrumb">
  <a href="/">Home</a><span class="sep">/</span>
  <a href="/#features">Features</a><span class="sep">/</span>
  <span>Alerts and triage</span>
</nav>

resources/views/pricing.stx:8-25 carries plan/interval state in signals with no Product/Offer; resources/views/index.stx has no SoftwareApplication or Organization.

RIGHT — one array, two consumers (verified render below):

<script server>
import type { Crumb } from '../../types/seo'

useSeoMeta({ title: 'Alerts and triage - bughq', description: '…' })

const crumbs: Crumb[] = [
  { name: 'Home', href: '/', url: 'https://bughq.org/' },
  { name: 'Features', href: '/#features', url: 'https://bughq.org/#features' },
  { name: 'Alerts and triage' },
]
</script>

@structuredData({
  '@type': 'BreadcrumbList',
  itemListElement: crumbs.map((c, i) => ({ '@type': 'ListItem', position: i + 1, name: c.name, item: c.url })),
})

<main>
  <nav class="subnav shell" aria-label="Breadcrumb">
    @foreach (crumbs as c)
      @if (c.href)<StxLink to="">{{ c.name }}</StxLink><span class="sep">/</span>
      @else<span>{{ c.name }}</span>@endif
    @endforeach
  </nav>

renders

<script type="application/ld+json">{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://bughq.org/"},…],"@context":"https://schema.org"}</script>

CHECK.

# every breadcrumb nav must be accompanied by a BreadcrumbList
diff <(grep -rl 'aria-label="Breadcrumb"' resources/views --include='*.stx' | sort) \
     <(grep -rl '
# today: 22 vs 0
grep -rn '<script type="application/ld+json"' resources/ --include='*.stx'   # must be empty — use the directive

Manual review for coverage beyond breadcrumbs: / needs SoftwareApplication, /pricing needs Product + Offer, /docs needs TechArticle. No grep can decide that; a reviewer must.

4.10 MUST — dynamic titles and descriptions are computed in <script server>, before useSeoMeta

WHY. context.__stx_runtime_head is written during server-script extraction (process.js:514 → variable-extractor.js:312-331), which runs before the shell is assembled at process.js:205. Any value in scope at that point can feed the title. There is a second constraint specific to bughq's SPA: serve.js:10189-10190 extracts the raw <title> text into the X-STX-Title header and stx-router/dist/client.js:347 assigns it straight to document.title, where HTML entities do not decode. Escape-then-assign shows &lt; on SPA arrival and a bare bracket on reload.

RIGHT — resources/views/issue/[id].stx:100-107 already gets this right and is the pattern to copy; extend it to the description:

// Dropping beats entity-escaping here: the SPA path hands raw title text to the
// X-STX-Title header and the router assigns it to document.title, where entities
// do NOT decode.
const issueHeadline = String((latest && latest.message) || issue?.title || '').replace(/[<>]/g, '').trim()
const short = issueHeadline.length > 60 ? `${issueHeadline.slice(0, 60)}…` : issueHeadline

useSeoMeta({
  title: issueHeadline ? `${short} - bughq` : 'Issue - bughq',
  description: `${issue.eventCount} events across ${issue.userCount} users, first seen in ${issue.firstRelease}.`,
  robots: 'noindex, nofollow',
})

CHECK.

# a title built from template data must not be interpolated into markup instead
grep -rn '<title>{{\|<title>@' resources/views --include='*.stx'    # must be empty
# and no entity escaping in a title expression
grep -rn "useSeoMeta\|useHead" -A3 resources/views --include='*.stx' | grep '&amp;\|&lt;\|escapeHtml'

4.11 SHOULD — every public page names an ogImage; og/twitter are never re-typed

WHY. head.js:43 and :58 derive og:title/og:description/twitter:title/twitter:description from title/description, and twitterImage = config.twitterImage ?? config.ogImage (head.js:58). Typing ogTitle or twitterDescription by hand is how the four tags drift apart. Only ogImage has no derivation and must be stated; site.config.ts:22 already points at https://bughq.org/og.png and public/og.png exists, so per-page images are the only reason to override.

WRONG. No page in resources/ passes ogImage, ogTitle, twitterCard or any og/twitter key — the entire og surface comes from site-builder/seo.js:7, which cannot see the page.

RIGHT.

useSeoMeta({
  title: 'bughq vs Sentry - bughq',
  description: 'How bughq, an open-source error tracker you self-host on Postgres, compares to Sentry, the incumbent error platform.',
  ogImage: 'https://bughq.org/og-compare-sentry.png',
  twitterCard: 'summary_large_image',
})

CHECK.

grep -rn "ogTitle:\|ogDescription:\|twitterTitle:\|twitterDescription:" resources/ --include='*.stx'   # must be empty — derived, not typed
grep -rLn "ogImage:" $(grep -rl "useSeoMeta(" resources/views --include='*.stx')                        # lists pages with no share image

4.12 MUST — SEO shapes live in types/, not inline in a .stx file

WHY. types/ already exists (types/stacks-app.d.ts, types/ts-cloud-augment.d.ts) and is where TypeScript surface belongs. stx ships the source types to extend: SeoMeta (head.d.ts:188-214), HeadConfig (head.d.ts:177-187), MetaTag/LinkTag/ScriptTag (head.d.ts:153-176). A page-local const crumbs = [...] with no annotation gives the agent editing it nothing to check against.

RIGHT — types/seo.ts:

import type { SeoMeta } from '@stacksjs/stx'

/** A single breadcrumb hop. `href`/`url` are omitted on the current page. */
export interface Crumb {
  name: string
  href?: string
  url?: string
}

/** Every public bughq page must supply at least these. */
export type PageSeo = Required<Pick<SeoMeta, 'title' | 'description'>> & Pick<SeoMeta, 'ogImage' | 'robots'>

/** Auth-walled routes. */
export const NOINDEX: Pick<SeoMeta, 'robots'> = { robots: 'noindex, nofollow' }

used as:

<script server>
import type { PageSeo } from '../../types/seo'

const seo: PageSeo = {
  title: 'Alerts and triage - bughq',
  description: 'Get alerted when an issue is new or spiking, and stay quiet for the known and handled.',
  ogImage: 'https://bughq.org/og.png',
}
useSeoMeta(seo)
</script>

useSeoMeta, useHead and state are injected into the server-script scope by name (variable-extractor.js:466-470) — never import { useSeoMeta } from '@stacksjs/stx' in a .stx file. Only import type is legitimate.

CHECK.

grep -rn "^import .*useSeoMeta\|^import .*useHead\|from '@stacksjs/stx'" resources/ --include='*.stx' | grep -v 'import type'
# must be empty — these are globals
ls types/seo.ts   # must exist once any page annotates its SEO object

4.13 Migration order (do not reorder — each step unblocks the next)

StepChangeUnblocks
1config/ui.ts: add skipDefaultSeoTags: true, app.head.meta, app.head.linkKills the 24 stx Project pages immediately; document-shell.js:72-82 dedup means no page edits required yet
2Delete the shared <meta charset>, viewport, font and /marketing.css lines from all 34 views4.8
3Strip <!DOCTYPE>/<html>/<head>/<title> from the 23 marketing pages; delete resources/views/layouts/marketing.stx4.1 — and only now does the runtime head reach these pages cleanly
4Replace all 11 useHead({ title }) and 23 hand-written titles with useSeoMeta({ title, description, ogImage }) + useHead({ link: [canonical] })4.2, 4.3, 4.5, 4.11
5Add robots: 'noindex, nofollow' to the 6 auth views; delete site.config.ts:33-40; ship public/robots.txt4.4
6Reduce site.config.ts pages to per-route image only4.7
7Add types/seo.ts; add : the expression could not be evaluated: Invalid character '\u2026' --> beside all 22 breadcrumb navs4.9, 4.12
10 days ago

5. Server blocks and the server-to-client bridge

A .stx file has exactly one server-side execution surface: <script server>. It runs on every render, its top-level declarations become the template context, and it is deleted from the response before the browser sees a byte. Everything the browser needs from it crosses one officially-supported boundary — the server-data bridge. bughq currently hand-rolls that boundary in base64, interpolates raw values into script bodies in 11 places, and is inlining the caller's session JWT into four pages as a side effect. This chapter is the fix.

The injected surface of <script server>

The block is transpiled with Bun.Transpiler({ loader: 'ts' }) (node_modules/@stacksjs/stx/dist/variable-extractor.js:218), rewritten to CommonJS (variable-extractor.js:495), then invoked as an async IIFE built by Function(...) at variable-extractor.js:466-470. These names are in scope with no import:

GroupNamesSource
Modulemodule, exports, requirevariable-extractor.js:466
Props (components)props, $props, defineProps, withDefaults, defineSlots, defineEmits, defineExposevariable-extractor.js:466; each prop key is also a bare local (:449-457)
HeaduseHead, useSeoMeta, definePageMetareal implementations, variable-extractor.js:315-330
Routingparams, useRoutevariable-extractor.js:300-310; useRouter is a stub (:311)
Reactivitystate, derived, effect, batch, ref, reactive, computed, watch, onMount, onDestroy, onMounted, onUnmounted, nextTick, provide, inject, useColorMode, useDarkall inert stubs, variable-extractor.js:283-331
Browser mockswindow, document, fetch, confirm, alertvariable-extractor.js:331-397; console is the real console (:397)
Request contextcookies, host, ip, locale, t, params, __stxServeSearch, __stxServeContextwritten onto the render context by node_modules/bun-plugin-stx/dist/serve.js:9151-9176

Every context key that looks like an identifier is passed in as a scope variable (variable-extractor.js:399, :460-466), which is why cookies and params are plain locals.


5.1 MUST — write the server block as the literal tag <script server>, once, at the top of the file

WHY. extractServerScriptVariables (process.js:483-499) runs any script whose attribute string matches /\bserver\b/ (process.js:488). The removal pass that deletes it from the response is stricter — /<script\s+server\b[^>]*>/gi at process.js:673 — so server must be the first attribute token or the block executes on the server and ships to the browser. The removal itself is string- and comment-aware (findScriptBodyEnd, process.js:55-..., called at :677); the extraction is not (see 5.9).

WRONG (hypothetical, and the reason the rule exists — \bserver\b matches inside a filename):

<script src="/js/server-status.js"></script>

process.js:488 accepts it as a server block; process.js:673 refuses to remove it.

RIGHT — resources/views/projects/index.stx:1:

<script server>

CHECK. Must return nothing:

grep -rn "<script[^>]*server[^>]*>" resources/ | grep -v "<script server>"

Currently clean (0 hits).


5.2 MUST — expose data to the template by declaring it at top level; nothing else works

WHY. convertToCommonJS appends module.exports.<name> = <name> for every top-level const/let/var (variable-extractor.js:586-600), function (:591-605), class (:618) and rewritten import binding (:566, :573). The result object is merged into the template context at variable-extractor.js:476. A binding nested inside if/try/a function body never reaches the template — extractDeclaredVariableNames only walks brace-depth 0 (variable-extractor.js:41-58).

WRONG — a value assigned only inside a block is invisible to the template unless it was also declared at top level.

RIGHT — resources/views/settings.stx:33-36 declares at top level and mutates inside the if:

let activeProject = null
let members = []
if (user) {
  activeProject = projects.find(p => p.id === requestedProject) || projects[0]
}

CHECK. Manual review, plus: if renders literally as in the response, the binding never left the server block. addCloakToUnresolvedExpressions (process.js:604) marks these — grep the served HTML for data-stx-cloak.


5.3 MUST — use import statements, one per line, at the top of the block

WHY. convertToCommonJS rewrites import { db } from '@stacksjs/database' into exactly const { db } = await import('@stacksjs/database') plus module.exports.db = db (variable-extractor.js:567-574). Hand-writing the await import() form buys nothing and loses the export line. The rewrite is line-based and regex-driven (variable-extractor.js:558): a multi-line import matches none of the three patterns, falls through to variable-extractor.js:579 and is emitted verbatim into a Function body — a SyntaxError that kills the whole block and silently degrades to regex-based static extraction (variable-extractor.js:480-492).

Verified against the shipped extractVariables:

BlockContext keys produced
import type { Issue } from '../types/issue' + import { db } from '@stacksjs/database' + const rows: Issue[] = [] + const n: number = 1db, rows, n
import {\n db,\n} from '@stacksjs/database' + const n = 1n only — block did not execute

WRONG — resources/views/settings.stx:10-11, resources/views/issue/[id].stx:12, and 10 more (22 await import( calls across resources/, 0 static imports):

const { db } = await import('@stacksjs/database')
const { appUrl, ingestUrl } = await import('../../app/Support/urls')

RIGHT:

import { db } from '@stacksjs/database'
import { appUrl, ingestUrl } from '../../app/Support/urls'

Both still satisfy 5.5's ordering requirement: the rewrite makes await import(...) the first statement in the compiled block.

CHECK.

grep -rn "await import(" resources/          # target: 0
grep -rnE "^\s*import\s+\{[^}]*$" resources/ # multi-line imports: must be 0

5.4 MUST NOT — call reactive APIs inside <script server>

WHY. state, derived, effect, ref, computed, watch, onMount, onDestroy, useRouter are no-op stubs in the server scope (variable-extractor.js:283-331). state() returns a getter whose .set and .update are empty functions (variable-extractor.js:284-286); effect() is (_fn) => {} (:300). Confirmed by running the shipped extractVariables: after const count = state(5); count.set(99) the value is still 5, and an effect() that assigns a variable leaves it untouched. Worse, a state() binding is a function, and extractBridgeData drops functions (client-script.js:371-372) — so server-side "state" cannot even reach the client.

RIGHT. Compute plain values in <script server>; create signals in the client script and seed them from the bridge (5.6).

CHECK.

python3 - <<'EOF'
import re,glob
S=re.compile(r'<script\s+server\b[^>]*>([\s\S]*?)</script>',re.I)
A=re.compile(r'\b(state|derived|effect|onMount|onDestroy|useRouter|watch|computed|ref)\s*\(')
for f in glob.glob('resources/**/*.stx',recursive=True):
    m=S.search(open(f).read())
    if m and A.search(m.group(1)): print(f, sorted({x.group(1) for x in A.finditer(m.group(1))}))
EOF

Currently clean (0 hits). Keep it that way.


5.5 MUST — read request state from the injected context locals, not requestContext or globalThis

WHY. injectServeRequestContext writes cookies, host, ip, __stxServeSearch and __stxServeContext onto this render's context object (serve.js:9156-9175), immediately before extractVariables runs (serve.js:9502 → 9505 for static routes, 9684 → 9686 for dynamic). cookies is a decoded Record<string,string> (serve.js:9105-9123). For a [param] route, processTemplateDynamic additionally sets context.params and each bare param name (serve.js:9667-9678).

requestContext is not part of stx — a full-tree grep of node_modules/@stacksjs/stx returns zero hits. It is a global installed by whichever server boots, and the two implementations differ:

dev (node_modules/@stacksjs/actions/dist/dev/views.js)production (node_modules/@stacksjs/buddy/dist/production-server.js)
lookuprequestStore.getStore() ?? globalThis.__stxServeContext (:7)globalThis.__stxServeContext, then globalThis.__stxServeCookies (:8-11)
storeAsyncLocalStorage, entered with requestStore.enterWith(ctx) (:110) after the globals are set (:108-109)none

That AsyncLocalStorage-first-then-mutable-global lookup is what forces bughq's ordering workaround, documented verbatim at resources/views/issue/[id].stx:5-9: "the request context rides an AsyncLocalStorage that only resolves correctly AFTER the first top-level await — read synchronously at the very top it comes back empty." The context locals have no ordering requirement and no cross-request global to race on.

WRONG — resources/views/issue/[id].stx:17-39 reconstructs the route param by parsing the URL, because the comment at :8-9 believes id is not injected:

let issueId = ''
try {
  const u = (typeof requestContext !== 'undefined' && requestContext.url && requestContext.url()) || ''
  if (u) {
    const seg = new URL(u).pathname.split('/').filter(Boolean)
    const i = seg.indexOf('issue')
    if (i !== -1 && seg[i + 1]) issueId = decodeURIComponent(seg[i + 1])
  }
} catch { issueId = '' }

and resources/views/settings.stx:18-22:

const query = new URLSearchParams((typeof globalThis !== 'undefined' && globalThis.__stxServeSearch) || '')
const cookieProject = (typeof requestContext !== 'undefined' && requestContext.cookie && requestContext.cookie('bughq_project')) || ''
const token = (typeof requestContext !== 'undefined' && requestContext.cookie && requestContext.cookie('bughq_token')) || ''

RIGHT — serve.js:9160, :9678, :9156:

const issueId = params.id ?? ''
const query = new URLSearchParams(__stxServeSearch || '')
const cookieProject = cookies.bughq_project ?? ''
const __token = cookies.bughq_token ?? ''   // __ prefix: see 5.8

CHECK.

grep -rn "requestContext\|globalThis.__stxServe" resources/   # target: 0 (currently 15)

If you must keep requestContext for a route the serve layer does not populate, the first statement in the block must be an await, and you must state why in a comment.


5.6 MUST — move server values to the client with the built-in bridge; never write your own encoder

WHY. stx ships the bridge. extractBridgeData(context) (client-script.js:365-376) collects every top-level, non-function, non-__/$ context value; generateServerDataBridge(code, serverData) (client-script.js:377-402) emits var <name> = <json>; for each name the client script references (:384) but does not redeclare (:386), escaping < to \u003c on the way out (:396). It is wired into every client-script path: process.js:609-610 and :705 for pages, client-script.js:731 inside processClientScript, signal-processing.js:685 for signal setup blocks, utils.js:638-642 for components (so component props bridge too). The contract is documented at client-script.d.ts:44-55. The emitted vars are function-scoped inside the IIFE / window.stx.mount wrapper (client-script.js:747-771) — they are not globals.

WRONG — resources/views/settings.stx:144-157 (server) and :181-189, :220-221 (client), 20 lines of hand-rolled base64 to reproduce the escaping the bridge already performs:

// settings.stx:144-151
function b64(value) {
  return JSON.stringify(Buffer.from(JSON.stringify(value ?? ''), 'utf8').toString('base64'))
}
const guideInstallB64 = b64(guide.install || '')
const keyFullB64 = b64(keyFull)
const projectIdB64 = b64(projectId || '')
// settings.stx:181-189
function fromB64(encoded) {
  const binary = atob(encoded)
  const bytes = Uint8Array.from(binary, ch => ch.charCodeAt(0))
  return JSON.parse(new TextDecoder().decode(bytes))
}
const installText = fromB64({!! guideInstallB64 !!})
const projectId = fromB64({!! projectIdB64 !!})

RIGHT — delete b64, fromB64 and all seven *B64 constants; reference the server names directly:

// <script server>
const guide = activeProject ? installGuide(activeProject.platform, keyFull) : { label: '', install: '', code: '' }
const projectId = activeProject?.id || null

// <script client>
const installText = guide.install
const codeText = guide.code
const currentKey = state(keyFull)
const maskedKey = state(keyMask)

Verified against the shipped function — the bridge already produces safe output for the exact value the base64 layer was written to protect:

generateServerDataBridge('console.log(install)', { install: '<script src="…/sdk.js" data-key="abc"></script>' })
→   var install = "\u003cscript src=\"…/sdk.js\" data-key=\"abc\">\u003c/script>";

Do not redeclare a bridged name in the client script: client-script.js:386 skips any name matched by (?:const|let|var|function|class)\s+<name>\b, which is why settings.stx:189's const projectId = … silently suppresses the server's projectId.

CHECK.

grep -rn "btoa(\|atob(\|toString('base64')\|TextDecoder()" resources/   # target: 0 (currently 2 sites)

5.7 MUST NOT — interpolate a server value into a script body with or

WHY. interpolateScriptsInTemplate (expressions.js:326-339) rewrites every non-server, non-src, non-data-raw, non-application/json script body before the bridge runs (process.js:515). Inside a script body:

FormEmitsEscapes <?Source
String(value) — raw text, no quoting at allnoexpressions.js:306
JSON.stringify(value) — a quoted JS literalnoexpressions.js:319
bridgeJSON.stringify(value).replace(/</g,'\\u003c')yesclient-script.js:396

Two independent failures follow. First, truncation — proven against the shipped interpolator with bughq's own values:

// issue/[id].stx:348 with a real stack trace
const stackText = "TypeError: x is not a function\n    at <script> (https://app/#</script>)"

The HTML parser ends the script element at that </script>; copyStack and everything after it cease to exist. Second, double-quoting — emits a JSON literal, so wrapping it in quotes embeds the quotes:

interpolateScriptExpressions("const autofixIssueId = ''", { issueId: 'abc-123' })
→ const autofixIssueId = '"abc-123"'

WRONG — resources/components/AutofixPanel.stx:11-12 (every autofix API call sends a double-quoted id), resources/views/issue/[id].stx:800 (var proj = '"proj_x"', so the bughq_project cookie never matches a real project id), and resources/views/issue/[id].stx:111, :348:

// server
const stackJson = JSON.stringify(stack)
// client
const stackText = {!! stackJson !!}

RIGHT — delete stackJson; the bridge already ships stack to this page (it is referenced by the word stack in the comment at issue/[id].stx:346):

// client
navigator.clipboard.writeText(stack)

For AutofixPanel.stx, props reach the component's client script through the bridge (utils.js:638, props merged into context at variable-extractor.js:477-479):

const autofixIssueId = issueId
const autofixProjectId = projectId

CHECK.

python3 - <<'EOF'
import re,glob
C=re.compile(r'<script(?![^>]*\bserver\b)[^>]*>(.*?)</script>',re.S|re.I)
for f in glob.glob('resources/**/*.stx',recursive=True):
    s=open(f).read()
    for m in C.finditer(s):
        for mm in re.finditer(r'\ inside a script body
awk '/<script/{s=1} /<\/script>/{s=0} s && (/@json\(/ || /\{!!/) {print FILENAME":"FNR": "$0}' $(grep -rl '' resources --include='*.stx')

Currently flags issue/[id].stx:348, settings.stx:186-190 (the latter is the safe base64 form). Any new hit that is not base64-wrapped is a bug.


7.13 MUST NOT — @suspense, @errorBoundary, @transition, @defer, @teleport, @keepAlive, @stream, @virtualList

WHY. Every one of these expands to markup plus an inline <script> that drives the DOM by hand — the exact thing rule 3 of the house standard forbids, wrapped in a directive:

  • suspense.js:38-48 — document.querySelector('[data-suspense-id="…"]') ×3, then style.display toggling.
  • error-boundaries.js:38 — same shape, data-boundary-content / data-boundary-fallback with style="display: none;".
  • transitions.js:99-101 — document.querySelector('[data-transition-id="…"]') plus a MutationObserver calling window.STX?.transition?.enter, a global that is not part of the signals runtime.

None of them are signal-aware: they toggle raw display on ids generated by Math.random() (transitions.js:88, suspense.js:3), so an SPA container swap orphans the listeners. The signals runtime already ships the reactive equivalents — :show (signals.js:2012), @if= (signals.js:1681), and a native <Suspense> boundary bound at signals.js:1958+ that aggregates useQuery/useFetch state.

Audit finding 59's own recommendation agrees for the async pair: the Loading… panels at dashboard.stx:397, settings.stx:738, projects/index.stx:141 are server-rendered auth gates, not async children, so @suspense is the wrong tool there too. @memo (misc-directives.js:100-135 → signals.js:1566-1567) is the one member of this family that is purely declarative and MAY be used.

CHECK.

grep -rnE '@(suspense|errorBoundary|transition|defer|teleport|keepAlive|stream|virtualList|virtualGrid|infiniteList|client|static)\b' resources --include='*.stx'

Must stay 0. If a hit appears, confirm the generated output with grep -c 'document.querySelector' dist/<page>.html before and after.

10 days ago

8. No vanilla JS — the strict-mode mandate

stx ships a client-script DOM validator. bughq has never turned it on. config/ui.ts (79 lines, read in full) declares componentsDir:10, layoutsDir:13, partialsDir:16, router:41, app:74 — and no strict key. The consequence is mechanical: validateClientScript(content, filePath, options.strict) runs on every non-server <script> body at node_modules/@stacksjs/stx/dist/process.js:704, but with strict undefined it resolves to { enabled: false } at script-validation.js:129 and the if (errors.length > 0 && strictConfig.enabled) guard at script-validation.js:148 makes every violation a silent no-op. defaultConfig (config.js:16-41) has no strict key, and the serve path only forwards it when it exists — bun-plugin-stx/dist/serve.js:9517 is literally ..."strict" in stxConfig && { strict: stxConfig.strict }.

Running stx's own PROHIBITED_DOM_PATTERNS over resources/**/*.stx today (verified, not estimated):

FileDistinct violation sitesViolating <script> blocksRules tripped
resources/views/settings.stx183getElementById, document.cookie, window.location, window.confirm, setTimeout, location.href=, location.assign, location.replace
resources/views/dashboard.stx153document.cookie, window.location, location.assign, location.replace, getElementById, window.history
resources/views/issue/[id].stx62setTimeout, document.cookie, window.location, location.replace
resources/views/projects/index.stx62document.cookie, window.location, location.assign, location.replace
resources/views/projects/new.stx51document.cookie, window.location, location.assign, location.replace
resources/views/register.stx42document.cookie, querySelector, createElement
resources/views/account.stx42document.cookie, window.location, location.assign, location.replace
resources/views/login.stx32document.cookie, getElementById, window.location
resources/views/index.stx31querySelectorAll
resources/views/pricing.stx11location.assign
resources/components/AutofixPanel.stx11setTimeout
Total662012 of the 25 shipped rules

Against that: grep -rn 'useRef\|useEventListener\|useTimeout\|useInterval\|useCookie\|useSearchParams\|useLocalStorage\|useQuery\|useColorMode\|onMount\|onDestroy' resources/ --include='*.stx' returns 0 across all 52 .stx files. navigate() is used exactly 3 times (login.stx:43, pricing.stx:16, register.stx:56). The framework's answer to every one of the 66 sites is already loaded in the page and already auto-imported.


8.1 MUST — turn strict on in config/ui.ts before writing another line of client script

WHY. StrictModeConfig is { enabled, failOnViolation?, allowPatterns? } (node_modules/@stacksjs/stx/dist/types/config-types.d.ts:214-218), surfaced as strict?: boolean | StrictModeConfig at config-types.d.ts:309. loadStxConfig reads bughq's file via alias: 'ui' (config.js:401-402), so config/ui.ts is the one place this can live. With enabled: true, failOnViolation: false the validator console.warns a per-file report with line numbers and the exact composable to use (script-validation.js:150-155); with failOnViolation: true it throws (script-validation.js:152) — at render time, which on the SSR path means a 500 per request. Turn it on warning-first, then flip.

WRONG — config/ui.ts:8-79, the entire default export, with the key absent:

export default {
  componentsDir: 'resources/components',   // :10
  layoutsDir: 'resources/layouts',         // :13
  partialsDir: 'resources/partials',       // :16
  router: { container: 'main', interceptAllLinks: true },  // :41-43
  app: { head: { title: 'bughq' } },       // :74-77
} satisfies UiOptions

RIGHT:

export default {
  componentsDir: 'resources/components',
  layoutsDir: 'resources/layouts',
  partialsDir: 'resources/partials',

  // stx's client-script DOM guard. process.js:704 calls validateClientScript on
  // every non-server <script> body; without this key it resolves to
  // { enabled: false } (script-validation.js:129) and never reports anything.
  // failOnViolation throws from inside the render, so it stays off in prod.
  strict: {
    enabled: true,
    failOnViolation: process.env.NODE_ENV !== 'production',
    allowPatterns: [],
  },

  router: { container: 'main', interceptAllLinks: true },
  app: { head: { title: 'bughq' } },
} satisfies UiOptions

Two things about allowPatterns you must know before using it. The filter is allowPatterns.some(a => message.includes(a) || pattern.source.includes(a)) (script-validation.js:130-132). It is rule-global, not site-scoped — there is no way to exempt one call site. And it is a substring match on the message, so allowPatterns: ['location'] silently kills four rules at once (window.location, location.href assignment, location.assign(), location.replace()). Keep the array empty and fix the code.

CHECK:

grep -n "strict" config/ui.ts   # must print a `strict:` key

and the burn-down list, which runs stx's own validator and needs no config change:

bun -e '
const { validateClientScript: V } = await import("./node_modules/@stacksjs/stx/dist/script-validation.js")
let bad = 0
for await (const f of new Bun.Glob("resources/**/*.stx").scan(".")) {
  const src = await Bun.file(f).text()
  for (const m of src.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
    if (/\bserver\b/i.test(m[1])) continue
    try { V(m[2], f, { enabled: true, failOnViolation: true }) } catch (e) { bad++; console.log(e.message) }
  }
}
console.log("violating blocks:", bad); process.exit(bad ? 1 : 0)'

Today that exits 1 and prints violating blocks: 20. Gotcha: the line numbers it reports are body-relative, not file-relative — script-validation.js:139-145 splits content, which is the script body only. AutofixPanel.stx reports line: 180; the <script client> opens at file line 10, so the real site is line 189. Add (tag line − 1).


8.2 MUST — use this replacement table. Every row is a real bughq site

PROHIBITED_DOM_PATTERNS is 25 rules at script-validation.js:1-127. Each carries its own replacement in suggestion. Every replacement below is auto-imported into <script client> from window.stx — see STX_AUTO_IMPORTS at client-script.js:143 (onMount:152, onDestroy:153, useRef:154, navigate:155, useSearchParams:160, useQuery:162, useLocalStorage:166, useCookie:168, useEventListener:173, useInterval:178, useTimeout:179, useColorMode:185), destructured at client-script.js:334-340 as var { … } = window.stx || window;. Do not import any of them.

Vanilla (banned)stx replacementMechanismLive bughq site
document.getElementById()ref="name" + useRef('name')signals.js:1825-1828 (ref handler), signals.js:926-935 (useRef)login.stx:172, settings.stx:513, dashboard.stx:577
document.querySelector(All)()ref= + useRef, or :if/@foreach on the markupsameregister.stx:187,191, index.stx:135,139,142
document.createElement()markup + @if / :ifsignal-processing.js:201-265, signals.js:1681-1690register.stx:193
el.innerHTML = x:html="x"signals.js:1817-18230 sites — keep it that way
el.textContent = x:text="x"signals.js:1811-1815login.stx:173, index.stx:136,143
el.style.display = …:if / :show / :class + Crosswindsignals.js:1694-1697 (:show), 1802-1805 (:class)login.stx:173, index.stx:140, register.stx:188,195
el.classList.add/remove:class="{ 'cls': cond }"bindClass, signals.js:2155-21710 sites — keep it that way
el.setAttribute(k, v)__stx_k="eyJydWxlcyI6W10sImRlZmF1bHRWYWx1ZSI6IiJ9"signals.js:1784-1800index.stx:137,144, register.stx:188
addEventListener / on*= attr@click / @change / @input, or useEventListener()signals.js:1830-1840, signals.js:3645-3652dashboard.stx:429, StartMenu.stx:52
window.location.*, location.assign/replace/href=navigate(url) / navigate(url, true)signals.js:941-95229 sites across 8 files
window.history.replaceStateuseSearchParams().set/setAllsignals.js:1004-1016dashboard.stx:593
setTimeout / setIntervaluseTimeout() / useInterval()signals.js:3271-3300, 3229-3268settings.stx:203,406,512, issue/[id].stx:354, AutofixPanel.stx:189
self-rescheduling polluseQuery(url, { refetchInterval }) or useIntervalsignals.js:1110-1113AutofixPanel.stx:189
document.cookie = …useCookie(name, opts)signals.js:3568-359921 sites across 8 files
localStorage.*useLocalStorage(key, default)signals.js:3526-353974 raw hits
window.confirm()window.stxConfirm(...)already used at settings.stx:235-236; fallback at :237 is the violationsettings.stx:237
document.title = …useHead({ title })signals.js:1119+; already correct in bughq's server blocks—

8.3 MUST — never select an element; declare a ref and read it with useRef()

WHY. useRef(name) reads componentScope.$refs[name] (signals.js:926-935), populated by the ref / :ref / x-ref attribute handler at signals.js:1825-1828. componentScope.$refs is initialised at signals.js:1224 and — critically for bughq, whose router swaps <main> — preserved across SPA navigation at signals.js:5108-5109. A getElementById cached in a closure is not; it points at a detached node after the first swap.

WRONG — resources/views/settings.stx:504-519. dzTyped is already a signal (settings.stx:480) and the input already carries @input="dzType" (settings.stx:980), yet the open handler reaches through the DOM and re-clears the field by hand:

function dzOpen(which) {
  dzAction.set(which)
  dzTyped.set('')
  // …
  setTimeout(() => {
    const field = document.getElementById('dz-input')   // settings.stx:513
    if (field) {
      field.value = ''
      field.focus()
    }
  }, 20)
}

RIGHT — one ref, one composable timer, and :model doing the value mirror so field.value = '' becomes unrepresentable:

<!-- resources/views/settings.stx:980 -->
<input ref="dzInput" :model="dzTyped" type="text"
       class="field w-full px-3.5 py-2.5 text-sm mono mb-4"
       autocomplete="off" autocapitalize="off" spellcheck="false">
const dzInput = useRef('dzInput')
// useTimeout starts on creation (signals.js:3294) and registers its own
// onDestroy(stop) at :3296 — park it, then re-arm per open.
const focusDz = useTimeout(() => dzInput.current?.focus(), 20)
focusDz.stop()

function dzOpen(which) {
  dzAction.set(which)
  dzTyped.set('')      // :model writes this back to the input; no field.value
  dzNote.set(null)
  dzBusy.set(false)
  focusDz.start()
}

:model two-way binding is dispatched at signals.js:1699-1703 → bindModel at signals.js:2069. dzType (settings.stx:528-530) is then dead code — delete it and the @input attribute.

CHECK: grep -rnE 'document\.(getElementById|querySelector|querySelectorAll|getElementsBy)' resources/ --include='*.stx' → must be empty. Today: 8 hits.


8.4 MUST — bind events with directives; no addEventListener, no on*= attributes

WHY. @click / @change / @input / @submit are handled by the attribute dispatcher at signals.js:1830-1840, which parses modifiers (@submit.prevent, @keydown.enter) from the attribute name. For non-element targets (window, document) use useEventListener(event, handler, { target }) — signals.js:3645-3652 — which registers onDestroy(() => target.removeEventListener(...)) for you. A DOM-0 onchange= attribute gets none of that, is invisible to the strict validator (it is not inside a <script> body, so the scan at process.js:696-701 never sees it), and cannot reference the page's signals.

WRONG — resources/views/dashboard.stx:429, ~200 characters of inline JS in an attribute, doing by hand exactly what the sibling page does with a directive:

<select onchange="document.cookie = 'bughq_project=' + encodeURIComponent(this.value) + '; path=/; max-age=31536000; samesite=lax' + (location.protocol === 'https:' ? '; secure' : ''); location.href = ''"
        class="panel mono px-3 py-1.5 text-sm text-muted">

RIGHT — mirror resources/views/settings.stx:750, which already does this correctly:

<select @change="switchProject" class="panel mono px-3 py-1.5 text-sm text-muted">
  @foreach (projects as p)
    <option value="" {{ p.id === projectId ? 'selected' : '' }}>{{ p.name }}</option>
  @endforeach
</select>
// <script client> — useCookie and navigate are auto-imported
const activeProject = useCookie('bughq_project', { maxAge: 31536000, sameSite: 'Lax' })

function switchProject(e) {
  activeProject.set(e.target.value)
  navigate('/dashboard')
}

useCookie serialises path=/, SameSite=Lax and adds Secure on https itself (signals.js:3582-3596), and encodeURIComponent is its default encoder (signals.js:3570) — the hand-written string is byte-for-byte reimplementation.

CHECK:

grep -rnE '\son(click|change|input|submit|keydown|keyup|focus|blur|load|error)=' resources/ --include='*.stx'
grep -rn 'addEventListener' resources/ --include='*.stx'

Today: 17 DOM-0 handlers (16 in the dead scaffolding under resources/components/, 1 live at dashboard.stx:429), 0 addEventListener. Both greps must return empty.


8.5 MUST — express visual state as :class / :if / :show, never by assigning .style.* or .classList

WHY. :class accepts the object form { 'a b': cond } and splits multi-token keys before touching classList (bindClass, signals.js:2155-2171); :style, :show and :if are dispatched at signals.js:1806-1809, 1694-1697, 1681-1690. All four re-run inside an effect, so they track the signal. An imperative el.style.display = 'none' runs once and is erased by the next <main> swap. It also writes raw CSS from JS, which violates the Crosswind-only rule from Chapter 5 twice over.

WRONG — resources/views/index.stx:132-147. This script exists on 1 of the 24 pages that include SiteNav, so on the other 23 a signed-in visitor still sees "Log in / Sign up":

<script>
  (function () {
    if (!localStorage.getItem('token')) return
    document.querySelectorAll('[data-auth-login]').forEach(function (a) {
      a.textContent = 'Dashboard'                 // index.stx:136
      a.setAttribute('href', '/dashboard')        // index.stx:137
    })
    document.querySelectorAll('[data-auth-signup]').forEach(function (a) {
      a.style.display = 'none'                    // index.stx:140
    })
  })()
</script>

RIGHT — the condition moves into resources/partials/SiteNav.stx (lines 44-45, 70-71, where the data-auth-* markers currently sit), so all 24 pages get it, and the data-auth-* markers disappear:

<script client>
const loggedIn = state(!!localStorage.getItem('token'))
</script>

<StxLink to="/dashboard" :if="loggedIn()"  class="nav-link">Dashboard</StxLink>
<StxLink to="/login"     :if="!loggedIn()" class="nav-link">Log in</StxLink>
<StxLink to="/register"  :if="!loggedIn()" class="nav-cta">Sign up</StxLink>

Use the attribute form :if="expr" for signal-driven conditionals, not the block form. bughq already does this correctly at dashboard.stx:413 (@if="!dismissed().includes('')"). The block form is server-side (process.js), and only becomes reactive when convertSignalDirectivesToAttributes recognises the condition as client-reactive and rewrites it to an attribute (signal-processing.js:201-265, gated by conditionIsClientReactive at :244 and :257). Attribute form is unconditional; use it and stop guessing.

CHECK: grep -rnE '\.(classList|innerHTML|outerHTML|textContent)\b|\.style\.' resources/ --include='*.stx' → must be empty. Today: 10 hits (login.stx:173, index.stx:136,140,143, register.stx:188,195, plus prop-name false positives in InputGroup.stx:12,28 — read them, do not blanket-suppress).


8.6 MUST — build DOM in markup; createElement and innerHTML are banned outright

WHY. There is no legitimate use in a .stx file. The template is the DOM description; @if/:if/@foreach are the conditional and repeated cases. createElement additionally forces you to write CSS in a string, which is how register.stx ended up duplicating the page's own design tokens.

WRONG — resources/views/register.stx:181-202. Three violations stacked: querySelector twice, createElement, a 200-char cssText, and an insertBefore:

var p = new URLSearchParams(location.search)
var email = p.get('email')
if (email) {
  var el = document.querySelector('input[name="email"]')                    // :187
  if (el) { el.value = email; el.setAttribute('readonly', 'readonly'); el.style.opacity = '0.85' }  // :188
}
if (p.get('invite')) {
  var form = document.querySelector('form')                                 // :191
  if (form) {
    var hint = document.createElement('p')                                  // :193
    hint.textContent = "You've been invited to a project — sign up to join it."
    hint.style.cssText = 'font-size:13px;margin:0 0 14px;…color:var(--accent);…'  // :195
    form.parentNode.insertBefore(hint, form)                                // :196
  }
}

RIGHT — the whole trailing <script> is deleted. Two signals in the existing <script client> (which already holds error and loading), and the banner becomes markup above the form at register.stx:130:

// <script client> — useSearchParams resyncs on popstate and stx:navigate (signals.js:999-1000)
const params = useSearchParams()
const invitedEmail = derived(() => params.get('email') || '')
const isInvited = derived(() => !!params.get('invite'))
<p :if="isInvited()" class="text-[13px] mb-3.5 px-3 py-2.5 rounded-lg text-accent bg-accent/10 border border-accent/35">
  You've been invited to a project — sign up to join it.
</p>

<form @submit="handleSubmit" class="space-y-4">
  @if (error())
    <div class="err text-sm rounded-lg px-3 py-2">{{ error() }}</div>
  @endif
  <!-- resources/views/register.stx:141 -->
  <input name="email" type="email" required autocomplete="email"
         :value="invitedEmail()" :readonly="isInvited()"
         :class="{ 'opacity-85': isInvited() }"
         placeholder="you@example.com" class="field w-full px-3.5 py-2.5 text-sm">

:readonly goes through the generic attribute binder at signals.js:1784-1800, which removes the attribute on false and sets it bare on true — the correct boolean-attribute semantics setAttribute('readonly','readonly') fakes.

The identical rewrite applies to resources/views/login.stx: delete the permanently-hidden <div id="oauth-error" … style="display:none"> at login.stx:139 and the whole script at login.stx:167-176, then add two lines to the <script client> that already exists at login.stx:13:

const error = state('')                       // login.stx:16 — already there
const params = useSearchParams()
if (params.get('error')) error.set(params.get('error'))

The file already renders it: @if feature-btn", a directive mangled into an attribute. NotificationPopup.stx:24,41 calls onclick="closeNotificationPopup()" against globals that do not exist.

And resources/views/components/FeatureCard.stx:1-5 is silently dead — it declares locals, never reads props, so it would always render an empty title and <img src="">:

<script server>
const title = ''
const description = ''
const image = ''
</script>

RIGHT — the library's own convention (Badge.stx:2-6, Avatar.stx:2-8), no imports, no <template> wrapper needed:

<script server>
export const title = $props.title || ''
export const description = $props.description || ''
export const image = $props.image || ''
</script>

<article class="area-card">
  @if (image)
    <div class="area-icon"><img src="" alt="" loading="lazy"></div>
  @endif
  <h3>{{ title }}</h3>
  <p>{{ description }}</p>
  <slot />
</article>

CHECK

grep -rn "^import" resources/components/*.stx        # expect 0 — everything is auto-imported
grep -rln "DOCTYPE\|<html" resources/components/      # expect 0
grep -rn "const [a-z][A-Za-z]* = ''" resources/components/  # locals masquerading as props

10.13 MUST delete the dead scaffolding under resources/components

RULE — Delete the ten unreferenced template files. MUST.

WHY — Only AutofixPanel is used (issue/[id].stx:564); ContextMenu/StartMenu are referenced solely by their own sibling item files. The rest are Windows-desktop-demo scaffolding from the project template, every one of them shadowing a shipped equivalent that resolves through the plugin dir once §10.1 lands: NotificationPopup.stx vs Notification.stx; ContextMenu.stx/ContextMenuItem.stx vs the dropdown family (quarantined — delete anyway, they are unused); InputGroup.stx vs TextInput.stx; LoginForm.stx vs auth/Login.stx. Additionally, resources/views/components/FeatureCard.stx sits inside pagesDir, so stx routes it as a public page and builds /components/FeatureCard into the sitemap.

WRONG — present and unreferenced:

resources/components/ContextMenu.stx        resources/components/StartMenu.stx
resources/components/ContextMenuItem.stx    resources/components/StartMenuItem.stx
resources/components/FeatureButton.stx      resources/components/Taskbar.stx
resources/components/InputGroup.stx         resources/components/Window.stx
resources/components/LoginForm.stx          resources/components/PackageItem.stx
resources/components/NotificationPopup.stx  resources/views/components/FeatureCard.stx

RIGHT — git rm all twelve. resources/components/ then holds AutofixPanel.stx plus the new CodeBlock.stx from §10.7, and resources/views/components/ is removed entirely.

CHECK — a reference audit, runnable in CI:

for f in resources/components/*.stx; do
  n=$(basename "$f" .stx)
  c=$(grep -rl "<$n\b" resources/ app/ --include='*.stx' | grep -v "$f" | wc -l)
  [ "$c" -eq 0 ] && echo "UNREFERENCED: $f"
done

Plus test ! -d resources/views/components — no component may live under pagesDir.

10 days ago

11. Styling with Crosswind, and TypeScript

Two halves of the same discipline: the browser gets utility classes generated from a typed config, and the editor gets types that actually run. bughq currently fails both — 1,001 lines of hand-written CSS live inside 23 .stx files, another 273 in public/marketing.css, and bun run typecheck inspects exactly nine project files.

Part A — Crosswind

How the CSS actually gets to the page (read this before any rule)

StepMechanismFile:line
Config discoverybunfig searches <cwd>, <cwd>/config, <cwd>/.config for crosswind.* — this is why config/crosswind.ts is foundnode_modules/bunfig/dist/discovery.js:32-40, node_modules/@stacksjs/stx/dist/dev-server/crosswind.js:145-147
Class extractionRegex over the rendered HTML: class="…" plus string literals only inside :class="…" / x-class="…"dev-server/crosswind.js:218-238
Config merge{...baseConfig, ...userConfig, content: [], output: '', preflight: true, minify: false, theme, safelist} — your content/output/preflight/minify are discardeddev-server/crosswind.js:305-313
Theme mergeOnly theme.extend is carried across; top-level theme.* keys are droppeddev-server/crosswind.js:296-300
Generationnew CSSGenerator(cfg), generate() per safelist entry then per extracted class, toCSS(true, false)dev-server/crosswind.js:313-318
InjectionOne <style data-crosswind="generated"> before </head>dev-server/crosswind.js:371-393, called from process.js:258-259 (top-level render only, gated on context.__stx_inject_css !== false)
SPA survivalOn navigation the router merges style[data-crosswind] (append-only, dedup by block) and destroys every other <head> stylenode_modules/stx-router/dist/client.js:517-529, 548

Extraction runs on rendered output, so a class string composed in <script server> and interpolated with is safe. A class string computed in the browser is not. That single distinction drives rules 11.4 and 11.5.


11.1 MUST NOT put a <style> block in any .stx file

WHY. Three independent mechanisms punish it.

  1. It is invisible to Crosswind. The extractor only reads class= attributes (dev-server/crosswind.js:219); a <style> block is dead weight the engine cannot dedupe, purge, or minify.
  2. The SPA router tears down and rebuilds page styles on every swap, but only the Crosswind tag is treated as durable (stx-router/dist/client.js:548 removes every head style that is neither keepIds nor [data-crosswind]). Fragment swaps re-append the incoming page's styles as style[data-stx-page] (client.js:433-438) while the entry page's server-rendered <style> — which carries no data-stx-page — is never removed (client.js:418). Navigate /dashboard → /settings and two conflicting :root blocks are live at once.
  3. It defeats every one of the twelve requirements at once: it is vanilla CSS, it is not a directive, and it is duplicated per file.

WRONG — resources/views/dashboard.stx:298-369, 72 lines of CSS in a view:

<style>
:root {
  --bg: #fbfbfd; --panel: #ffffff; --border: rgba(15,23,42,0.09);
  --text: #0b0f19; --text-2: #4b5565; --text-3: #97a1b2; --accent: #e11d48;
  ...
}
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; }
.icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 34px;
  height: 34px; border: 1px solid var(--border); border-radius: 9px; color: var(--text-2);
  background: var(--panel); cursor: pointer; transition: color 0.15s ease, border-color 0.15s ease; }
.chrome-logout { right: 18px; font-weight: 500; cursor: pointer; ... }
</style>

RIGHT — tokens move to config/crosswind.ts (rule 11.2), repeated shapes become shortcuts, one-offs become utilities on the element:

<!-- resources/views/dashboard.stx — no <style> tag at all -->
<button class="inline-flex items-center justify-center w-[34px] h-[34px] rounded-[9px]
               border border-line bg-panel text-muted transition-colors hover:text-ink">
// config/crosswind.ts
shortcuts: {
  'panel': 'bg-panel border border-line rounded-xl',
  'icon-btn': 'inline-flex items-center justify-center w-[34px] h-[34px] rounded-[9px] border border-line bg-panel text-muted transition-colors hover:text-ink',
},

shortcuts are expanded by the render path (dev-server/crosswind.js:319-345) provided the shortcut name itself appears in a class= attribute or in safelist.

CHECK.

grep -rln --include='*.stx' '<style' resources/    # must print nothing; currently 23 files

11.2 MUST declare design tokens exactly once, in config/crosswind.ts

WHY. theme.extend.colors values are emitted verbatim into the utility (bg-panel { background-color: var(--panel) } — verified by generating against bughq's own config), and the var() targets must be defined in exactly one place or the utilities lie. bughq defines them in three mutually inconsistent places:

Source--bg (light)--bg (dark)panel tokenConsumers
resources/views/dashboard.stx:302 (+ 8 byte-identical copies in account, login, register, forgot-password, reset-password, projects/index, projects/new, settings)#fbfbfd#090b11--panelapp pages
resources/views/issue/[id].stx:369#f6f7f9#0a0c12--panel, --panel-2one page
public/marketing.css:5-51#fbfbfd#090b11--surface (no --panel)26 pages

config/crosswind.ts:25 maps panel: 'var(--panel)'. On any of the 26 marketing pages that token is undefined, so bg-panel resolves to nothing. On /issue/[id] the "same" background is a different colour than on /dashboard.

WRONG — nine copies of this, one per app page (resources/views/account.stx:64-76, dashboard.stx:300-315, …):

<style>
:root { --bg: #fbfbfd; --panel: #ffffff; --border: rgba(15,23,42,0.09); ... }
@media (prefers-color-scheme: dark) { :root { --bg: #090b11; ... } }
:root[data-theme="dark"]  { --bg: #090b11; ... }
:root[data-theme="light"] { --bg: #fbfbfd; ... }
</style>

RIGHT — one preflights entry in the typed config. Preflight is { getCSS: () => string } (node_modules/@cwcss/crosswind/dist/types.d.ts:136-138); the returned CSS is prepended to the generated sheet, so it lands inside the one <style data-crosswind> tag the router preserves across SPA swaps:

// config/crosswind.ts
const LIGHT = `--bg:#fbfbfd;--panel:#ffffff;--panel-2:#f4f5f8;--border:rgba(15,23,42,.09);
--text:#0b0f19;--text-2:#4b5565;--text-3:#97a1b2;--accent:#e11d48;`
const DARK = `--bg:#090b11;--panel:#10131c;--panel-2:#161a24;--border:rgba(148,163,184,.12);
--text:#f3f5f9;--text-2:#b3bccb;--text-3:#667085;--accent:#fb7185;`

preflights: [{
  getCSS: () => `:root{${LIGHT}}
    @media (prefers-color-scheme: dark){:root{${DARK}}}
    :root[data-theme="dark"]{${DARK}}
    :root[data-theme="light"]{${LIGHT}}`,
}],

Verified: a preflights entry survives the render path's config merge and appears in the generated sheet.

CHECK.

# Any CSS custom property declared outside config/crosswind.ts is a violation.
grep -rn --include='*.stx' -- '--[a-z0-9-]*:' resources/ | wc -l   # must be 0; currently 101
grep -cE -- '^\s*--[a-z-]+:' public/marketing.css                  # must be 0; currently 47

11.3 MUST end config/crosswind.ts with satisfies CrosswindConfig, and put every theme value under theme.extend

WHY. config/ui.ts:79 ends } satisfies UiOptions and is therefore correct. config/crosswind.ts:40 ends with a bare } and is therefore not: line 38 says preflight: true, and preflight is not a key of CrosswindConfig — the real key is includePreflight (node_modules/@cwcss/crosswind/dist/types.d.ts:27-48; the runtime reads includePreflight ?? true). It is a silent no-op. So are content (lines 14-20), minify (line 39) and output: the render path overwrites all four unconditionally at dev-server/crosswind.js:307-310. Five of the eight lines in that config do nothing.

Separately, dev-server/crosswind.js:296-300 copies only theme.extend from user config onto the base theme. A top-level theme.colors is silently discarded at request time.

WRONG — config/crosswind.ts:13, 38-40:

export default {
  content: [ './resources/views/**/*.{stx,html}', ... ],   // discarded at crosswind.js:307
  theme: { extend: { colors: {...}, fontFamily: {...} } },
  preflight: true,                                          // not a key. no-op.
  minify: false,                                            // discarded at crosswind.js:310
}

RIGHT:

import type { CrosswindConfig } from '@cwcss/crosswind'

export default {
  theme: { extend: { colors: { canvas: 'var(--bg)', panel: 'var(--panel)', /* … */ } } },
  preflights: [/* rule 11.2 */],
  safelist: [/* rule 11.4 */],
  shortcuts: { /* rule 11.1 */ },
} satisfies Partial<CrosswindConfig>

CHECK. Add the satisfies and run tsc. Confirmed to produce:

config/crosswind.ts(38,3): error TS2561: Object literal may only specify known properties,
  but 'preflight' does not exist in type 'Partial<CrosswindConfig>'. Did you mean 'preflights'?

Grep guard: grep -L 'satisfies' config/crosswind.ts must print nothing.


11.4 MUST NOT return a class string from a client-side function

WHY. The extractor's dynamic branch (dev-server/crosswind.js:227-237) pulls single-quoted string literals out of the :class attribute value and nothing else. :class="fn()" yields zero literals, so zero utilities are generated. The classes then exist in the DOM at runtime with no matching rule.

WRONG — resources/components/AutofixPanel.stx:89-91, 109-113 bound at :309, :337:

<script client>
function loadingClass() {
  return autofixLoading() ? 'grid gap-3 autofix-run' : 'autofix-hidden'
}
function readyClass() {
  return !autofixLoading() && autofixEnabled() && !!currentRepository() && !hasAutofixRun()
    ? 'flex flex-col gap-4 md:flex-row md:items-center md:justify-between autofix-run'
    : 'autofix-hidden'
}
</script>
<div :class="loadingClass()" aria-busy="true">
<div :class="readyClass()">

Verified against bughq's own config/crosswind.ts — rendering <div :class="loadingClass()"></div> produces a sheet in which .grid, .gap-3, .flex-col and md\:flex-row are all absent. Those styles only ever appear when some other element on the same document happens to use the same utility literally.

RIGHT — literals inside the attribute. Same test, <div :class="loading() ? 'grid gap-3' : 'hidden'"></div>, generates .grid, .gap-3 and .hidden:

<div :class="autofixLoading() ? 'grid gap-3' : 'hidden'" aria-busy="true">
<div :class="isReady() ? 'flex flex-col gap-4 md:flex-row md:items-center md:justify-between' : 'hidden'">

If a helper is genuinely unavoidable, every class it can emit MUST be listed in safelist in config/crosswind.ts — the safelist is merged at dev-server/crosswind.js:301-304 and force-generated at :314-315.

Composing in <script server> is always safe, because extraction runs after interpolation. That is how the shipped library does it — node_modules/@stacksjs/components/src/ui/badge/Badge.stx:30 builds badgeClasses in a server block and emits class="", and it ships zero <style> tags.

CHECK. Rendered-output check using stx's own extractor:

bun -e '
const {extractClassNames}=await import("./node_modules/@stacksjs/stx/dist/dev-server/crosswind.js")
for await (const f of new Bun.Glob("resources/**/*.stx").scan(".")) {
  const s=await Bun.file(f).text()
  for (const m of s.matchAll(/:class="([^"]+)"/g))
    if (!/[\x27]/.test(m[1])) console.log(`${f}: :class="${m[1]}" — extracts no classes`)
}'

Currently reports every :class in AutofixPanel.stx.


11.5 MUST NOT use a style="…" attribute for anything a utility can express

WHY. Inline styles are unreachable by the extractor, unpurgeable, un-themable, and outrank every utility — which is precisely why dashboard.stx:337 needs the comment "the ACTIVE tab carries inline accent styles which outrank these rules." They are also a CSP liability: config-types.d.ts exposes a csp surface that a style-src 'unsafe-inline'-free policy would break.

WRONG — 102 occurrences across resources/, 14 of them re-deriving tokens by hand (resources/components/AutofixPanel.stx:318):

<p :class="repositoryFieldsClass()"
   style="color:var(--text-2); font-size:.875rem; margin:0 0 .75rem">Connect the repository…</p>

RIGHT:

<p :class="canRun() ? 'block' : 'hidden'" class="text-muted text-sm mt-0 mb-3">Connect the repository…</p>

The only legitimate style= is a value that is genuinely computed per-render and unbounded — a progress bar width, a chart bar height. Those MUST be bound with :style, never string-concatenated into a static attribute: AutofixPanel.stx:348 <span :style="autofixProgressStyle()"> is correct.

CHECK.

grep -rn --include='*.stx' 'style="' resources/ | grep -v ':style='   # currently 102

Manual review for the :style survivors: each must be a genuinely continuous numeric value.


11.6 MUST NOT let a component reference a token its host page happens to define

WHY. Components are placed by their consumer. A component whose CSS depends on variables declared in one particular page's <style> block renders wrong everywhere else, and the failure is silent — an undefined var() resolves to the property's initial value, not an error.

WRONG — resources/components/AutofixPanel.stx:250-295 references var(--panel-2) and var(--border-2) seven times:

<style>
.autofix-top   { ... border-bottom: 1px solid var(--border-2); }
.autofix-setup { padding: 1rem 1.1rem 1.15rem; background: var(--panel-2); }
.autofix-step  { border-top: 1px solid var(--border-2); }
</style>

--panel-2 and --border-2 are declared in exactly one file in the entire repo: resources/views/issue/[id].stx:369. That is also the only page that mounts the component (issue/[id].stx:564). Drop <AutofixPanel> on /dashboard — which defines --panel but not --panel-2 — and every panel surface goes transparent.

RIGHT. The tokens live in config/crosswind.ts preflights (rule 11.2) and the component uses utilities that are defined for every page:

<!-- resources/components/AutofixPanel.stx — no <style> -->
<section class="w-full min-w-0 overflow-hidden bg-panel border border-line rounded-[14px]"
         aria-labelledby="autofix-heading">
  <div class="flex items-center justify-between gap-4 px-[1.1rem] py-4 border-b border-line">

CHECK.

# every var(--x) referenced from resources/components must be declared in config/crosswind.ts
comm -23 \
  <(grep -rhoE -- '--[a-z0-9-]+' resources/components/*.stx | sort -u) \
  <(grep -ohE -- '--[a-z0-9-]+' config/crosswind.ts | sort -u)

11.7 SHOULD delete public/marketing.css; only preflights is legitimate global CSS

WHY. public/marketing.css is 273 lines / 16 KB defining 121 bespoke classes (.btn, .panel, .cell, .chip, .hero, .cmp-cell, …) linked by 26 pages (resources/views/index.stx:39). Every one of those is a utility composition, i.e. a shortcut. Worse, it is a render-blocking external stylesheet that the SPA router must special-case: because the app→marketing boundary arrives without it, config/ui.ts:38-42 documents data-no-router as the workaround.

Crosswind already emits the reset — includePreflight defaults true and the render path hardcodes toCSS(true, false) (dev-server/crosswind.js:318). Confirmed output includes *,::before,::after{box-sizing:border-box}, [hidden]{display:none}, img{max-width:100%;height:auto}.

What legitimately stays hand-written CSS, and where it goes:

CategoryExampleWhere
Design tokens:root { --bg: … }preflights (rule 11.2)
@font-face / web fontsSpace Grotesk, JetBrains Monofonts: { google: [...], display: 'swap' } — @cwcss/crosswind/dist/types.d.ts:20-25 emits the @import/@font-face for you
Named @keyframes beyond the built-ins@keyframes autofix-shimmerpreflights
Pseudo-elements no utility reaches::-webkit-scrollbar, summary::-webkit-details-marker, ::selectionpreflights (verified: a preflights entry emitting ::selection{…} reaches the sheet)
Third-party widget overridesnone in bughqpreflights
Anything else—utilities or a shortcut. No exceptions.

Everything in that table lands inside the single <style data-crosswind="generated"> tag, which is the only style tag the SPA router preserves and merges rather than destroys (stx-router/dist/client.js:517-529).

CHECK.

grep -rn --include='*.stx' 'marketing.css' resources/ | wc -l   # must be 0; currently 26
test ! -f public/marketing.css
ls public/*.css 2>/dev/null                                     # must print nothing

11.8 MUST make dark: work before using a component that ships it

WHY. Crosswind's darkMode defaults to 'class'. Verified: dark:bg-red-900 compiles to .dark .dark\:bg-red-900 { … } with the default and to @media (prefers-color-scheme: dark){ .dark\:bg-red-900 { … } } with darkMode: 'media' (@cwcss/crosswind/dist/types.d.ts:34). bughq's theme switch writes data-theme on <html> and never a dark class (resources/views/dashboard.stx:312-315). Every dark: utility in the shipped library — Badge.stx:9-16 alone has fourteen — is therefore dead in bughq today.

bughq's own markup does not need dark: at all: the semantic tokens flip under [data-theme] and prefers-color-scheme, so bg-panel is correct in both modes with one class. That is the intended pattern and MUST be preserved.

RIGHT. Keep darkMode: 'class' (so an explicit user choice wins over the OS) and have the theme writer set both attributes on <html>:

// resources/stores/theme.ts — one place, per rule 11.2's sibling in the state chapter
root.setAttribute('data-theme', mode)
root.classList.toggle('dark', mode === 'dark')

Do not switch to darkMode: 'media': it would make library components follow the OS and ignore the user's toggle, producing a light Badge on a dark page.

CHECK.

grep -rn --include='*.stx' 'dark:' resources/ | head            # bughq markup: should stay empty
grep -rn "classList.toggle('dark'\|classList.add('dark')" resources/   # must find the theme writer

Part B — TypeScript

11.9 MUST make tsconfig.json include every file you write

WHY. This is the rule that makes all the others enforceable, and it is currently the single largest gap in the repo.

tsconfig.json:31-38 includes only *.ts, *.d.ts, types/**/*.d.ts, app/Models/**, app/Services/**, app/Support/**. tsc --noEmit --listFilesOnly confirms bun run typecheck inspects nine project files:

site.config.ts, types/stacks-app.d.ts, types/ts-cloud-augment.d.ts,
app/Models/{AlertChannel,ErrorEvent,Issue,Project,Subscription,User}.ts, app/Support/urls.ts

Not app/Actions/**, not app/Errors/**, not app/Autofix/**, not config/**, not resources/**. The irony is on display in the repo itself: types/ts-cloud-augment.d.ts:5-7 says it exists so "config/cloud.ts fails tsc --noEmit even though the config is valid" — but config/ is not in include, so the augmentation guards nothing.

WRONG — tsconfig.json:31-38:

"include": [
  "*.ts", "*.d.ts", "types/**/*.d.ts",
  "app/Models/**/*.ts", "app/Services/**/*.ts", "app/Support/**/*.ts"
],

RIGHT:

"include": [
  "*.ts", "*.d.ts",
  "types/**/*.d.ts",
  "app/**/*.ts",
  "config/**/*.ts",
  "resources/**/*.ts"
],

The cost is bounded and measured — expanding include this way produces exactly 12 errors, all real:

FileError
app/Actions/Auth/SocialCallbackAction.ts:34,110Property 'html' does not exist on type 'ResponseFactory'
app/Actions/StripeWebhook.ts:2,67missing stripe types; event implicitly any
config/auth.ts:94, config/dns.ts:28string | number | true not assignable to string
config/commit.ts:2two non-existent imports from @stacksjs/utils
config/deps.ts:18-19, config/query-builder.ts:74invalid literal / missing snapshotDir
resources/functions/dark.ts:5Cannot find name 'usePreferredDark'

That last one is a live runtime bug: resources/functions/dark.ts:5 calls usePreferredDark(), which is exported from @stacksjs/stx/composables but is neither in STX_AUTO_IMPORTS (client-script.js:143-218) nor an ambient global in stx.d.ts. It is a ReferenceError waiting for a page to touch it, and the current include hides it.

CHECK. bun run typecheck must exit 0 and tsc --noEmit --listFilesOnly | grep -v node_modules | wc -l must be greater than the number of files you touched.


11.10 MUST put shared app types in types/, one declaration per concept

WHY. types/ exists, is already on the include path (tsconfig.json:34), and is empty of app types — types/stacks-app.d.ts is ten bytes: export {}. With nowhere to put them, types have been re-declared inline. app/Errors/alerts.ts:20-29 and app/Errors/channels.ts:18-27 declare AlertKind twice and the same six-field issue shape under two names, field for field:

// app/Errors/alerts.ts:20                      // app/Errors/channels.ts:18
export type AlertKind = 'new' | 'regression'    export type AlertKind = 'new' | 'regression'
export interface AlertIssue {                   export interface ChannelIssue {
  id: string                                      id: string
  title: string                                   title: string
  culprit?: string | null                         culprit?: string | null
  level?: string | null                           level?: string | null
  environment?: string | null                     environment?: string | null
  count?: number                                  count?: number
}                                               }

app/Errors/limits.ts:23 is worse — interface Window { count: number; resetAt: number } shadows the DOM Window inside that module, and lib includes DOM (tsconfig.json:11).

RIGHT — the layout an agent MUST follow:

PathHolds
types/globals.d.tsambient wiring only (rule 11.12) — triple-slash references, no app types
types/domain.tsentities the app reasons about: Issue, Project, AlertKind, IngestAuth
types/api.tsrequest/response shapes crossing /api/*
types/views.tsper-view server-block context shapes: DashboardContext, IssueContext
types/props.tscomponent prop interfaces (rule 11.11)

Module-private helper types stay in their module. Anything named in two files moves to types/.

// types/domain.ts
export type AlertKind = 'new' | 'regression'
export type ChannelType = 'slack' | 'discord'

export interface AlertIssue {
  id: string
  title: string
  culprit?: string | null
  level?: string | null
  environment?: string | null
  count?: number
}
// app/Errors/channels.ts
import type { AlertIssue, AlertKind, ChannelType } from '~/types/domain'

~/* already maps to ./* (tsconfig.json:26), and a plain value import through that alias is verified to resolve inside a <script server> block too.

CHECK.

# any interface/type name declared in two or more files under app/ or resources/
grep -rhoE '^(export )?(interface|type) [A-Z][A-Za-z0-9]*' app/ resources/ --include='*.ts' \
  | awk '{print $NF}' | sort | uniq -d

Currently prints AlertKind. Additionally, grep -rn 'interface \(Window\|Document\|Event\|Element\|Response\|Request\)\b' app/ resources/ must print nothing.


11.11 MUST type <script server> data and component props

WHY. Every <script> block in a .stx file is TypeScript unless it is tagged js or lang="js" (node_modules/@stacksjs/stx/dist/utils.js:28-33), and server blocks go through new Bun.Transpiler({ loader: 'ts' }) (variable-extractor.js:218). Verified end-to-end: a server block containing import type, interface, const rows: Row[] and satisfies renders correctly, with all annotations erased. There is no excuse for untyped server data.

WRONG — resources/views/dashboard.stx:45, 55-56. Under "strict": true these infer null, any[], null; every downstream .id, .name, .is_owner access is unchecked, and the template's has no contract at all:

<script server>
let user = null
let projects = []
let activeProject = null
</script>

RIGHT:

<script server>
import type { DashboardContext, ProjectRow, PendingInvite } from '~/types/views'
import type { AuthUser } from '~/types/domain'

useHead({ title: 'Issues - bughq' })

let user: AuthUser | null = null
let projects: ProjectRow[] = []
let activeProject: ProjectRow | null = null
let pendingInvites: PendingInvite[] = []

projects = (await db.unsafe<ProjectRow>(/* … */, [Number(user.id), userEmail])) ?? []
</script>

For components, prefer the form that already works and does not hand-import (rule 11.12) — resources/components/AutofixPanel.stx:1-8 is the correct model in the repo, with the interface hoisted to types/props.ts:

<script>
import type { AutofixPanelProps } from '~/types/props'
const { issueId, projectId } = defineProps<AutofixPanelProps>()
</script>

Client blocks are TypeScript too, so annotate them. AutofixPanel.stx:14, 27 currently ships const autofixState = state(null) (inferred StxSignal<null>) and function activeRun(run) (implicit any). Write const autofixState = state<AutofixState | null>(null) in a server block or a hoisted .ts module — see rule 11.12 for why the generic form is forbidden at a client call site — and function activeRun(run: AutofixRun | null): boolean.

CHECK. Manual review plus:

# server-block bindings initialised to a bare null/[] with no annotation
grep -rn --include='*.stx' -E '^\s*let [a-zA-Z]+ = (null|\[\])\s*$' resources/   # currently 3 in dashboard.stx

Once rule 11.9 lands, hoisting these shapes into types/*.ts makes them tsc-verified for real.


11.12 MUST NOT hand-import an auto-imported symbol, and MUST NOT call one with a generic type argument

WHY — the hand-import half. client-script.js:296-310 finds import { … } from 'stx' | '@stacksjs/browser', records the names, and replaces the whole statement with // [auto-import processed]. The names are then re-bound from window.stx / window.StacksBrowser (client-script.js:334-343). A hand-written import is at best a no-op and at worst a redirect: import an stx symbol from any other module specifier and it is bound from window.StacksBrowser, which does not have it, and the emitted guard (client-script.js:347-363) logs "…the client runtime does not provide it."

WRONG — 11 of 12 files in resources/components/ open with the same dead line (FeatureButton.stx:2, InputGroup.stx:2, LoginForm.stx:2, Window.stx:2, Taskbar.stx:2, StartMenu.stx:2, StartMenuItem.stx:2, ContextMenu.stx:2, ContextMenuItem.stx:2, PackageItem.stx:2, NotificationPopup.stx:2):

<script server>
import { defineProps, withDefaults } from 'stx'
</script>

RIGHT — AutofixPanel.stx:1-8 gets it right: no import at all. The 74 auto-imported symbols are listed at client-script.js:143-218; the browser-core set at :218-227. Ambient TypeScript declarations for them ship with the package (dist/stx.d.ts, pulled in by /// <reference path="./stx.d.ts" /> at dist/index.d.ts:1). Only import type is ever acceptable, and only for types — value imports of state, derived, computed, useHead, defineProps, navigate, useCookie, onMount, … are forbidden.

WHY — the generic half. Auto-import detection runs on the raw TypeScript before erasure (client-script.js:727 transformAutoImports, then :734 transpileTypeScript), and the detector's regex is \b<symbol>\s*\( (client-script.js:315). useSessionStorage<string>( puts a < where the ( must be, so the symbol is never detected and the destructuring line is never emitted.

Verified end-to-end by rendering a real .stx:

<script client>const tok = useSessionStorage('k','')</script>
  ->  var { useSessionStorage } = window.stx || window;
      const tok = useSessionStorage("k", "");

<script client>const tok = useSessionStorage<string>('k','')</script>
  ->  (no destructuring emitted)
      const tok = useSessionStorage("k", "");     // ReferenceError at runtime

The signals runtime assigns 40 of the 74 symbols directly to window (signals.js:4627ff), so those degrade silently rather than crashing. The other 34 hard-fail:

untrack, peek, isSignal, isDerived, setRouteParams, useOptimistic, useReactiveProp, useSessionStorage, useCookie, definePageMeta, useMeta, watchMultiple, onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted, defineProps, withDefaults, defineEmits, defineExpose, defineSlots, inject, nextTick, getCurrentInstance, onErrorCaptured, useSlots, useAttrs, createStore, action, createSelector, h, Fragment

useCookie is on that list, and the audit calls for replacing 17 hand-serialised document.cookie writes with it.

RIGHT — annotate the binding, never the call:

<script client>
const token: StxSignal<string> = useCookie('bughq_token')
const draft = useSessionStorage('issue-draft', '') as StxSignal<string>
</script>

defineProps<T>() in a server block is exempt — that path does not run transformAutoImports — which is why the 12 existing defineProps<T>() calls work. Do not generalise from them.

WIRING (do this once). Create types/globals.d.ts so both the stx ambients and the generated auto-imports are guaranteed in the program rather than arriving by accident through a transitive import:

/// <reference types="@stacksjs/stx" />
/// <reference path="../.stx/route-types.d.ts" />
/// <reference path="../storage/framework/auto-imports/globals.d.ts" />

Verified: with that file present, state(0), useDark() and haversineDistance all resolve under tsc --noEmit — and the reference chain drags resources/functions/* into the program, which is what surfaces the usePreferredDark bug from rule 11.9. Note storage/framework/** is in exclude (tsconfig.json:44); exclude does not block files reached by reference, so this works as written.

CHECK.

# 1. hand-imports — currently 11
grep -rnE --include='*.stx' "^\s*import \{[^}]*\} from '(stx|@stacksjs/browser)'" resources/

# 2. generic calls in client/signals blocks, classified by stx's own classifier
bun -e '
const {classifyAllScripts}=await import("./node_modules/@stacksjs/stx/dist/script-classifier.js")
const S=/\b(state|derived|effect|batch|untrack|peek|onMount|onDestroy|useRef|navigate|useRoute|useSearchParams|useFetch|useQuery|useMutation|useOptimistic|useReactiveProp|useLocalStorage|useSessionStorage|useCookie|useWebSocket|useHead|useSeoMeta|useEventListener|useDebounce|useThrottle|useInterval|useTimeout|useToggle|useCounter|useAsync|useColorMode|useDark|ref|reactive|computed|watch|watchEffect|defineProps|withDefaults|defineEmits|defineSlots|defineExpose|inject|nextTick|h)\s*<[^<>()]*>\s*\(/g
let bad=0
for await (const f of new Bun.Glob("resources/**/*.stx").scan(".")) {
  const {signals,client}=classifyAllScripts(await Bun.file(f).text())
  for (const s of [...signals,...client]) for (const m of s.content.matchAll(S)) { console.log(f,m[0].trim()); bad++ }
}
process.exit(bad?1:0)'

Check 2 passes today (all 12 generics sit in server blocks) — keep it in CI so the first useCookie<string>() fails the build instead of the page.

10 days ago

12. Verification and enforcement

"Done" is not a judgement you are permitted to make. It is the output of a fixed set of commands. This chapter defines them.

Throughout, dist/… means node_modules/@stacksjs/stx/dist/… (stx 0.2.113), and router/… means node_modules/stx-router/dist/…. Every number quoted was measured against the working tree, not estimated.


12.1 MUST — turn on stx strict in config/ui.ts before editing any .stx file

RULE. config/ui.ts must set strict: { enabled: true, failOnViolation: false, allowPatterns: [...] }. Do not write a client script until it does.

WHY. stx ships the "no vanilla JS" rule as executable code, not documentation. dist/script-validation.js:1-127 is a 25-entry table of prohibited browser APIs, each carrying the exact stx replacement (document.cookie → useCookie(), window.location → navigate(), setInterval → useInterval(), document.getElementById → useRef()). dist/process.js:704 calls validateClientScript(content, filePath, options.strict) on every non-server script block in every render. With strict unset, dist/script-validation.js:129 defaults it to { enabled: false } and the guard at dist/script-validation.js:148 (if (errors.length > 0 && strictConfig.enabled)) discards every finding. The option is typed at dist/types/config-types.d.ts:309 (strict?: boolean | StrictModeConfig, interface at :214-218) and is forwarded to the renderer by node_modules/bun-plugin-stx/dist/serve.js:9517 and :9698 — but only under "strict" in stxConfig, so an absent key is never forwarded.

WRONG — config/ui.ts:8-79. The whole default export; there is no strict key anywhere in the file:

export default {
  componentsDir: 'resources/components',
  layoutsDir: 'resources/views/layouts',
  partialsDir: 'resources/partials',
  router: { container: 'main', interceptAllLinks: true },
  app: { head: { title: 'bughq' } },
} satisfies UiOptions

Resolved at runtime, loadStxConfig(process.cwd()) returns strict: undefined. Consequence: 85 prohibited DOM calls across 11 .stx files render silently today.

RIGHT:

export default {
  // …existing keys…

  // stx's own DOM-API guard (dist/script-validation.js:128-157), wired into every
  // client script at dist/process.js:704. Warn-only until the list is clean, then
  // flip failOnViolation and regressions throw at render instead of shipping.
  strict: {
    enabled: true,
    failOnViolation: false,
    allowPatterns: [
      'location.replace()', // pre-paint auth guard — must beat first paint
    ],
  },
} satisfies UiOptions

allowPatterns is matched against the rule's message or its pattern.source (dist/script-validation.js:131), so 'location.replace()' suppresses exactly that one rule.

CHECK:

bun -e 'const c=(await import("./config/ui.ts")).default; if(c.strict==null){console.error("config/ui.ts: strict is unset");process.exit(1)}'

12.2 MUST — lint with bunx --bun pickier ., never eslint, and gate on --max-warnings 0

RULE. The only linter is pickier. Never invoke eslint. A change is not done until bunx --bun pickier . --max-warnings 0 exits 0.

WHY. AGENTS.md:16-17 states it as a project law ("Use pickier for linting, never eslint directly"), and .claude/skills/stacks-lint/SKILL.md repeats it as CRITICAL RULE 1. It is not stylistic: pickier lints .stx files, and its pickier/sort-tailwind-classes rule is the machine enforcement of the Crosswind-only requirement. Measured on this tree, bunx --bun pickier resources config app reports 470 problems, 100% of them pickier/sort-tailwind-classes in .stx files — all auto-fixable. eslint cannot see a .stx file at all, so running it produces a false green.

Default exit is 0 even with warnings; --max-warnings <n> (pickier 0.1.44) is what makes it a gate. Verified: pickier resources → exit 0; pickier resources --max-warnings 0 → exit 1.

WRONG: running eslint ., or running bunx --bun pickier ., reading "470 warnings", and calling it clean because the exit code was 0.

RIGHT:

bunx --bun pickier . --fix          # auto-sort Crosswind classes, fix what is fixable
bunx --bun pickier . --max-warnings 0   # then gate; must exit 0

CHECK:

bunx --bun pickier . --max-warnings 0
bunx --bun pickier . --reporter compact | awk '{print $3}' | sort | uniq -c | sort -rn   # rule breakdown

12.3 MUST — run lintStxStrict yourself; it is not wired into pickier

RULE. Run stx's footgun linter over resources/**/*.stx on every change. Pin the rule toggles in a committed script with a comment citing the runtime line that justifies each toggle.

WHY. dist/strict-lint.js ships a self-contained detector for the five documented "looks correct, does nothing" patterns (stacksjs/stx#1744, Phase 1). Its own header states the wiring is a follow-up — it is exported from the package root (import { lintStxStrict } from '@stacksjs/stx', verified) but no CLI, no pickier rule, and no build step calls it. If you do not call it, it never runs. Entry point dist/strict-lint.js:267-280; per-rule toggles via StxStrictOptions.rules (dist/strict-lint.d.ts).

Measured on this tree: 40 diagnostics in 12 files. Both firing rules are stale against 0.2.113, and this is why the "pin the toggles with a citation" half of the rule exists — an agent that mass-edits on Phase-1 output will damage 40 working call sites.

RuleFires in bughqVerdict vs 0.2.113Proof
stx/no-bare-function-ref-in-event29 (settings.stx ×12, AutofixPanel.stx ×4, …)Stale — false positive. The runtime added bare-ref support.dist/signals.js:1356-1362 bareIdMatch → return ($event) => fn($event), reached first at dist/signals.js:1878-1879 via parseEventShorthand. The source comment at dist/signals.js:1354-1355 describes the old no-op behaviour the fix replaced.
stx/no-view-level-script-client11 (every app view: login.stx:13, dashboard.stx:210, settings.stx:160, …)Stale — false positive. View-level <script client> compiles normally.Minimal repro: a view with <script client> renders <body data-stx="__stx_setup_…"> and the function body survives, both with and without a layout applied.
stx/store-value-imports-must-be-local0Untriggered — bughq has no resources/stores/dist/strict-lint.js:161-... only runs for paths containing resources/stores/
stx/no-signal-call-in-for-iteration-var0Advisorydist/strict-lint.js
stx/no-backticks-in-html-comments0Advisory — but see 12.5; the rule scans <!-- --> only (dist/strict-lint.js:248), not 22 backticks exist in bughq comments, all inside , all correctly ignored

WRONG: taking the 40 diagnostics at face value and rewriting @click="toggleTheme" → @click="toggleTheme()" in 29 places.

RIGHT: disable the two stale rules explicitly, with the citation inline, so the next agent does not re-litigate it:

const RULES = {
  // Both stale vs stx 0.2.113. Bare refs are handled by dist/signals.js:1356-1362
  // (parseEventShorthand bareIdMatch); view-level <script client> compiles to a
  // normal __stx_setup_ function. Re-enable and re-verify on an stx upgrade.
  'stx/no-bare-function-ref-in-event': false,
  'stx/no-view-level-script-client': false,
} as const

CHECK:

bun -e '
import { lintStxStrict } from "@stacksjs/stx"; import { Glob } from "bun"
const RULES = { "stx/no-view-level-script-client": false, "stx/no-bare-function-ref-in-event": false }
let n = 0
for await (const f of new Glob("resources/**/*.stx").scan(".")) 
  for (const d of lintStxStrict(await Bun.file(f).text(), { filePath: f, rules: RULES }))
    { console.log(`${f}:${d.line}:${d.column} ${d.ruleId} ${d.message}`); n++ }
process.exit(n ? 1 : 0)'

Re-run this with rules: {} after every @stacksjs/stx version bump and re-triage the two toggles against the shipped runtime.


12.4 MUST — never let the characters </script appear inside a <script> body, in any form

Silent-failure landmine 1.

RULE. A JS comment, string literal, or template literal inside a <script> block must never contain </script. Write "the client script block", not "the </script> tag".

WHY. stx scans client scripts with scanScriptTags (dist/signal-processing.js:8-24), whose body terminator is a naive first-match: dist/signal-processing.js:13 — html.toLowerCase().indexOf("</script>", bodyStart). It is not comment-aware. stx does ship a comment-aware, string-aware, regex-aware scanner — findScriptBodyEnd at dist/process.js:55-120 — but it is used only for <script server> blocks (dist/process.js:677). Client scripts go through the naive path at dist/process.js:695-706. The browser's own HTML parser behaves identically, so the failure is doubled.

Reproduced end-to-end through processDirectives:

input:  <script client>
          // now handled in the </script> block
          const n = state(1)
          function bump(){ n.set(n()+1) }
        </script>
        <main><b @click="bump()"></b></main>

output: <body>                                    ← data-stx="__stx_setup_…" GONE
        <script/><script/> block
        const n = state(1)
        function bump(){ n.set(n()+1) }
        </script>
        <main><b @click="bump()"></b></main>       ←  never interpolated

The script body is truncated at // now handled in the , the remaining JS is emitted as visible page text, <body> loses its data-stx setup binding (dist/process.js:234-236 only stamps it when a __stx_setup_ function survives), and the page is inert.

The one diagnostic stx emits is [stx] page.stx: <script> 0 opened, 1 closed — 1 stray </script>. from warnUnbalancedTags (dist/template-tag-balance.js:73-92), gated at dist/process.js:198 to options.debug || (!isProduction() && !isTest()) and deduped once per filePath:length by dist/template-tag-balance.js:82-84. It is a console.warn in dev-server stdout. In production it never prints.

WRONG (the shape that broke bughq — documented at .claude/skills/stacks-browse/SKILL.md:217-222): a comment reading "…now handled in the <script client> block" produced SyntaxError: Unexpected identifier from the router's doFragSwap on every navigation to that page, while the page still looked fine because the swap completed.

RIGHT:

// now handled in the client script block

CHECK — the truncation hides itself from a body scan (the body is already cut short), so count tags instead:

bun -e '
import { Glob } from "bun"; let n = 0
for await (const f of new Glob("resources/**/*.stx").scan(".")) {
  const s = await Bun.file(f).text()
  const open = (s.match(/<script\b/gi) ?? []).length, close = (s.match(/<\/script\s*>/gi) ?? []).length
  if (open !== close) { console.log(`${f}: ${open} <script> vs ${close} </script>`); n++ }
}
process.exit(n ? 1 : 0)'

Control test (mandatory the first time you wire this): plant // never write </script> here in a scratch .stx, confirm the check fires, then remove it. Verified — the control reports 1 <script> vs 2 </script>.


12.5 MUST — comment with ; never use <!-- --> in a .stx file

Silent-failure landmine 2.

RULE. Every comment in a .stx file uses . <!-- … --> is prohibited in resources/views/, resources/components/, resources/partials/, and resources/layouts/.

WHY. is compiled away; <!-- --> ships to the browser verbatim and is then parsed as content by four separate consumers. Verified:

source: {{-- SECRET_STX with <html> and </script> and ` --}}
        <!-- SECRET_HTML -->
        <main>x</main>

rendered output contains SECRET_STX  : false
rendered output contains SECRET_HTML : true

The specific kill is <html>. hasDocumentShell (dist/document-shell.js:64-66) is a raw text test — /<!DOCTYPE\b/i.test(html) || /<html[\s>]/i.test(html) — run over the whole rendered string at dist/process.js:223. A true result makes ensureDocumentShell return the html untouched (dist/document-shell.js:101-103). Measured:

Page source<!DOCTYPE> + <html> + <head> emitted?
<main>Hi</main>yes
<!-- the <html> tag is banned here --> + <main>Hi</main>no
<!-- <!DOCTYPE html> --> + <main>Hi</main>no
{{-- the <html> tag is banned here --}} + <main>Hi</main>yes

With no <head>, the layout-metadata injection at dist/process.js:227-232 is skipped (it is gated on result.includes("<head")), so the page ships without <meta name="stx-layout">. On a full-document swap the client router then fails isStxDocument (router/client.js:447-457, which looks for exactly name="stx-layout", __stxRouterConfig, or data-stx-content) and executes location.href=url at router/client.js:392 — a native full reload. .claude/skills/stacks-browse/SKILL.md:223-227 records the incident: "One comment mentioning <html> silently turned every app-to-app navigation into a full reload."

<script inside an HTML comment is the same family via a different consumer: the fragment swap re-executes scripts by regex-scanning the incoming HTML (router/client.js:405-408) and matches inside comments (SKILL.md:217-222). A backtick inside an HTML comment is the third (dist/strict-lint.js:247-262).

WRONG:

<!-- Confirm modal (archive / delete): type the project name to confirm -->

— resources/views/settings.stx:970. Harmless today, but it is one word away from fatal and it ships to every visitor.

RIGHT — the shape already used correctly at resources/views/layouts/default.stx:1-38, which safely contains <main>, <head>, <StxLink> and backticks precisely because it is {{-- --}}:

{{-- Confirm modal (archive / delete): type the project name to confirm --}}

CHECK:

grep -rn --include='*.stx' -e '<!--' resources/views resources/components resources/partials resources/layouts

Must return nothing. If a genuine HTML comment is required (a conditional comment, a build marker), the narrower gate is:

bun -e '
import { Glob } from "bun"; let n = 0
for await (const f of new Glob("resources/**/*.stx").scan(".")) {
  const s = await Bun.file(f).text()
  for (const m of s.matchAll(/<!--([\s\S]*?)-->/g))
    if (/<\/?html\b|<!DOCTYPE\b|<\/?script\b|`/i.test(m[1]))
      { console.log(`${f}:${s.slice(0,m.index).split("\n").length}  banned token in <!-- -->`); n++ }
}
process.exit(n ? 1 : 0)'

12.6 MUST — never write <!DOCTYPE in a page or component

Silent-failure landmine 3.

RULE. No .stx file under resources/views/, resources/components/, or resources/partials/ may contain <!DOCTYPE. The shell is produced by autoShell from app.head in config/ui.ts, or by a layout.

WHY. A DOCTYPE is stx's implicit signal to disable the entire layout system. dist/process.js:311-312:

const hasDoctype = /<!DOCTYPE\s/i.test(output), hasSections = /@section\s*\(/.test(output);
if (!hasDoctype || hasSections) {

Everything inside that branch — _layout.stx auto-discovery (dist/process.js:313-327), defaultLayout resolution (dist/process.js:328-331), and the automatic @section('content') wrap (dist/process.js:332-335) — is skipped. Nothing is logged. Measured against a real layoutsDir + defaultLayout:

PageLayout applied?
<main><h1>Hi</h1></main>yes
<!DOCTYPE html><html>…<main><h1>Hi</h1></main>…</html>no
<!DOCTYPE html><html><body>@section('content')<main>Hi</main>@endsection</body></html>yes — the `

That last row is the trap: the gate is an OR. A DOCTYPE page that gains a single @section( starts resolving a layout, and dist/process.js:332-335 then wraps its entire document, DOCTYPE included, into @section('content') and injects it into the layout's @yield('content') — producing a nested document.

The second-order cost is that useHead/useSeoMeta stop working: dist/process.js:206-222 builds headConfig from the runtime head, but dist/process.js:223-225 throws it away for an already-shelled page and calls injectConfigHeadTags(result, baseHeadConfig) with the base config only.

Currently: 24 files carry a DOCTYPE with no @nolayout, across resources/views/{index,compare,docs,use-cases}.stx, compare/*.stx (8), features/*.stx (6), use-cases/*.stx (5), and layouts/marketing.stx. grep -rn '@nolayout' resources/ returns zero hits.

WRONG — resources/views/features/alerts.stx:1, and 23 siblings:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  ...

RIGHT — the page owns content only; the layout owns the document:

@extends('marketing')

@section('title', 'Alert routing — bughq')
@section('content')
  <div class="…">…</div>
@endsection

If a file genuinely must own its document (an email template such as resources/emails/subscription-confirmation.stx, or an embed), say so explicitly with @nolayout — the supported opt-out at dist/process.js:302-304 and :310 — instead of relying on the DOCTYPE side effect.

CHECK:

bun -e '
import { Glob } from "bun"; let n = 0
for await (const f of new Glob("resources/{views,components,partials,layouts}/**/*.stx").scan(".")) {
  const s = await Bun.file(f).text()
  if (/<!DOCTYPE\s/i.test(s) && !/@nolayout\b/.test(s)) { console.log(`${f}: DOCTYPE with no @nolayout`); n++ }
}
process.exit(n ? 1 : 0)'

Baseline today: 24. Target: 0.


12.7 MUST — prove SPA navigation with the probe harness; never by looking at the page

RULE. Any change touching a view, a layout, <main>, an anchor, config/ui.ts:router, or a data-no-router attribute must be verified with .claude/skills/stacks-browse/scripts/. Loading the page in a browser is not verification.

WHY. The router swaps <main>'s contents and keeps the outer element and the rest of the document (router/client.js:441 — currentContent.innerHTML=cleanFrag). A page can therefore be perfect on a direct load and broken when arrived at. .claude/skills/stacks-browse/SKILL.md:126-133 lists three defects shipped in bughq that direct loading could not see: body-appended chrome surviving the swap; a class on <main> leaking forward and narrowing every page reached from / by 48px; a page arriving completely unstyled across a shell boundary.

Three tools, three different questions. Pick by question, not by habit:

ScriptQuestion it answersOracleExit code
spa-probe.ts routes <base> <path…>Does each route render? How many <main>s, nested <main>s, <main> class, layout group, fullDoc, console errorsINSPECT expression, spa-probe.ts:60-780
spa-probe.ts navs <base> <from>::<to>…Was this navigation an SPA swap or a full reload?window.__spaProbe stamped pre-click (spa-probe.ts:148), survival checked post-click (spa-probe.ts:172-183) — timing-independent0
spa-shot.ts fresh|viaDoes the page render the same when arrived at?The same URL loaded directly seconds earlier, same browser, same data, same clock — no golden files0
link-intercept.ts <base> <page> <href>Did the router intercept this link, or was it native?Network request type: Fetch/XHR ⇒ intercepted, Document only ⇒ native (link-intercept.ts:114)0 native / 1 intercepted

WRONG: verifying a data-no-router attribute with spa-probe.ts. It reports a false pass — for a link whose target is not an stx page (OAuth redirect, download, API route) the end state is identical whether the router ignored it or intercepted it, failed, and fell back to location.href (router/client.js:371-373): dead JS context, URL on the target, either way. Only link-intercept.ts can distinguish them (link-intercept.ts:5-16).

RIGHT — with the control run, which is not optional:

export SPA_COOKIE="bughq_token=$TOKEN" SPA_LOCALSTORAGE="{\"token\":\"$TOKEN\"}"
S=.claude/skills/stacks-browse/scripts

bun $S/spa-probe.ts routes http://localhost:3100 / /pricing /dashboard /settings /account
bun $S/spa-probe.ts navs   http://localhost:3100 /dashboard::/settings /::/pricing
bun $S/spa-shot.ts fresh /settings /tmp/a.png && bun $S/spa-shot.ts via /dashboard /settings /tmp/b.png
bun $S/link-intercept.ts http://localhost:3100 /login /api/auth/github/redirect   # expect NATIVE, exit 0
# CONTROL: remove data-no-router, re-run, expect INTERCEPTED / exit 1, then restore.

Both credentials are required. The cookie authenticates the server render; the client guard in each page redirects to /login unless the token is also in localStorage. With only the cookie, link-intercept.ts reports NO_LINK for every app page — which reads as a missing link, not a missing credential (SKILL.md:196-206).

CHECK. spa-probe.ts navs must report SPA for every app→app pair, and mainsAfter: 1, nestedMainsAfter: 0. link-intercept.ts exit code is the gate for data-no-router. Assertions that hold for this codebase:

AssertionWhere it comes from
Exactly one <main> per page; zero nestedresources/views/layouts/default.stx:6-9
Runtime-mounted DOM lives inside <main>resources/views/layouts/default.stx:11-15
App→marketing and /api/auth/*/redirect links are plain <a data-no-router>config/ui.ts:36-40, resources/views/layouts/default.stx:22-31
data-no-router on a <StxLink> is silently ignored — the router matches [data-stx-link] firstresources/views/layouts/default.stx:16-21, reported as noRouter+stxLink both true by link-intercept.ts:119-122

Two measurement traps, both already paid for (SKILL.md:209-231): grep -c '<main' lies (the injected router script mentions <main ~6× in comments — count DOM elements or strip <script> first), and a newly created partial is not picked up until it is modified while the dev server runs — clearing .stx/cache and restarting is not enough.


12.8 SHOULD — make the pre-commit hook see .stx

RULE. The staged-lint glob must include stx.

WHY. package.json:79-86 wires bun-git-hooks:

"git-hooks": {
  "pre-commit": { "staged-lint": { "*.{js,ts,json,yaml,yml,md}": "bun x --bun pickier lint --fix" } },
  "commit-msg": "bun x gitlint --edit .git/COMMIT_EDITMSG"
}

stx is absent from that glob (package.json:82), so none of the 52 .stx files in resources/ are ever linted on commit — despite pickier supporting them (config/code-style.ts:5 lists stx in format.extensions, and pickier 0.1.44 emits pickier/sort-tailwind-classes on .stx today).

RIGHT:

"*.{js,ts,stx,json,yaml,yml,md}": "bun x --bun pickier lint --fix"

CHECK:

bun -e 'const p=await Bun.file("package.json").json(); const g=Object.keys(p["git-hooks"]["pre-commit"]["staged-lint"])[0]; if(!g.includes("stx")){console.error("staged-lint glob excludes .stx: "+g);process.exit(1)}'

12.9 The gate script

RULE. Every .stx change runs this, in this order, and every step exits 0 before you report done.

#!/usr/bin/env bash
# stx pre-merge gate — run from the repo root.
set -uo pipefail
cd "$(git rev-parse --show-toplevel)"
fail=0

echo "== 1. pickier (never eslint) =="
bunx --bun pickier . --fix >/dev/null
bunx --bun pickier . --max-warnings 0 || fail=1

echo "== 2. types =="
bun x tsc --noEmit || fail=1

echo "== 3. config surface =="
bun -e '
const c = (await import("./config/ui.ts")).default
for (const [k, why] of [["strict","DOM guard, ch.12.1"],["plugins","@stacksjs/components/stx-plugin"],["storesDir","state management"]])
  if (c[k] == null) { console.error(`config/ui.ts: missing ${k} (${why})`); process.exitCode = 1 }
' || fail=1

echo "== 4. stx-strict + DOM guard + landmines =="
bun -e '
import { lintStxStrict } from "@stacksjs/stx"
import { PROHIBITED_DOM_PATTERNS } from "@stacksjs/stx/script-validation"
import { scanScriptTags } from "@stacksjs/stx/signal-processing"
import { Glob } from "bun"
// Stale vs 0.2.113: bare refs handled at dist/signals.js:1356-1362; view-level
// <script client> compiles to a normal __stx_setup_ fn. Re-triage on upgrade.
const RULES = { "stx/no-view-level-script-client": false, "stx/no-bare-function-ref-in-event": false }
let bad = 0; const say = (s) => { console.log(s); bad++ }
for await (const f of new Glob("resources/**/*.stx").scan(".")) {
  const src = await Bun.file(f).text()
  for (const d of lintStxStrict(src, { filePath: f, rules: RULES }))
    say(`${f}:${d.line}:${d.column}  ${d.ruleId}  ${d.message}`)
  for (const s of scanScriptTags(src, { skipAttrs: /\bserver\b|\bsrc\s*=/ }))
    for (const { pattern, message, suggestion } of PROHIBITED_DOM_PATTERNS) {
      pattern.lastIndex = 0
      const m = s.body.match(pattern)
      if (m) say(`${f}  dom-guard: ${message} x${m.length} -> ${suggestion}`)
    }
  const open = (src.match(/<script\b/gi) ?? []).length, close = (src.match(/<\/script\s*>/gi) ?? []).length
  if (open !== close) say(`${f}  LANDMINE 1: ${open} <script> vs ${close} </script> — "</script" inside a script body`)
  for (const m of src.matchAll(/<!--([\s\S]*?)-->/g))
    if (/<\/?html\b|<!DOCTYPE\b|<\/?script\b|`/i.test(m[1]))
      say(`${f}:${src.slice(0,m.index).split("\n").length}  LANDMINE 2: banned token in <!-- -->. Use {{-- --}}.`)
  if (/<!DOCTYPE\s/i.test(src) && !/@nolayout\b/.test(src) && !f.startsWith("resources/emails/"))
    say(`${f}  LANDMINE 3: <!DOCTYPE> with no @nolayout — layouts silently disabled`)
}
process.exit(bad ? 1 : 0)
' || fail=1

echo "== 5. grep gates =="
g() { local n=$1 want=$2; shift 2
  local got; got=$("$@" 2>/dev/null | wc -l | tr -d " ")
  [ "$got" -le "$want" ] && printf "  ok   %-38s %s\n" "$n" "$got" \
    || { printf "  FAIL %-38s %s>%s\n" "$n" "$got" "$want"; fail=1; } }
g "no vanilla <style> block"   0 grep -rn --include=*.stx -e '<style' resources/views resources/components resources/partials
g "no inline style= attribute" 0 grep -rEn --include=*.stx -e 'style="' resources
g "no plain internal <a href>" 0 grep -rEn --include=*.stx -e '<a [^>]*href="/' resources
g "no unmanaged timers"        0 grep -rEn --include=*.stx -e '(^|[^.\w])(setTimeout|setInterval)\s*\(' resources

echo "== 6. a11y (advisory — stx a11y always exits 0) =="
bun node_modules/@stacksjs/stx/dist/cli.js a11y resources | tail -1

echo "== 7. runtime resolution =="
bun node_modules/@stacksjs/stx/dist/cli.js doctor

exit $fail

Measured baseline on the current tree, so you can tell your regressions from the inherited debt:

GateNowTarget
pickier --max-warnings 0470 warnings (100% sort-tailwind-classes, all --fix-able)0
config/ui.ts missing keysstrict, plugins, storesDirnone
stx-strict (both stale rules off)00
DOM guard (PROHIBITED_DOM_PATTERNS)85 in 11 files0
Landmine 1 — unbalanced <script>00
Landmine 2 — banned token in <!-- -->00
Landmine 3 — DOCTYPE without @nolayout240
<style> blocks in views/components/partials250
Inline style="1020
Plain internal <a href="/…">276 (vs 29 <StxLink>)0
stx a11y resources23 issues in 11 files0
stx doctorall layers alignedaligned

Steps 6 and 7 are advisory: stx a11y resources exits 0 regardless of findings (verified), so read its Total: line — it is not a gate. stx doctor (dist/doctor.d.ts, CLI at dist/cli.js) never mutates anything; it verifies that @stacksjs/stx, bun-plugin-stx, and the generated signals runtime resolve to the same install — run it first when render behaviour contradicts the source you are reading.


12.10 The pre-merge checklist

Tick every line, in order. A line you cannot tick is a line you must report as not done.

  1. bunx --bun pickier . --max-warnings 0 exits 0. You did not run eslint.
  2. bun x tsc --noEmit exits 0.
  3. config/ui.ts declares strict, plugins, storesDir, componentsDir, layoutsDir, partialsDir.
  4. The gate script in 12.9 exits 0, or every remaining violation is pre-existing and listed by file:line in your report.
  5. No file you touched contains <!DOCTYPE (unless it carries @nolayout and you can say why).
  6. No file you touched contains <!-- -->.
  7. <script> and </script> counts balance in every file you touched.
  8. Dev servers are up (bun run dev → :3100 web, :3108 API; scripts/dev.ts:20-23). A ":3100 Unable to connect" 500 means :3108 is down, not a code bug.
  9. spa-probe.ts routes for every route you touched: status 200, at equals the path you asked for (an unauthenticated run happily verifies /login N times and reports all green), mains: 1, nestedMains: 0, consoleErrors: [].
  10. spa-probe.ts navs for every navigation into or out of a page you touched: SPA, not FULL_RELOAD.
  11. spa-shot.ts fresh + spa-shot.ts via captured for any page whose layout, <main>, or runtime-mounted DOM changed. Read both PNGs and compare them. Do not assert they match without looking.
  12. link-intercept.ts run for every data-no-router you added or removed, plus the control run with the attribute inverted. A probe that reports "correct" in both directions is measuring nothing.
  13. If you verified against dist/: re-verify against the running server. Production is SSR, so dist/ defects may never ship and dist/ passes may not reflect production (SKILL.md:215-216).
  14. If you created a new partial: modify it once while the dev server is running before trusting any render of it (SKILL.md:228-231).
  15. Report the numbers, not adjectives. "pickier 0 / tsc 0 / gate 0 / 6 navs SPA / 2 shots compared" — never "verified" or "looks good".
10 days ago

Correction: chapter 11 buried the most important rule in it

The twelve rules above previously read "Types live in types/" as rule 10 — which says where types go, not that code must be typed. An agent reading only the headline rules would never learn that annotations are required. Chapter 11 did say "There is no excuse for untyped server data", but at line 442 of a 563-line chapter.

Rule 10 is now the mandate itself, with the organisational point demoted to 10b, and chapter 11 leads with the same statement instead of arriving at it.

The distinction that makes this matter, and that the original draft never stated plainly:

A .stx script block is compiled as TypeScript whether or not you type it. There is no "JavaScript phase" — only typed TypeScript and untyped TypeScript. Untyped TypeScript is strictly worse than JavaScript, because it pays for a compiler and delivers none of the guarantee.

This was caught by the owner of the audited codebase reading the chapter and asking why the app was still untyped after five refactor stages. Fair question — the guide never told anyone to type it.

10 days ago

Landed in 251661092e — the twelve chapters are now docs/guide/agent-guide.md, versioned with the framework they describe rather than living in issue comments.

I added a status note at the top, and I think it's the honest thing to do rather than a caveat for its own sake: the guide was written against 0.2.113, and several of the framework defects it names as root causes have since been fixed — including the doctype heuristic it opens with.

The table lists them: doctype-disables-layouts, unanchored shell detection, the title XSS, </script> truncation, definePageMeta, site.config titles, the router-default disagreement, unreachable composables. A reader hitting "because the framework does X" needs to know X may no longer be true.

The rules themselves stand — they're about writing stx well, not about working around bugs.

Worth saying plainly: this audit is why most of those got fixed. The doctype line you identified as the single root cause was exactly right, and tracing it produced #1792, which in turn surfaced two security issues that weren't in any report. The guide's value was never only the rules.

Sign in to comment on this issue.