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
AI agents (and humans) occasionally write a raw class X extends HTMLElement custom element in webjs code instead of the framework's WebComponent base class. The blog's components/muted-text.ts is the live example (the only raw HTMLElement in the whole app). A raw HTMLElement custom element is a real anti-pattern in webjs:
It is invisible to the elision analyser, so it ships unconditionally and, as a client-effecting NON-component, it keeps any importing page/layout from being import-only (Elide page/layout modules that ship only to import interactive components #605). muted-text is exactly what stops the blog home page from dropping its page module.
It bypasses the SSR / lifecycle / reactive-prop machinery and the declare-free factory DX.
It usually does its DOM work in connectedCallback (client-only), so a JS-off visitor misses it: a progressive-enhancement bug. muted-text applies its Tailwind classes client-side only.
The blog's own examples/blog/AGENTS.md "Add a component" recipe already says to extend WebComponent, so this is a guardrail to make that rule enforced, not just documented.
Design / approach
A .claude PreToolUse hook (Claude) plus the multi-agent prose rule the scaffold already ships for every other agent. The hook blocks a NEW raw-HTMLElement custom element and points at WebComponent, with an explicit escape hatch (webjs-allow-htmlelement: <reason> marker) for the rare native-API case WebComponent cannot express (form-associated ElementInternals, a customized built-in via extends HTMLButtonElement, etc.).
A reference implementation already exists and is verified at ~/.claude/hooks/webjs-prefer-webcomponent.sh (the global copy): it matches class \w+ extends HTMLElement, exempts packages/ + node_modules/, gates on a package.json up-tree depending on @webjsdev/* (so non-webjs projects are untouched), honours the marker, and exits 2 to block. Lift that into the repo + scaffold.
NOT a webjs check correctness rule in this issue. That is a heavier, CI-wide, all-files mechanism worth considering as a follow-up; the ask here is the .claude guardrail + scaffold parity.
Implementation notes (for the implementing agent)
Where to edit:
.claude/hooks/block-raw-htmlelement.sh (new), wired in .claude/settings.json under PreToolUse on a Write|Edit|MultiEdit matcher. Mirror the existing .claude/hooks/block-prose-punctuation.sh (same exit-2-to-block contract) and route-skills.sh.
Scaffold: packages/cli/templates/.claude/hooks/block-raw-htmlelement.sh + wire in packages/cli/templates/.claude/settings.json (the scaffold ships .claude/hooks/ + settings.json, see packages/cli/templates/.claude/).
Multi-agent parity (root AGENTS.md "guardrails for all agents"): add the same rule in prose to the scaffold's .cursorrules, .agents/rules/workflow.md, .github/copilot-instructions.md, CONVENTIONS.md, and the scaffolded AGENTS.md template, since the scaffold ships the same rule in each agent's format.
Fix the motivating offender: examples/blog/components/muted-text.ts. Convert to a display-only WebComponent that sets the host classes in the constructor (runs at SSR, so the classes land in the served HTML and it stays progressive-enhancement-safe), no render() so children are preserved:
Do NOT fire on the framework's own base (packages/core/src/component.{js,d.ts} defines class ... extends HTMLElement) or on typeof HTMLElement / instanceof HTMLElement guards. The regex must target a class HEAD (class \w+ extends HTMLElement), and the path exemption must cover packages/ + node_modules/.
The hook must be a no-op outside a webjs project (the up-tree package.json@webjsdev gate), or it breaks every vanilla-JS project the user touches.
Legitimate exceptions exist (form-associated elements via ElementInternals, customized built-ins). The escape-hatch marker is the sanctioned way through; do not make the block unconditional.
The hook receives the new text as .tool_input.content (Write) or .tool_input.new_string (Edit); read both.
Invariants: root AGENTS.md "guardrails for ALL agents" (the rule must be expressed in every agent's config the scaffold ships, not just Claude's); .claude/hooks/ scripts are committed and tested (the test/hooks/ pattern); the scaffold ships the per-agent config (packages/cli/templates/).
Tests + docs: test/hooks/block-raw-htmlelement.test.mjs (mirror test/hooks/route-skills.test.mjs); the scaffold-template test surface (test/scaffolds/) if it asserts the shipped hook set; update examples/blog/AGENTS.md (note the guardrail) and CONVENTIONS.md.
Acceptance criteria
Writing a raw class X extends HTMLElement custom element in a webjs file is blocked (exit 2) with a message pointing at WebComponent
A WebComponent subclass, a file with the webjs-allow-htmlelement marker, a non-webjs project, and a packages/ / node_modules/ path are all allowed (no false block)
typeof HTMLElement / instanceof HTMLElement guards do NOT trip the hook
The hook + rule are propagated to the scaffold (packages/cli/templates/) and to every per-agent config the scaffold ships
examples/blog/components/muted-text.ts is a display-only WebComponent, the blog still renders the muted styling with JS off, and the home page boots one fewer module
test/hooks/block-raw-htmlelement.test.mjs covers block + each allow case, with a counterfactual
CONVENTIONS.md + examples/blog/AGENTS.md note the guardrail
Problem
AI agents (and humans) occasionally write a raw
class X extends HTMLElementcustom element in webjs code instead of the framework'sWebComponentbase class. The blog'scomponents/muted-text.tsis the live example (the only raw HTMLElement in the whole app). A raw HTMLElement custom element is a real anti-pattern in webjs:muted-textis exactly what stops the blog home page from dropping its page module.connectedCallback(client-only), so a JS-off visitor misses it: a progressive-enhancement bug.muted-textapplies its Tailwind classes client-side only.The blog's own
examples/blog/AGENTS.md"Add a component" recipe already says to extendWebComponent, so this is a guardrail to make that rule enforced, not just documented.Design / approach
A
.claudePreToolUse hook (Claude) plus the multi-agent prose rule the scaffold already ships for every other agent. The hook blocks a NEW raw-HTMLElement custom element and points atWebComponent, with an explicit escape hatch (webjs-allow-htmlelement: <reason>marker) for the rare native-API caseWebComponentcannot express (form-associatedElementInternals, a customized built-in viaextends HTMLButtonElement, etc.).A reference implementation already exists and is verified at
~/.claude/hooks/webjs-prefer-webcomponent.sh(the global copy): it matchesclass \w+ extends HTMLElement, exemptspackages/+node_modules/, gates on apackage.jsonup-tree depending on@webjsdev/*(so non-webjs projects are untouched), honours the marker, and exits 2 to block. Lift that into the repo + scaffold.NOT a
webjs checkcorrectness rule in this issue. That is a heavier, CI-wide, all-files mechanism worth considering as a follow-up; the ask here is the.claudeguardrail + scaffold parity.Implementation notes (for the implementing agent)
.claude/hooks/block-raw-htmlelement.sh(new), wired in.claude/settings.jsonunderPreToolUseon aWrite|Edit|MultiEditmatcher. Mirror the existing.claude/hooks/block-prose-punctuation.sh(same exit-2-to-block contract) androute-skills.sh.packages/cli/templates/.claude/hooks/block-raw-htmlelement.sh+ wire inpackages/cli/templates/.claude/settings.json(the scaffold ships.claude/hooks/+settings.json, seepackages/cli/templates/.claude/)..cursorrules,.agents/rules/workflow.md,.github/copilot-instructions.md,CONVENTIONS.md, and the scaffoldedAGENTS.mdtemplate, since the scaffold ships the same rule in each agent's format.examples/blog/components/muted-text.ts. Convert to a display-onlyWebComponentthat sets the host classes in the constructor (runs at SSR, so the classes land in the served HTML and it stays progressive-enhancement-safe), norender()so children are preserved:extends WebComponent({...})defeats display-only elision #604): it ships zero JS, the classes are SSR'd, and the blog home page can become import-only (Elide page/layout modules that ship only to import interactive components #605).packages/core/src/component.{js,d.ts}definesclass ... extends HTMLElement) or ontypeof HTMLElement/instanceof HTMLElementguards. The regex must target a class HEAD (class \w+ extends HTMLElement), and the path exemption must coverpackages/+node_modules/.package.json@webjsdev gate), or it breaks every vanilla-JS project the user touches.ElementInternals, customized built-ins). The escape-hatch marker is the sanctioned way through; do not make the block unconditional..tool_input.content(Write) or.tool_input.new_string(Edit); read both..claude/hooks/scripts are committed and tested (thetest/hooks/pattern); the scaffold ships the per-agent config (packages/cli/templates/).test/hooks/block-raw-htmlelement.test.mjs(mirrortest/hooks/route-skills.test.mjs); the scaffold-template test surface (test/scaffolds/) if it asserts the shipped hook set; updateexamples/blog/AGENTS.md(note the guardrail) andCONVENTIONS.md.Acceptance criteria
class X extends HTMLElementcustom element in a webjs file is blocked (exit 2) with a message pointing atWebComponentWebComponentsubclass, a file with thewebjs-allow-htmlelementmarker, a non-webjs project, and apackages//node_modules/path are all allowed (no false block)typeof HTMLElement/instanceof HTMLElementguards do NOT trip the hookpackages/cli/templates/) and to every per-agent config the scaffold shipsexamples/blog/components/muted-text.tsis a display-onlyWebComponent, the blog still renders the muted styling with JS off, and the home page boots one fewer moduletest/hooks/block-raw-htmlelement.test.mjscovers block + each allow case, with a counterfactualCONVENTIONS.md+examples/blog/AGENTS.mdnote the guardrail