diff --git a/.gitignore b/.gitignore index c31aebb..d799a1d 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,13 @@ __screenshots__ .claude/tools/node_modules /libs/ui/scripts/output/* /libs/ui/.size-limit.json +/libs/web-components/bundle-size-report.json + +# Generated by `npm run check:web-components-ssr` — regenerated on every run, so tracking them +# makes the working tree dirty for anyone who runs the check. The command that produces them is +# step 6 of the walkthrough in the root README. +/libs/web-components/harness/ssr-dsd-static.html +/libs/web-components/harness/ssr-dsd-hydrated.html # Vitest artifacts .vitest-artifacts @@ -84,3 +91,10 @@ plans/ .mcp.json AGENTS.md /stats.html +/libs/web-components/screenshots/ + +# CTORNDSD-646b platform fixtures — build output and generated types only; the fixture +# sources themselves are tracked. See docs/webcomponents-migration/08-react-and-nextjs.md. +/fixtures/*/.next/ +/fixtures/*/next-env.d.ts +.vitest-attachments diff --git a/.nxignore b/.nxignore new file mode 100644 index 0000000..fe0f9e3 --- /dev/null +++ b/.nxignore @@ -0,0 +1,5 @@ +# CTORNDSD-646b: throwaway platform fixtures live here. Next.js generates +# `fixtures/next-ssr-check/.next/dev/package.json` with no `name` field, which makes Nx fail to +# process the project graph for the ENTIRE repo ("The projects in the following directories have no +# name provided"). Every `nx` command — lint, build, test — breaks until this is excluded. +fixtures/** diff --git a/.prettierignore b/.prettierignore index 4c6aead..6d5d6c8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,8 @@ dist/ libs/ui/src/stories libs/ui/scripts/ libs/ui/stats.html +libs/web-components/harness/ssr-dsd-static.html +libs/web-components/harness/ssr-dsd-hydrated.html package.json package-lock.json .github/ diff --git a/README.md b/README.md index e1d418f..57d956a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,13 @@ Nx monorepo containing the GridKit design system packages. | `gd-form-configurator` | ![npm](https://img.shields.io/npm/v/gd-form-configurator) | JSON-Schema form engine (AJV + Zustand) | | `gd-form-configurator-react` | ![npm](https://img.shields.io/npm/v/gd-form-configurator-react) | React bindings for gd-form-configurator | +Not published, and under active investigation: + +| Package | Description | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `gd-design-core` (`libs/design-core`) | Framework-agnostic state stores + token resolution. No React, no Lit, no `gd-design-library` dependency | +| `web-components` (`libs/web-components`) | Lit custom-element port of 5 GridKit atoms. `private: true` — see [Web Components spike](#web-components-spike-ctorndsd-646) | + ## Quick start Install the primary package: @@ -82,3 +89,197 @@ npm run build:form-configurator && npm run publish:form-configurator ```bash npm run crc ComponentName ``` + +## Web Components spike (CTORNDSD-646) + +An investigation into porting GridKit from React to Lit custom elements. **Every demo below runs from +the repo root — you never need to `cd` into a package or fixture.** + +### One-time setup + +```bash +npm install # if you haven't already +npm run demo:setup # builds dist/ + installs the two fixtures (~2-3 min) +``` + +`demo:setup` builds `gd-design-library`, `gd-design-core`, and `web-components`, then installs +`fixtures/react19-check` and `fixtures/next-ssr-check`. Some demos need those build artifacts; the +demo index tells you which. + +### Test it step by step + +Twelve steps, in order, all from the repo root. Each one lists the command, what you should see, and +which finding it proves. **Steps 1–6 are non-interactive** — run them and read the terminal. **Steps +7–11 open a browser.** Step 12 runs the whole automated set at once. + +If you only have five minutes, run **step 12**. + +#### 1. Type-check + +```bash +npm run type-check:web-components +echo $? # 0 +``` + +**Success is silent** — `tsc` prints nothing and exits `0`. Any output at all means a failure. This +checks two projects: the shipped library and the harness. The harness one was broken for a while and is +now covered. + +#### 2. Lint + +```bash +npx nx lint web-components +npx nx lint design-core +``` + +Expect `Successfully ran target lint` from both, with no rule violations listed above it. `nx` prints a +problem count only when there are problems. + +#### 3. Framework-agnostic core tests + +```bash +npm run test:design-core +``` + +Expect **73 passed (6 files)**. Includes the `resetTo` action added because form reset was silently +keeping a checkbox checked — see `FINDINGS.md` §17.1. + +#### 4. Component tests in a real browser + +```bash +npm run test:web-components +``` + +Expect **35 passed (4 files)**, running in real Chromium. This is the suite that found the `gd-input` +accessibility bug on its first run (§18.2). It covers the Input cursor guard, the checkbox +`attribute: false` constraint, form participation, the shared-stylesheet cache, and axe. + +> Real Chromium is a constraint, not a preference — jsdom does not reliably implement Constructable +> StyleSheets, the `popover` attribute, or Declarative Shadow DOM. + +#### 5. Bundle size and the regression gate + +```bash +npm run check:web-components-size +``` + +Prints a per-atom Lit-vs-React table, then expect `✓ no bundle-size regressions`. Ballpark: 5 atoms at +**~11 kB** gzip (**~18 kB** including the `lit` runtime) against **~106 kB** for React+Emotion — +§3, §18.5. + +Exact totals drift by a few dozen bytes between builds because Rollup redistributes shared-helper bytes +between chunks whenever any one chunk changes. That is why the gate's tolerance is 10% rather than 0 — +see [`10-performance-report.md`](./docs/webcomponents-migration/10-performance-report.md). + +To prove the gate actually fails, edit a number down in +`libs/web-components/bundle-size-baseline.json` and re-run: it should exit `1`. + +#### 6. Server rendering with zero client JavaScript + +```bash +npm run check:web-components-ssr +``` + +Generates `libs/web-components/harness/ssr-dsd-static.html` (**0** ` +``` + +A real theme is **mandatory**, not cosmetic: the components resolve the real token files, whose own +fallbacks are debug placeholder strings, so a themeless render produces invalid CSS (**measured**, +§§13, 16). + +### Lit + +```ts +html`Save`; +``` + +### React + +```tsx +import { createComponent } from '@lit/react'; +import * as React from 'react'; +import { GdCheckbox as GdCheckboxElement } from 'gd-design-web'; + +export const GdCheckbox = createComponent({ + tagName: 'gd-checkbox', + elementClass: GdCheckboxElement, + react: React, + events: { onGdChange: 'gd-change' }, +}); + + setChecked(e.detail.checked)}> + Accept terms +; +``` + +The `events` map is required on **both** React 18 and 19. + +### Next.js (App Router) + +```tsx +// app/page.tsx — server component: renders tags, imports nothing from the package +Save + +``` + +```tsx +// app/client-island.tsx +'use client'; +import { defaultTheme } from 'gd-design-library/tokens'; +import 'gd-design-web'; +``` + +`'use client'` is **mandatory** — the token barrel cannot be imported in a server component +(**measured**, §17.4). See `08-react-and-nextjs.md`. + +### Vue + +```vue + +``` + +Vue handles custom elements natively; `.prop` forces property assignment for objects, and `@gd-change` +binds custom events directly. **Reasoned, not verified** — no Vue fixture was built. + +### Angular + +```html +Save +``` + +Requires `CUSTOM_ELEMENTS_SCHEMA` in the consuming module. **Reasoned, not verified** — no Angular +fixture was built. diff --git a/docs/webcomponents-migration/05-native-html-guidelines.md b/docs/webcomponents-migration/05-native-html-guidelines.md new file mode 100644 index 0000000..34bb71d --- /dev/null +++ b/docs/webcomponents-migration/05-native-html-guidelines.md @@ -0,0 +1,158 @@ +# 05 — Native HTML Versus Custom Element Guidelines + +**Owner:** CTORNDSD-646a · **Answers:** CTORNDSD-646 acceptance criterion 6 · **Status:** Delivered + +CTORNDSD-646 asks whether _all_ existing GridKit components should become custom elements. The +answer is **no**, and the dividing line is empirical rather than stylistic. + +This analysis uses only components that already exist. No component was built for it. + +## The question that actually decides it + +Shadow DOM is not free. It buys style isolation — the measured fix for CTORNDSD-286 (**measured**, +`FINDINGS.md` §1) — and it charges three things: + +1. **Light-DOM discoverability.** `document.querySelector('h1')` cannot reach a heading rendered + inside a shadow root (**measured**, §5). The accessibility tree is unaffected — screen readers see + a real heading — so this is a DOM-query gap, not an a11y regression. But SEO crawlers, link + checkers, browser extensions, analytics selectors, E2E selectors, and testing-library shortcuts + all use light-DOM queries. +2. **A containing block per element.** A percentage width on a shadow-DOM child resolves against the + host's box, and `:host` defaults to `auto`. This is not theoretical: `gd-select` collapsed to + icon-only width and clipped its dropdown text for exactly this reason, fixed by setting an + explicit width on the host (**measured**, §10). +3. **Per-instance setup cost.** Custom-element upgrade plus shadow-root attachment, paid per node. At + 300 instances this is measurable (**measured**, §14). + +So the decision rule is not "is this a component?" but **"does this element own behavior worth paying +isolation for, and does anything outside need to find its internals?"** + +## Decision rule + +Apply in order; stop at the first match. + +| Ship | When | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Lit custom element** | It owns interaction state or behavior the browser does not provide natively, **and** it renders a visual surface that must survive a hostile host reset, **and** nothing external needs to query its internals by light-DOM tag or attribute | +| **Native element + shared token CSS** | It renders a real semantic element whose **discoverability matters** (SEO, crawlers, link checkers, analytics, E2E selectors) — even if it carries a little behavior. Behavior small enough to express as a documented pattern or a thin hook does not justify a shadow root | +| **No abstraction — shared utility CSS** | It carries no behavior **and** no visual surface of its own. Pure layout and spacing wrappers | +| **Documentation only** | The value is a naming convention over CSS that already exists | +| **React wrapper** | Orthogonal, not exclusive — applies **on top of** a Lit element whenever consumers are React. See `08-react-and-nextjs.md` | + +## Verdicts + +Behavior signals below are **measured** — line counts and hook/state/effect counts read from each +component's `.tsx`. Bundle figures are **measured** from `FINDINGS.md` §3. + +| Group | Existing component(s) | Behavior signal | Verdict | Confidence | +| --------------- | ----------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------- | --------------- | +| Button | `atoms/Button` | 82 lines, 0 state | **Lit custom element** | High | +| Input | `atoms/Input` | 106 lines, 0 state, 7 hooks | **Lit custom element** — conditional | High | +| Select | `atoms/Select` | 533 lines, 10 state, 6 effects, 5 context | **Lit custom element** | High | +| Checkbox | `atoms/Checkbox` | 97 lines, 2 state, 3 effects | **Lit custom element** — conditional | High | +| Link | `atoms/Link` | 58 lines, 0 state | **Native `` + shared token CSS** | High | +| Image | `atoms/Image` | 91 lines, 3 state | **Native `` + shared token CSS** | Medium | +| Typography | `atoms/Typography` | 46 lines, 0 state | **Native element + shared token CSS** | High — measured | +| Layout and grid | `layout/Row`, `layout/Column`, `layout/FlexContainer` | 38 / 41 / 30 lines, 0 state each | **No abstraction — shared utility CSS** | High — measured | +| Containers | `atoms/Box`, `atoms/Wrapper` | 41 / 22 lines, 0 state | **No abstraction — shared utility CSS** | High | +| Containers | `layout/ChatContainer` | 163 lines, 2 state, `useMediaQuery`, `useImperativeHandle` | **Lit custom element** — it is not a container | Medium | + +### Ship as Lit custom elements + +**Button.** The most-used interactive primitive, and CTORNDSD-286 was a button bug — style isolation +is the whole point here. It carries variant, loading, and focus-ring states that native `