Skip to content

dogfood: guardrail to block raw extends HTMLElement in webjs components #615

@vivek7405

Description

@vivek7405

Problem

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:
      import { WebComponent } from '@webjsdev/core';
      class MutedText extends WebComponent {
        constructor() {
          super();
          this.className = 'text-fg-subtle font-mono text-[11px] font-medium leading-snug tracking-[0.12em] uppercase';
        }
      }
      MutedText.register('muted-text');
      This has no interactivity signal, so it is elidable (dogfood: factory-form 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).
  • Landmines:
    • 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

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions