From 9e8ae662871318ffd7ccc4420a18622975d5af76 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 16 Aug 2026 14:18:32 +0530 Subject: [PATCH 01/10] docs: give feature-local view fragments a documented home styling.md named only lib/utils/ui.ts, so a fragment used by one feature had no documented location and landed in modules//utils/ beside the pure data helpers, with nothing in the path to say it returns markup. Worse, with no feature-local home the reflex is a display-only custom element, which stops being elided as soon as a shipping island renders it, turning free SSR markup into shipped JavaScript. Documents the second tier, modules//utils/ui/.ts, across the skill, the root AGENTS.md, the docs site, and both conventions files. --- .agents/skills/webjs/SKILL.md | 7 +++++-- .agents/skills/webjs/references/styling.md | 22 ++++++++++++++++++++-- AGENTS.md | 2 +- examples/blog/CONVENTIONS.md | 5 ++++- packages/cli/templates/CONVENTIONS.md | 6 ++++-- website/app/docs/conventions/page.ts | 2 +- website/app/docs/styling/page.ts | 6 +++++- 7 files changed, 40 insertions(+), 10 deletions(-) diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index d2d9b071d..ad91b32f5 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -45,6 +45,7 @@ Rows point rather than explain. The reference is the authority on the rule, and | abandon a render because something is missing or not allowed | throw `notFound()` / `forbidden()` / `unauthorized()` | returning an error object and branching in the template | `references/routing-and-pages.md` | `app/features/boundaries` | | set a page's title, description, or social preview | `export const metadata` or `generateMetadata()` | writing `` tags in the page | `references/routing-and-pages.md` | `app/features/metadata` | | make part of the page respond to a click or hold state | a `WebComponent` custom element | expecting the page's own markup to hydrate | `references/components.md` | `app/features/components` | +| draw the same READ-ONLY markup in two places | an `html` fragment helper (`modules//utils/ui/`, or `lib/utils/ui.ts` when app-wide) | a display-only custom element, which ships JS once an island renders it | `references/styling.md` | `app/examples/todo` | | render a keyed list, or swap one node when state changes | `repeat()` / `watch()` from `/directives` | re-rendering the component or diffing by hand | `references/components.md` | `app/features/directives` | | get server data into a component's first paint | `async render()` awaiting an action | fetching in `connectedCallback`, which SSR never calls | `references/components.md` | `app/features/async-render` | | call server code from the browser | import the `'use server'` function and call it | hand-writing `fetch()` against an endpoint | `references/data-and-actions.md` | `app/features/server-actions` | @@ -122,8 +123,10 @@ app/ ROUTING ONLY (thin adapters importing from modules/) error.ts loading.ts not-found.ts forbidden.ts unauthorized.ts boundaries (nearest wins) middleware.ts root middleware modules// actions/ (mutations, *.server.ts), queries/ (reads, *.server.ts), - components/, utils/ (pure), types.ts -lib/ lib/*.server.ts server-only infra, lib/utils/ browser-safe helpers + components/ (custom elements), utils/ (pure, returns data), + utils/ui/ (pure, returns an html fragment), types.ts +lib/ lib/*.server.ts server-only infra, lib/utils/ browser-safe helpers, + lib/utils/ui.ts app-wide html fragments components/*.ts shared presentational custom elements (one per file) db/*.server.ts Drizzle: schema, connection public/* static assets, served at /public/ diff --git a/.agents/skills/webjs/references/styling.md b/.agents/skills/webjs/references/styling.md index 18f8e3200..9b174ec1e 100644 --- a/.agents/skills/webjs/references/styling.md +++ b/.agents/skills/webjs/references/styling.md @@ -36,7 +36,18 @@ When custom CSS IS unavoidable inside a light-DOM component, the tag-prefix inva ## DRY via a JS helper, not `@apply` -When the same Tailwind bundle repeats across 2+ places, extract it into a helper in `lib/utils/ui.ts` that returns an `html` fragment (SSR-time, no client runtime, output identical to inline classes): +When the same Tailwind bundle repeats across 2+ places, extract it into a helper that returns an `html` fragment (SSR-time, no client runtime, output identical to inline classes). Where the helper LIVES follows the narrowest-owner rule, so pick the tier by who consumes it: + +| Consumers | Home | +|---|---| +| routes across the app (a heading, a lede, a back link) | `lib/utils/ui.ts` | +| one feature (a board, a match card, a comment row) | `modules//utils/ui/.ts` | + +One file per fragment under `utils/ui/`, because a feature accumulates several and one-per-file keeps them greppable. A fragment promotes from the feature tier to `lib/` only when a second feature genuinely consumes it. + +The `ui` segment is the part that carries meaning, so keep it at both tiers: inside `modules//`, `components/` holds custom elements, `utils/ui/` holds functions returning a `TemplateResult`, and the rest of `utils/` holds functions returning data. Dropping it lands a view fragment beside a pure data helper with nothing in the path to tell them apart. + +The example below is the app-wide tier: ```ts import { html } from '@webjsdev/core'; @@ -62,12 +73,19 @@ export default function Post({ params }) { | Repeats | Action | |---|---| | Once | Inline the classes. | -| 2 to 3 times, identical | Extract to `lib/utils/ui.ts`. | +| 2 to 3 times, identical, inside ONE feature | Extract to `modules//utils/ui/.ts`. | +| 2 to 3 times, identical, across features or routes | Extract to `lib/utils/ui.ts`. | | Varies by 1 to 2 props | Extract with a small parameter (`mb: 'sm' \| 'md'`). | | Radically different per call site | Keep inline, do not force-fit. | Avoid `@apply`: it hides which utilities a class uses and creates a second source of truth. A JS helper keeps the bundle visible at the definition site, composes with conditional classes and active states, and runs at SSR time. +### Reach for a fragment before a component + +A fragment helper, not a display-only component, is the default for read-only markup, and the reason is cost rather than taste. A component that only renders is normally elided from the browser, so making one looks free. It stops being free the moment a SHIPPING island renders it, because a component rendered by a component that ships can no longer be elided: the class downloads, and it upgrades once per instance. A board drawn inside a live `` island is exactly that case, so as a fragment it stays free SSR markup and as a component it becomes shipped JavaScript. + +Reach for a component when the markup needs behaviour of its own (state, an event handler, a lifecycle hook), and for nothing less. "It felt tidier as an element" is how free HTML turns into a payload. + ### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui` An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. Conflicts are keyed on the CSS PROPERTY wherever `cn` can tell the properties apart, rather than on the shared class prefix, so the common prefix collisions do NOT evict: `cn('border-2', 'border-primary')` keeps both (a width and a colour), `cn('flex', 'flex-1')` keeps both (a `display` and a `flex-grow`, the shape an element that is both a flex container and a flex child needs), `cn('shadow-lg', 'shadow-red-500')` keeps both (a box-shadow and its colour), `cn('bg-clip-text', 'bg-primary')` keeps both (a clip and a colour, so the gradient-text idiom survives a later background), and an arbitrary value carrying a type hint is read as the property the hint names (`cn('shadow-lg', 'shadow-[color:red]')` keeps both). It is a small hand-rolled merger, not `tailwind-merge`, so it is still coarse in two ways. A prefix outside the families it knows is not grouped at all, so both classes are emitted and the winner is left to compiled stylesheet order (`inset-shadow-sm` against `inset-shadow-red-500`, `ring-2` against `ring-red-500`). And where one prefix carries two properties it reads the value against Tailwind's DEFAULT scales, so a `@theme`-extended name it cannot know about can still be misread and evict the wrong class: a custom `--shadow-card` makes `shadow-card` a box-shadow, but `cn` sees an unfamiliar name under a prefix whose bare names are usually colours and treats it as one. When an override has to win and you are unsure, pass the one class rather than layering, or install `clsx` + `tailwind-merge` and replace the helper (its header comment shows the swap). For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size. diff --git a/AGENTS.md b/AGENTS.md index a302a606e..dd2540bad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -411,7 +411,7 @@ Derive the type at every boundary: a DB row from the schema (`typeof todos.$infe ## Styling: Tailwind-first -**Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a `lib/utils/ui.ts` helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`). Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. **Pin a header with `position: fixed`, never `position: sticky`** (a sticky header flickers on iOS WebKit during a client-router nav, #610, because the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug that the GPU-promotion hacks do NOT fix, so reserve the header height on the content with a `--header-height` offset). See `references/styling.md`. +**Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`); it lives in `modules//utils/ui/` when one feature consumes it and `lib/utils/ui.ts` when the whole app does. Prefer that fragment over a display-only custom element, which stops being elided (so it downloads) as soon as a shipping island renders it. Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. **Pin a header with `position: fixed`, never `position: sticky`** (a sticky header flickers on iOS WebKit during a client-router nav, #610, because the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug that the GPU-promotion hacks do NOT fix, so reserve the header height on the content with a `--header-height` offset). See `references/styling.md`. --- diff --git a/examples/blog/CONVENTIONS.md b/examples/blog/CONVENTIONS.md index f77ce5203..97b89faad 100644 --- a/examples/blog/CONVENTIONS.md +++ b/examples/blog/CONVENTIONS.md @@ -501,7 +501,10 @@ and available everywhere via utility classes (`text-foreground`, `bg-card`, **Dedup repeated Tailwind class bundles with JS helpers, not `@apply`.** When the same string of classes appears in 2+ places, extract it into a -small function in `lib/utils/ui.ts`: +small function returning an `html` fragment. App-wide chunks live in +`lib/utils/ui.ts` (this file's examples); a chunk only one feature draws +lives in `modules//utils/ui/.ts` instead, so a view helper +never sits unlabelled beside the pure data helpers in `utils/`: ```ts // lib/utils/ui.ts diff --git a/packages/cli/templates/CONVENTIONS.md b/packages/cli/templates/CONVENTIONS.md index 576116c75..3697a031e 100644 --- a/packages/cli/templates/CONVENTIONS.md +++ b/packages/cli/templates/CONVENTIONS.md @@ -9,8 +9,10 @@ is the short version. - **`app/` is routing only.** Only routing files live there (page, layout, route, middleware, metadata routes). Feature logic goes in `modules//` - (`actions/`, `queries/`, `components/`, `utils/`); shared UI primitives go in - top-level `components/`; browser-safe helpers in `lib/utils/`. + (`actions/`, `queries/`, `components/` for custom elements, `utils/` for pure + helpers returning data, `utils/ui/` for pure helpers returning an `html` + fragment); shared UI primitives go in top-level `components/`; browser-safe + helpers in `lib/utils/`, with app-wide markup fragments in `lib/utils/ui.ts`. - **Server-only code goes behind `.server.ts`.** Reach it from a page or component through a `'use server'` action, never by importing a server-only utility directly into browser-bound code. diff --git a/website/app/docs/conventions/page.ts b/website/app/docs/conventions/page.ts index eac3f63ae..5852eba43 100644 --- a/website/app/docs/conventions/page.ts +++ b/website/app/docs/conventions/page.ts @@ -14,7 +14,7 @@ export default function Conventions() {
  • Module architecture: where actions, queries, and components go.
  • Testing rules: when unit vs E2E tests are required.
  • Component patterns: light DOM by default with Tailwind, shadow DOM opt-in, Class.register('tag'), and the class-prefix rule for light-DOM custom CSS.
  • -
  • Styling convention: a static compiled Tailwind stylesheet (css:build) + @theme tokens, JS helpers in lib/utils/ui.ts to dedupe repeated class bundles, no @apply.
  • +
  • Styling convention: a static compiled Tailwind stylesheet (css:build) + @theme tokens, JS helpers to dedupe repeated class bundles (lib/utils/ui.ts app-wide, modules/<feature>/utils/ui/ per feature), no @apply.
  • Server action patterns: one function per file, ActionResult envelope.
  • Code style: TypeScript extensions, const/let preferences, async/await patterns.
  • diff --git a/website/app/docs/styling/page.ts b/website/app/docs/styling/page.ts index 9eaa0cbdc..f820e3145 100644 --- a/website/app/docs/styling/page.ts +++ b/website/app/docs/styling/page.ts @@ -183,7 +183,11 @@ Card.register('my-card'); .wordmark { background: currentColor; } /* the painted descendant */

    DRY'ing up repeated Tailwind classes via JS helpers

    -

    When the same bundle of Tailwind classes appears in 2+ places, extract it into a JS helper in lib/utils/ui.ts. The helper runs at SSR time inside html\`\`, so the browser sees fully materialised HTML. No client-side runtime, no diff from inline classes.

    +

    When the same bundle of Tailwind classes appears in 2+ places, extract it into a JS helper that returns an html fragment. The helper runs at SSR time inside html\`\`, so the browser sees fully materialised HTML. No client-side runtime, no diff from inline classes.

    + +

    Where it lives follows who consumes it. A fragment used across the app goes in lib/utils/ui.ts; one used by a single feature goes in modules/<feature>/utils/ui/<name>.ts, one file per fragment. The ui segment is what separates a function returning markup from the plain utils/ neighbours that return data, and from components/, which means custom elements.

    + +

    Prefer a fragment over a display-only custom element for read-only markup. A render-only component is normally elided from the browser, but a component rendered by a component that ships can no longer be elided, so the moment an interactive island draws it, the class downloads and upgrades per instance. Reach for a component when the markup needs state, an event handler, or a lifecycle hook.

    // lib/utils/ui.ts import { html } from '@webjsdev/core'; From c40be0b0c3694b468c474dfab39e23c4c99fbebf Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 16 Aug 2026 17:26:10 +0530 Subject: [PATCH 02/10] docs: keep the cheat sheet demo-anchored and CONVENTIONS.md thin The primitive-routing table is asserted one-row-per-gallery-demo (test/repo-health/skill-gallery-intent-parity.test.mjs), and the row I added reused app/examples/todo, so a demo was named twice. There is no demo for this concept and adding one is out of scope here, so the row comes out. The scaffold's CONVENTIONS.md is a deliberate thin bridge under a 2200-character ceiling (test/scaffolds/scaffold-integration.test.js) with 17 characters of headroom, so it is the wrong surface to expand. It points at AGENTS.md and the skill, which now carry the tier. --- .agents/skills/webjs/SKILL.md | 1 - packages/cli/templates/CONVENTIONS.md | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index ad91b32f5..bde56cc73 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -45,7 +45,6 @@ Rows point rather than explain. The reference is the authority on the rule, and | abandon a render because something is missing or not allowed | throw `notFound()` / `forbidden()` / `unauthorized()` | returning an error object and branching in the template | `references/routing-and-pages.md` | `app/features/boundaries` | | set a page's title, description, or social preview | `export const metadata` or `generateMetadata()` | writing `` tags in the page | `references/routing-and-pages.md` | `app/features/metadata` | | make part of the page respond to a click or hold state | a `WebComponent` custom element | expecting the page's own markup to hydrate | `references/components.md` | `app/features/components` | -| draw the same READ-ONLY markup in two places | an `html` fragment helper (`modules//utils/ui/`, or `lib/utils/ui.ts` when app-wide) | a display-only custom element, which ships JS once an island renders it | `references/styling.md` | `app/examples/todo` | | render a keyed list, or swap one node when state changes | `repeat()` / `watch()` from `/directives` | re-rendering the component or diffing by hand | `references/components.md` | `app/features/directives` | | get server data into a component's first paint | `async render()` awaiting an action | fetching in `connectedCallback`, which SSR never calls | `references/components.md` | `app/features/async-render` | | call server code from the browser | import the `'use server'` function and call it | hand-writing `fetch()` against an endpoint | `references/data-and-actions.md` | `app/features/server-actions` | diff --git a/packages/cli/templates/CONVENTIONS.md b/packages/cli/templates/CONVENTIONS.md index 3697a031e..576116c75 100644 --- a/packages/cli/templates/CONVENTIONS.md +++ b/packages/cli/templates/CONVENTIONS.md @@ -9,10 +9,8 @@ is the short version. - **`app/` is routing only.** Only routing files live there (page, layout, route, middleware, metadata routes). Feature logic goes in `modules//` - (`actions/`, `queries/`, `components/` for custom elements, `utils/` for pure - helpers returning data, `utils/ui/` for pure helpers returning an `html` - fragment); shared UI primitives go in top-level `components/`; browser-safe - helpers in `lib/utils/`, with app-wide markup fragments in `lib/utils/ui.ts`. + (`actions/`, `queries/`, `components/`, `utils/`); shared UI primitives go in + top-level `components/`; browser-safe helpers in `lib/utils/`. - **Server-only code goes behind `.server.ts`.** Reach it from a page or component through a `'use server'` action, never by importing a server-only utility directly into browser-bound code. From 856591ba7d6803603230631a4056e92e54a3c375 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 16 Aug 2026 17:50:36 +0530 Subject: [PATCH 03/10] docs: scope the fragment preference to the island case The first draft said a fragment is the default for read-only markup on cost grounds, and that a component is only for markup with behaviour. Both are too strong. A display-only component rendered by a page is elided, so there the two cost the same and the choice is taste. What survives is narrower and worth stating precisely: the fragment's cost is unconditionally zero, while a component's depends on who renders it, so it starts shipping the day an island renders it or it grows a lifecycle hook, with no edit to the file itself. That makes the fragment the safer default around islands, not the always-cheaper one. --- .agents/skills/webjs/references/styling.md | 6 ++++-- AGENTS.md | 2 +- website/app/docs/styling/page.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.agents/skills/webjs/references/styling.md b/.agents/skills/webjs/references/styling.md index 9b174ec1e..9eb67e44d 100644 --- a/.agents/skills/webjs/references/styling.md +++ b/.agents/skills/webjs/references/styling.md @@ -82,9 +82,11 @@ Avoid `@apply`: it hides which utilities a class uses and creates a second sourc ### Reach for a fragment before a component -A fragment helper, not a display-only component, is the default for read-only markup, and the reason is cost rather than taste. A component that only renders is normally elided from the browser, so making one looks free. It stops being free the moment a SHIPPING island renders it, because a component rendered by a component that ships can no longer be elided: the class downloads, and it upgrades once per instance. A board drawn inside a live `` island is exactly that case, so as a fragment it stays free SSR markup and as a component it becomes shipped JavaScript. +Read-only markup can be a fragment helper or a display-only component, and for markup a PAGE renders the two cost the same: a component that does no client work is elided, so the browser never fetches it. Anyone telling you to always prefer the fragment on byte grounds is wrong about that case. -Reach for a component when the markup needs behaviour of its own (state, an event handler, a lifecycle hook), and for nothing less. "It felt tidier as an element" is how free HTML turns into a payload. +The difference is where the markup ends up rendered. A component stays elidable only while nothing that ships renders it, and elision propagates downward, so a display-only component drawn inside a live island ships with it: the class downloads and upgrades once per instance. A board drawn by a page is free either way; the same board drawn inside a `` island is free as a fragment and shipped JavaScript as a component. + +That asymmetry is what makes the fragment the safer default rather than the always-cheaper one. Its cost is unconditionally zero. A component's is conditional on facts OUTSIDE its own file, so a component that is free today starts shipping the day someone renders it from an island, or the day it grows a lifecycle hook or a non-state reactive prop, and nothing about the file changed. Take the component when the markup needs behaviour of its own, take it freely for page-level markup if you prefer elements, and prefer the fragment where a shipping island is or might become the renderer. ### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui` diff --git a/AGENTS.md b/AGENTS.md index dd2540bad..e3670cce2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -411,7 +411,7 @@ Derive the type at every boundary: a DB row from the schema (`typeof todos.$infe ## Styling: Tailwind-first -**Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`); it lives in `modules//utils/ui/` when one feature consumes it and `lib/utils/ui.ts` when the whole app does. Prefer that fragment over a display-only custom element, which stops being elided (so it downloads) as soon as a shipping island renders it. Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. **Pin a header with `position: fixed`, never `position: sticky`** (a sticky header flickers on iOS WebKit during a client-router nav, #610, because the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug that the GPU-promotion hacks do NOT fix, so reserve the header height on the content with a `--header-height` offset). See `references/styling.md`. +**Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`); it lives in `modules//utils/ui/` when one feature consumes it and `lib/utils/ui.ts` when the whole app does. A display-only component is elided while only pages render it, so for page-level markup the two cost the same; prefer the fragment where a shipping island renders it, since a component rendered by one stops being elided and downloads. Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. **Pin a header with `position: fixed`, never `position: sticky`** (a sticky header flickers on iOS WebKit during a client-router nav, #610, because the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug that the GPU-promotion hacks do NOT fix, so reserve the header height on the content with a `--header-height` offset). See `references/styling.md`. --- diff --git a/website/app/docs/styling/page.ts b/website/app/docs/styling/page.ts index f820e3145..eeb7b9729 100644 --- a/website/app/docs/styling/page.ts +++ b/website/app/docs/styling/page.ts @@ -187,7 +187,7 @@ Card.register('my-card');

    Where it lives follows who consumes it. A fragment used across the app goes in lib/utils/ui.ts; one used by a single feature goes in modules/<feature>/utils/ui/<name>.ts, one file per fragment. The ui segment is what separates a function returning markup from the plain utils/ neighbours that return data, and from components/, which means custom elements.

    -

    Prefer a fragment over a display-only custom element for read-only markup. A render-only component is normally elided from the browser, but a component rendered by a component that ships can no longer be elided, so the moment an interactive island draws it, the class downloads and upgrades per instance. Reach for a component when the markup needs state, an event handler, or a lifecycle hook.

    +

    Read-only markup can be a fragment or a display-only component, and for markup a page renders the two cost the same, because a component that does no client work is elided. The difference shows up one level down: a component rendered by a component that ships can no longer be elided, so the moment an interactive island draws it, the class downloads and upgrades per instance. The fragment's cost is unconditionally zero, while the component's depends on who renders it, which is why the fragment is the safer default around islands rather than the always-cheaper choice.

    // lib/utils/ui.ts import { html } from '@webjsdev/core'; From e8cb5e3e79c63a04b584f65388e391097e12d9bb Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 16 Aug 2026 18:01:41 +0530 Subject: [PATCH 04/10] docs: correct the cost model and carve the stream row into a fragment Two corrections from reading the diff back. The cost claim was wrong. I wrote that a fragment stays free where a component ships, but an island's imports are fetched either way: the browser really does download the fragment module (confirmed by watching the network panel for a helper drawn inside a live island). Under a page both are free; under an island both ship, and what the component adds is an element class, its registration, an upgrade per instance, and the display-only components it renders, which stop being elided with it. The section says that now, and its heading no longer asserts a preference the mechanism only partly supports. The gallery demonstrates the tier instead of a new card, since this is architecture rather than a feature. modules/stream already defined its row three times (a rowCls const, a string builder, and two inlined copies in render() that used neither), so it moves to modules/stream/utils/ui/row.ts in the two shapes the demo needs. SSR output is byte-identical. --- .agents/skills/webjs/SKILL.md | 4 ++-- .agents/skills/webjs/references/styling.md | 12 ++++++---- AGENTS.md | 2 +- .../modules/stream/components/stream-demo.ts | 20 ++++++++-------- gallery/modules/stream/utils/ui/row.ts | 23 +++++++++++++++++++ website/app/docs/styling/page.ts | 2 +- 6 files changed, 45 insertions(+), 18 deletions(-) create mode 100644 gallery/modules/stream/utils/ui/row.ts diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index bde56cc73..fe7ef2011 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -122,8 +122,8 @@ app/ ROUTING ONLY (thin adapters importing from modules/) error.ts loading.ts not-found.ts forbidden.ts unauthorized.ts boundaries (nearest wins) middleware.ts root middleware modules// actions/ (mutations, *.server.ts), queries/ (reads, *.server.ts), - components/ (custom elements), utils/ (pure, returns data), - utils/ui/ (pure, returns an html fragment), types.ts + components/ (custom elements), types.ts, + utils/ (pure; returns data, or an html fragment under utils/ui/) lib/ lib/*.server.ts server-only infra, lib/utils/ browser-safe helpers, lib/utils/ui.ts app-wide html fragments components/*.ts shared presentational custom elements (one per file) diff --git a/.agents/skills/webjs/references/styling.md b/.agents/skills/webjs/references/styling.md index 9eb67e44d..a9e83e2f0 100644 --- a/.agents/skills/webjs/references/styling.md +++ b/.agents/skills/webjs/references/styling.md @@ -41,7 +41,7 @@ When the same Tailwind bundle repeats across 2+ places, extract it into a helper | Consumers | Home | |---|---| | routes across the app (a heading, a lede, a back link) | `lib/utils/ui.ts` | -| one feature (a board, a match card, a comment row) | `modules//utils/ui/.ts` | +| one feature (a todo row, a comment card, a board) | `modules//utils/ui/.ts` | One file per fragment under `utils/ui/`, because a feature accumulates several and one-per-file keeps them greppable. A fragment promotes from the feature tier to `lib/` only when a second feature genuinely consumes it. @@ -80,13 +80,15 @@ export default function Post({ params }) { Avoid `@apply`: it hides which utilities a class uses and creates a second source of truth. A JS helper keeps the bundle visible at the definition site, composes with conditional classes and active states, and runs at SSR time. -### Reach for a fragment before a component +### Fragment or display-only component? -Read-only markup can be a fragment helper or a display-only component, and for markup a PAGE renders the two cost the same: a component that does no client work is elided, so the browser never fetches it. Anyone telling you to always prefer the fragment on byte grounds is wrong about that case. +Read-only markup can be a fragment helper or a display-only component, and WHO RENDERS IT decides what either one costs. Be precise about this, because the obvious summary ("fragments are free, components ship") is wrong in both directions. -The difference is where the markup ends up rendered. A component stays elidable only while nothing that ships renders it, and elision propagates downward, so a display-only component drawn inside a live island ships with it: the class downloads and upgrades once per instance. A board drawn by a page is free either way; the same board drawn inside a `` island is free as a fragment and shipped JavaScript as a component. +**Rendered only by pages, they cost the same: nothing.** A page is inert or import-only, so a fragment it calls runs at SSR and is never fetched. A component that does no client work is elided, so it is never fetched either. Byte arguments do not decide this case; pick whichever reads better. -That asymmetry is what makes the fragment the safer default rather than the always-cheaper one. Its cost is unconditionally zero. A component's is conditional on facts OUTSIDE its own file, so a component that is free today starts shipping the day someone renders it from an island, or the day it grows a lifecycle hook or a non-state reactive prop, and nothing about the file changed. Take the component when the markup needs behaviour of its own, take it freely for page-level markup if you prefer elements, and prefer the fragment where a shipping island is or might become the renderer. +**Rendered by a shipping island, BOTH ship.** A module a shipping component imports is fetched, fragment or not, so the fragment's function is downloaded too (verify it yourself: watch the network panel for the helper's path). What differs is what else comes with it. The component ships a custom element class plus its registration and upgrades once per instance, and, because elision propagates downward, it un-elides every display-only component IT renders. The fragment ships a function and stops there. + +So the fragment is the smaller and more predictable choice near an island, not a free one, and the gap is a class and its blast radius rather than everything. The other half of the argument is stability: a fragment cannot change category, while a component that is free today starts shipping the day an island renders it, or the day it grows a lifecycle hook or a non-state reactive prop, with no edit to its own file. Take the component when the markup needs behaviour of its own, take it freely for page-level markup if you prefer elements, and prefer the fragment where a shipping island is or might become the renderer. When it matters, measure with `webjs elision` instead of reasoning from either rule of thumb. ### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui` diff --git a/AGENTS.md b/AGENTS.md index e3670cce2..c4888c05b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -411,7 +411,7 @@ Derive the type at every boundary: a DB row from the schema (`typeof todos.$infe ## Styling: Tailwind-first -**Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`); it lives in `modules//utils/ui/` when one feature consumes it and `lib/utils/ui.ts` when the whole app does. A display-only component is elided while only pages render it, so for page-level markup the two cost the same; prefer the fragment where a shipping island renders it, since a component rendered by one stops being elided and downloads. Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. **Pin a header with `position: fixed`, never `position: sticky`** (a sticky header flickers on iOS WebKit during a client-router nav, #610, because the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug that the GPU-promotion hacks do NOT fix, so reserve the header height on the content with a `--header-height` offset). See `references/styling.md`. +**Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`); it lives in `modules//utils/ui/` when one feature consumes it and `lib/utils/ui.ts` when the whole app does. Who renders it decides the cost: under a page a fragment and a display-only component both cost nothing (the page is inert, the component elides), while under a shipping island BOTH ship, since an island's imports are fetched either way. Prefer the fragment there anyway, because the component additionally ships an element class and its registration, upgrades per instance, and un-elides the display-only components it renders. Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. **Pin a header with `position: fixed`, never `position: sticky`** (a sticky header flickers on iOS WebKit during a client-router nav, #610, because the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug that the GPU-promotion hacks do NOT fix, so reserve the header height on the content with a `--header-height` offset). See `references/styling.md`. --- diff --git a/gallery/modules/stream/components/stream-demo.ts b/gallery/modules/stream/components/stream-demo.ts index fca370a52..1a91d65a3 100644 --- a/gallery/modules/stream/components/stream-demo.ts +++ b/gallery/modules/stream/components/stream-demo.ts @@ -13,6 +13,12 @@ // region-swap would clobber. import { WebComponent, html, renderStream } from '@webjsdev/core'; import { buttonClass } from '#components/ui/button.ts'; +// The row markup lives in ONE place, a feature-local view fragment under +// `utils/ui/`, because both the seeded list below and every streamed-in row +// draw it. A display-only element would have worked too, but this +// component ships, so an element rendered here would ship with it; a fragment +// is a function, so what arrives is the function and nothing else. +import { streamRow, streamRowHTML } from '../utils/ui/row.ts'; // Build a payload string. It is a plain string (NOT an html`` // template), so interpolating the row markup here is fine. `remove` needs no @@ -22,9 +28,6 @@ function streamPayload(action: string, target: string, inner = '') { return `${body}`; } -const rowCls = 'flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground'; -const row = (id: string, label: string) => `
  • ${label}
  • `; - export class StreamDemo extends WebComponent { // A plain instance field, NOT a signal: incremented to mint unique row ids. // It is never read inside render(), so appending a row does not re-render the @@ -37,16 +40,16 @@ export class StreamDemo extends WebComponent { // never runs. Name your handlers something else (see muscle-memory-gotchas). appendRow() { this.#n++; - renderStream(streamPayload('append', 'stream-list', row(`row-${this.#n}`, `Row ${this.#n} (appended)`))); + renderStream(streamPayload('append', 'stream-list', streamRowHTML(`row-${this.#n}`, `Row ${this.#n} (appended)`))); } prependRow() { this.#n++; - renderStream(streamPayload('prepend', 'stream-list', row(`row-${this.#n}`, `Row ${this.#n} (prepended)`))); + renderStream(streamPayload('prepend', 'stream-list', streamRowHTML(`row-${this.#n}`, `Row ${this.#n} (prepended)`))); } replaceFirst() { // `replace` swaps the target element itself. The replacement keeps id row-1, // so the button stays repeatable. - renderStream(streamPayload('replace', 'row-1', row('row-1', 'Row 1 (replaced)'))); + renderStream(streamPayload('replace', 'row-1', streamRowHTML('row-1', 'Row 1 (replaced)'))); } removeSecond() { // `remove` deletes the target and needs no