You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
references/styling.md documents exactly one home for a repeated html-fragment helper, lib/utils/ui.ts. That is the right home for an APP-WIDE chunk (the gallery's pageHeading / lede / backLink, the blog's rubric / stat / displayH1), but it leaves a FEATURE-SCOPED markup helper with no documented location.
Found while dogfooding a tic-tac-toe app on a freshly cleared scaffold. A read-only board fragment (nine cells to markup, no state, no events) is drawn by both the matches list and the match detail page, so it is genuinely feature-local rather than app-wide. The narrowest-owner rule in SKILL.md (step 3 of the Default Workflow) pushes it into modules/matches/, but the only sub-folder the layout offers is utils/ (pure), so it lands as modules/matches/utils/board-view.ts, directly beside modules/matches/utils/stats.ts, which transforms data rather than returning a TemplateResult.
The result is a folder mixing two kinds of helper with nothing in the path to tell them apart, and an agent following the docs literally cannot do better. The naming half of the convention (ui) is documented only at the lib/ tier, so it is silently dropped on the way to the feature-local tier.
The second-order cost is worse than the tidiness one. With no documented feature-local home for view markup, the reflex is to reach for modules/<feature>/components/ instead, which means authoring a custom element for display-only markup. That is a real regression rather than a style question, because a display-only component rendered by a component that ships (a live island such as <match-replay>) can no longer be elided. The "just make it a component" answer therefore converts free SSR markup into shipped JavaScript.
Design / approach
Document a second tier, mirroring the existing one rather than replacing it.
Scope
Home
app-wide markup chunk
lib/utils/ui.ts (unchanged)
feature-scoped markup chunk
modules/<feature>/utils/ui/<name>.ts
A directory (utils/ui/) rather than a single utils/ui.ts, because a feature accumulates several view fragments (a board, a card, a row) and one per file keeps them greppable. It also matches the existing repo rule that a module splits into a sibling directory named after it (references/module-structure.md).
Two alternatives considered and rejected.
A modules/<feature>/views/ folder. It invents a taxonomy the skill does not otherwise use, so the next agent will not think to look there. utils/ already means "pure, browser-safe", which a fragment helper is.
Putting fragments in modules/<feature>/components/. That folder means custom elements. A non-element file in it is precisely the confusion that leads to hand-rolling a display-only component (see the elision cost above).
The rule to state, in one line. components/ is for custom elements, utils/ui/ is for functions returning a TemplateResult, and the rest of utils/ is for functions returning data.
Implementation notes (for the implementing agent)
Where to edit. Paths verified in this repo at time of filing.
.agents/skills/webjs/references/styling.md, the "DRY via a JS helper, not @apply" section, roughly L37 to L69. It carries the lib/utils/ui.ts example and the repeats table ("Once / 2 to 3 times / Varies by 1 to 2 props / Radically different"). Add the scope tier here. That table is the natural place for a feature-scoped versus app-wide row.
.agents/skills/webjs/SKILL.md L124 to L125, the Project Layout block, currently reading modules/<feature>/ actions/ ..., components/, utils/ (pure), types.ts. This is the line an agent scans when deciding where a file goes, so it is the highest-leverage edit in the change.
AGENTS.md L414 (repo root), the Tailwind paragraph naming lib/utils/ui.ts as the extraction target.
website/app/docs/styling/page.ts L186 to L207, the docs-site version of the same guidance, including a <code-block> showing // lib/utils/ui.ts.
website/app/docs/conventions/page.ts L17, a one-line summary naming lib/utils/ui.ts.
examples/blog/CONVENTIONS.md L504 to L521, the same helper story told for the blog example.
packages/cli/templates/CONVENTIONS.md L11, the scaffold's own conventions file, which describes modules/<feature>/ generally and may want the same one-liner.
Landmines.
The skill is canonical exactly once, at repo-root .agents/skills/webjs/. packages/cli/lib/create.js (around L709 to L717) prefers a bundle at packages/cli/templates/.agents/skills/webjs (written at prepack by scripts/sync-scaffold-skill.mjs, gitignored in the monorepo) and falls back to the repo-root copy. Edit the canonical copy only. Do NOT hand-edit a bundled or scaffolded copy, it will be overwritten.
Scaffolded apps already on disk keep their stale skill copy. The change reaches an existing app only through a fresh webjs create, so this wants a changelog line rather than an assumption that live apps pick it up.
bunifyProse rewrites skill markdown on copy for Bun scaffolds (in create.js, the block right after the skill copy). Author any command examples in the canonical npm form. references/runtime.md is deliberately excluded from that rewrite.
Do not move the existing ui.ts files.gallery/lib/utils/ui.ts (pageHeading, lede, backLink) and examples/blog/lib/utils/ui.ts (rubric, stat, backLink, displayH1, clampH1, sectionH2, banner, accentLink, codeChip) are app-wide chunks consumed from many routes and belong exactly where they are. This change is additive.
The gallery has no feature-local markup helper today. Every file under gallery/modules/*/utils/ and examples/blog/modules/*/utils/ is pure data or logic (tasks.ts, slugify.ts, validate.ts, format.ts, bus.ts, clients.ts, clock.server.ts, format.server.ts), so there is nothing to relocate. Demonstrating the new tier in the gallery therefore means ADDING an example, which is optional and should be weighed against gallery bloat. The docs change stands on its own without it.
Invariants to respect.
app/ stays routing-only, and feature logic lives in modules/<feature>/ (AGENTS.md, workspace rules).
components/ continues to mean custom elements, at both the app and the feature tier.
The islands sizing rule stands. A fragment helper is preferred over a display-only component precisely because a component rendered by a shipping island cannot be elided (references/components.md).
Tests and docs surfaces.
No runtime code changes, so no unit or browser layer unless the gallery example is added.
Website, run the docs tag-balance guard after editing the two website/app/docs/* pages.
Consider a changelog entry, since the guidance an agent follows is an agent-facing surface.
webjs-doc-sync holds the authoritative surface map. Run it against this change before opening the PR to confirm nothing above was missed.
Acceptance criteria
references/styling.md documents both tiers, app-wide lib/utils/ui.ts and feature-scoped modules/<feature>/utils/ui/<name>.ts
SKILL.md's Project Layout block lists utils/ui/ under modules/<feature>/
The one-line disambiguation is stated, components/ for custom elements, utils/ui/ for a TemplateResult, utils/ for data
The rationale for preferring a fragment over a display-only component (elision inside a shipping island) is stated where the choice is made
Root AGENTS.md, website/app/docs/styling/page.ts, website/app/docs/conventions/page.ts, and examples/blog/CONVENTIONS.md agree with the skill, with no surface left contradicting another
Existing lib/utils/ui.ts files in gallery/ and examples/blog/ are unchanged
Website docs tests pass, including the tag-balance guard
A decision is recorded in the PR on whether the gallery gains a demonstrative feature-local fragment, with the reason either way
Problem
references/styling.mddocuments exactly one home for a repeatedhtml-fragment helper,lib/utils/ui.ts. That is the right home for an APP-WIDE chunk (the gallery'spageHeading/lede/backLink, the blog'srubric/stat/displayH1), but it leaves a FEATURE-SCOPED markup helper with no documented location.Found while dogfooding a tic-tac-toe app on a freshly cleared scaffold. A read-only board fragment (nine cells to markup, no state, no events) is drawn by both the matches list and the match detail page, so it is genuinely feature-local rather than app-wide. The narrowest-owner rule in
SKILL.md(step 3 of the Default Workflow) pushes it intomodules/matches/, but the only sub-folder the layout offers isutils/ (pure), so it lands asmodules/matches/utils/board-view.ts, directly besidemodules/matches/utils/stats.ts, which transforms data rather than returning aTemplateResult.The result is a folder mixing two kinds of helper with nothing in the path to tell them apart, and an agent following the docs literally cannot do better. The naming half of the convention (
ui) is documented only at thelib/tier, so it is silently dropped on the way to the feature-local tier.The second-order cost is worse than the tidiness one. With no documented feature-local home for view markup, the reflex is to reach for
modules/<feature>/components/instead, which means authoring a custom element for display-only markup. That is a real regression rather than a style question, because a display-only component rendered by a component that ships (a live island such as<match-replay>) can no longer be elided. The "just make it a component" answer therefore converts free SSR markup into shipped JavaScript.Design / approach
Document a second tier, mirroring the existing one rather than replacing it.
lib/utils/ui.ts(unchanged)modules/<feature>/utils/ui/<name>.tsA directory (
utils/ui/) rather than a singleutils/ui.ts, because a feature accumulates several view fragments (a board, a card, a row) and one per file keeps them greppable. It also matches the existing repo rule that a module splits into a sibling directory named after it (references/module-structure.md).Two alternatives considered and rejected.
modules/<feature>/views/folder. It invents a taxonomy the skill does not otherwise use, so the next agent will not think to look there.utils/already means "pure, browser-safe", which a fragment helper is.modules/<feature>/components/. That folder means custom elements. A non-element file in it is precisely the confusion that leads to hand-rolling a display-only component (see the elision cost above).The rule to state, in one line.
components/is for custom elements,utils/ui/is for functions returning aTemplateResult, and the rest ofutils/is for functions returning data.Implementation notes (for the implementing agent)
Where to edit. Paths verified in this repo at time of filing.
.agents/skills/webjs/references/styling.md, the "DRY via a JS helper, not@apply" section, roughly L37 to L69. It carries thelib/utils/ui.tsexample and the repeats table ("Once / 2 to 3 times / Varies by 1 to 2 props / Radically different"). Add the scope tier here. That table is the natural place for a feature-scoped versus app-wide row..agents/skills/webjs/SKILL.mdL124 to L125, the Project Layout block, currently readingmodules/<feature>/ actions/ ..., components/, utils/ (pure), types.ts. This is the line an agent scans when deciding where a file goes, so it is the highest-leverage edit in the change.AGENTS.mdL414 (repo root), the Tailwind paragraph naminglib/utils/ui.tsas the extraction target.website/app/docs/styling/page.tsL186 to L207, the docs-site version of the same guidance, including a<code-block>showing// lib/utils/ui.ts.website/app/docs/conventions/page.tsL17, a one-line summary naminglib/utils/ui.ts.examples/blog/CONVENTIONS.mdL504 to L521, the same helper story told for the blog example.packages/cli/templates/CONVENTIONS.mdL11, the scaffold's own conventions file, which describesmodules/<feature>/generally and may want the same one-liner.Landmines.
.agents/skills/webjs/.packages/cli/lib/create.js(around L709 to L717) prefers a bundle atpackages/cli/templates/.agents/skills/webjs(written at prepack byscripts/sync-scaffold-skill.mjs, gitignored in the monorepo) and falls back to the repo-root copy. Edit the canonical copy only. Do NOT hand-edit a bundled or scaffolded copy, it will be overwritten.webjs create, so this wants a changelog line rather than an assumption that live apps pick it up.bunifyProserewrites skill markdown on copy for Bun scaffolds (increate.js, the block right after the skill copy). Author any command examples in the canonical npm form.references/runtime.mdis deliberately excluded from that rewrite..tstemplates, so the no-backtick-inside-htmlrule applies, and the tag-balance guard test coverswebsite/app/docs(see test(website): tag-balance guard only covers app/docs, not the rest of the site #1263 for its scope). Run the website test layer after editing those two pages.ui.tsfiles.gallery/lib/utils/ui.ts(pageHeading,lede,backLink) andexamples/blog/lib/utils/ui.ts(rubric,stat,backLink,displayH1,clampH1,sectionH2,banner,accentLink,codeChip) are app-wide chunks consumed from many routes and belong exactly where they are. This change is additive.gallery/modules/*/utils/andexamples/blog/modules/*/utils/is pure data or logic (tasks.ts,slugify.ts,validate.ts,format.ts,bus.ts,clients.ts,clock.server.ts,format.server.ts), so there is nothing to relocate. Demonstrating the new tier in the gallery therefore means ADDING an example, which is optional and should be weighed against gallery bloat. The docs change stands on its own without it.Invariants to respect.
app/stays routing-only, and feature logic lives inmodules/<feature>/(AGENTS.md, workspace rules).components/continues to mean custom elements, at both the app and the feature tier.references/components.md).Tests and docs surfaces.
website/app/docs/*pages.webjs-doc-syncholds the authoritative surface map. Run it against this change before opening the PR to confirm nothing above was missed.Acceptance criteria
references/styling.mddocuments both tiers, app-widelib/utils/ui.tsand feature-scopedmodules/<feature>/utils/ui/<name>.tsSKILL.md's Project Layout block listsutils/ui/undermodules/<feature>/components/for custom elements,utils/ui/for aTemplateResult,utils/for dataAGENTS.md,website/app/docs/styling/page.ts,website/app/docs/conventions/page.ts, andexamples/blog/CONVENTIONS.mdagree with the skill, with no surface left contradicting anotherlib/utils/ui.tsfiles ingallery/andexamples/blog/are unchanged