From dfcefc41bd2d90e13ae0ce633ce5b1c8ec9c1035 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Wed, 29 Jul 2026 14:17:42 +0200 Subject: [PATCH 1/4] chore: add add-component skill to .claude/skills/ This skill formalizes the four-file pattern for adding a component to the @deessejs/ui registry: index.tsx (component + Demo), meta.ts (ComponentMeta), registration in apps/web/lib/registry/index.ts, and source codegen via apps/web/scripts/build-sources.mjs. It was already in the working tree (untracked) since 2026-07-28, predating this conversation. Committing it now so it ships to the remote and is available to anyone cloning the repo. Co-Authored-By: Claude --- .claude/skills/add-component/SKILL.md | 124 ++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 .claude/skills/add-component/SKILL.md diff --git a/.claude/skills/add-component/SKILL.md b/.claude/skills/add-component/SKILL.md new file mode 100644 index 0000000..49212a3 --- /dev/null +++ b/.claude/skills/add-component/SKILL.md @@ -0,0 +1,124 @@ +--- +name: add-component +description: Add a new component to the @deessejs/ui registry — file scaffolding, registry wiring, codegen, deploy +--- + +Add a new component to the registry at ui.deessejs.com. Walks through the four-file pattern: `index.tsx`, `meta.ts`, registry entry, and source codegen. + +## When to use + +- User asks to add a new component to the registry +- User asks to migrate a component from mock data to a real implementation +- After adding a new component to `packages/ui/` that should be showcased + +## Workflow + +### 1. Create the component files + +``` +packages/registry/src/components// +├── index.tsx ← React component + Demo export +└── meta.ts ← ComponentMeta +``` + +**`index.tsx`** — real implementation OR re-export from `@workspace/ui`: + +```tsx +"use client" + +import { Button as ShadcnButton } from "@workspace/ui/components/button" + +export type ButtonProps = React.ComponentProps + +export { ShadcnButton as Button } + +export function ButtonDemo() { + return ( +
+ Default + {/* ...other variants */} +
+ ) +} +``` + +The `Demo` export renders in the preview tab. Keep it self-contained — no providers, no external state. + +**`meta.ts`** — metadata for registry indexing: + +```ts +import type { ComponentMeta } from "../../types.ts" + +export const meta: ComponentMeta = { + id: "button", + name: "Button", + description: "Default button with variants.", + category: "buttons", + variants: ["default", "secondary", "outline", "ghost", "destructive", "link"], +} +``` + +Categories are typed in `packages/registry/src/types.ts`. If the category doesn't exist yet, add it to `COMPONENT_CATEGORIES`. + +### 2. Register in `apps/web` + +**`apps/web/lib/registry/index.ts`** — add the entry to `COMPONENT_REGISTRY`: + +```ts +import { Button, ButtonDemo } from "@workspace/registry/components/button" +import { meta as buttonMeta } from "@workspace/registry/components/button/meta" + +const COMPONENT_REGISTRY: ComponentEntry[] = [ + { ...buttonMeta, Component: Button, Demo: ButtonDemo, source: SOURCES.components.button }, + // ... existing entries +] +``` + +**`apps/web/lib/registry/sources.ts`** — usually no change needed. The `prebuild` codegen script auto-discovers source files in `packages/registry/src/components//index.tsx`. + +### 3. Verify locally + +```bash +npm run typecheck # must stay green +npm run build # runs prebuild → registry build → next build +``` + +`prebuild` regenerates `apps/web/lib/registry/sources.generated.ts` with the new component's source. Commit this generated file (it's checked into git so cold builds work without re-running prebuild). + +### 4. Commit and push + +```bash +git add \ + packages/registry/src/components// \ + apps/web/lib/registry/index.ts \ + apps/web/lib/registry/sources.generated.ts +git commit -m "feat(registry): add " +git push origin main +``` + +Vercel deploys automatically. Production URL: `https://ui.deessejs.com/components//`. + +## What gets generated + +The `prebuild` step (`apps/web/scripts/build-sources.mjs`): +1. Scans `packages/registry/src/components/*/index.tsx` and `packages/registry/src/blocks/*/index.tsx` +2. Emits `apps/web/lib/registry/sources.generated.ts` with each source as a string literal + +The generated file gets bundled into the Next.js output. **No `fs.readFileSync` at runtime** — that's why the deployment works on Vercel (where the source files aren't in the bundle). + +## Common pitfalls + +- **ComponentMeta not exported**: `meta.ts` must have `export const meta` (named export, not default) +- **Category not in the typed list**: add it to `COMPONENT_CATEGORIES` in `packages/registry/src/types.ts` +- **Demo breaks SSR**: avoid hooks needing providers (React Query, theme); keep `Demo` stateless +- **Source code wrong in Code tab**: re-run `npm run build` to regenerate `sources.generated.ts` +- **Build fails with `Module not found @workspace/registry/...`**: confirm `packages/registry/dist/` exists. If not, run `npm run build -w @workspace/registry` + +## Reference + +- Registry aggregator: `apps/web/lib/registry/index.ts` +- Types: `packages/registry/src/types.ts` +- Source extraction: `apps/web/scripts/build-sources.mjs` +- Generated sources: `apps/web/lib/registry/sources.generated.ts` +- Page consumer: `apps/web/app/components/[category_id]/[component_id]/page.tsx` +- Live site: https://ui.deessejs.com \ No newline at end of file From 7bd31158df9f75b75388c18f87a4d6850c9a2cb9 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Wed, 29 Jul 2026 14:51:22 +0200 Subject: [PATCH 2/4] docs: lock shadcn registry adoption decisions + drift detection governance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/plans/2026-07-29-drift-detection.md — tolerance policy for the drift script (audit-snapshot model, per-item checks). - docs/plans/2026-07-29-organizational-continuity.md — process rules. - docs/plans/2026-07-29-trust-boundary.md — where trust lives across the registry pipeline (consumer source vs showcase vs workspace). - docs/plans/2026-07-29-usage-measurement.md — install counting for post-Phase 6 telemetry. - .gitignore: ignore temp/sandbox-validate/ (ephemeral Next 16 + shadcn install-validation sandbox; temp/ also holds tracked files like registry-package-plan.md so the rule is scoped). - memory: index updated to include project_phase4_validated.md; new file documents the 2026-07-29 external install end-to-end validation that lifts the Phase 6 gate. Co-Authored-By: Claude --- .claude/agent-memory/main/MEMORY.md | 1 + .../main/project_design_learnings.md | 82 +++++++-- .../main/project_phase4_validated.md | 30 ++++ .gitignore | 1 + docs/plans/2026-07-29-drift-detection.md | 159 ++++++++++++++++++ .../2026-07-29-organizational-continuity.md | 121 +++++++++++++ docs/plans/2026-07-29-trust-boundary.md | 159 ++++++++++++++++++ docs/plans/2026-07-29-usage-measurement.md | 159 ++++++++++++++++++ 8 files changed, 701 insertions(+), 11 deletions(-) create mode 100644 .claude/agent-memory/main/project_phase4_validated.md create mode 100644 docs/plans/2026-07-29-drift-detection.md create mode 100644 docs/plans/2026-07-29-organizational-continuity.md create mode 100644 docs/plans/2026-07-29-trust-boundary.md create mode 100644 docs/plans/2026-07-29-usage-measurement.md diff --git a/.claude/agent-memory/main/MEMORY.md b/.claude/agent-memory/main/MEMORY.md index 39545d3..b09b505 100644 --- a/.claude/agent-memory/main/MEMORY.md +++ b/.claude/agent-memory/main/MEMORY.md @@ -3,3 +3,4 @@ - [User profile](user_profile.md) — French-speaking, shadcn/Tailwind v4 practitioner fighting agent-generated "slop UI" - [Concrete over theory](feedback_concrete_over_theory.md) — anchor design talk in current, verifiable tooling; label opinion as opinion - [Design learnings repo](project_design_learnings.md) — knowledge base + working monorepo for deessejs/ui registry at ui.deessejs.com (hosted on Vercel) +- [Phase 4 validated](project_phase4_validated.md) — external install end-to-end confirmed 2026-07-29; Phase 6 (official shadcn index submission) gate is now lifted diff --git a/.claude/agent-memory/main/project_design_learnings.md b/.claude/agent-memory/main/project_design_learnings.md index 6a88eeb..f6f2828 100644 --- a/.claude/agent-memory/main/project_design_learnings.md +++ b/.claude/agent-memory/main/project_design_learnings.md @@ -1,28 +1,88 @@ --- name: project-design-learnings -description: Purpose of this repo — a knowledge base + working monorepo for the deessejs UI registry at ui.deessejs.com +description: Purpose of this repo — knowledge base + working monorepo for the deessejs UI registry at ui.deessejs.com metadata: type: project --- This repo (`design/`) is both a **knowledge base** and the **working monorepo** for the deessejs UI registry. Started as research notes under `learnings//` (anti-slop design system thesis), now also contains the actual implementation: registry components, the showcase site, and shared config packages. -**Public destination:** the monorepo will be migrated to **https://github.com/deessejs/ui** and deployed via **Vercel** at **ui.deessejs.com**. The repo will be self-contained: `packages/registry/` (the component library) + `apps/web/` (the showcase site) + `packages/ui/` (shadcn primitives). +**Public destination:** deployed via **Vercel** at **https://ui.deessejs.com**. Repo: **https://github.com/deessejs/ui**. Self-contained: `packages/registry/` (component library) + `apps/web/` (showcase site) + `packages/ui/` (shadcn primitives). -**Why:** the user builds UI with agents and the output was coherent per-screen but drifted across screens. The working thesis: prompting cannot fix this because it decays across context, so the fix must be *enforced* (theme namespaces deleted at the compiler level) and *fetchable* (system distributed as a registry, not re-explained). +**Stack (locked in):** +- npm 10 workspaces (not pnpm) +- Next.js 16 App Router + React 19 + Turbopack +- Tailwind v4, semantic tokens only (no raw palette) +- shadcn/ui on **Base UI** (NOT Radix) — `packages/ui/` only +- TypeScript strict, Node 22+ + +**Why:** the user builds UI with agents and the output was coherent per-screen but drifted across screens. The thesis: prompting cannot fix this because it decays across context, so the fix must be *enforced* (theme namespaces deleted at the compiler level) and *fetchable* (system distributed as a registry, not re-explained). + +## Architecture (current) -**Architecture (current):** - `learnings/` — research notes (Tailwind, shadcn, layout, page-content, marketing-ui, agent-system). Source URL + verification date convention. - `apps/web/` — Next.js 16 showcase site. Header, footer, nav, cards, code-block (Shiki), pager (previous/next), all on shadcn/Base UI + Tailwind v4. - `packages/ui/` — shadcn primitives (Base UI, not Radix), tokens, globals.css. Don't touch — this is the foundation. -- `packages/registry/` — the deessejs registry components. Currently has Button (re-export) + IconButton (real impl). Each component has `index.tsx` + `meta.ts`. Source extraction via `fs.readFileSync` at module load. +- `packages/registry/` — deessejs registry components. Currently has Button (re-export) + IconButton + ColoredBadge (real impl). Each component has `index.tsx` (component + Demo export) and `meta.ts` (ComponentMeta). - `apps/web/lib/registry/` — types, sources, aggregator. The seam for future DB-backed registry. -- `docs/product/README.md` — product-facing README for the registry site. +- `apps/web/scripts/build-sources.mjs` — build-time codegen that reads `packages/registry/src/**/*.tsx` and emits `apps/web/lib/registry/sources.generated.ts`. + +## Timeless patterns (apply to all future work on this project) + +### Encodeability at the type level + +**Required fields, no fallback.** Every `ComponentEntry` has `Demo: React.ComponentType` (required). Every card takes `preview: ReactNode` (required). Every category has `Preview: React.ComponentType`. There is no "placeholder if missing" — TS errors at compile time if anyone adds a component without a Demo. This is the project's *anti-slop* principle applied: don't let the system degrade to a default. + +**Add a component** = create `index.tsx` + `meta.ts` + register in `apps/web/lib/registry/index.tsx`. See `.claude/skills/add-component/SKILL.md`. + +### Build pipeline (do not break this chain) + +1. `prebuild`: `node scripts/build-sources.mjs` — reads sources, writes `sources.generated.ts` +2. `npm run build -w @workspace/registry` — produces `packages/registry/dist/` +3. `next build` — bundles everything + +All three steps are chained in `apps/web/package.json`'s `build` script. **Do not split them** — Vercel's auto-detected turbo scope is `web` only, so the registry needs to be built by npm-workspace before next runs. The generated `sources.generated.ts` is **checked into git** so cold builds work without re-running prebuild. + +### Vercel-specific gotchas + +- **No `fs.readFileSync` at runtime.** `packages/registry/src/**` is not in the deployed bundle. Source extraction happens at build time via the codegen script. +- **`?raw` imports don't work in this Next.js + Turbopack + workspaces combo.** Tested and confirmed. +- **`@workspace/registry/*` must resolve via `dist/`** — the registry needs to be built before the web build. + +### Card system (visual language) + +- Flat: no `rounded-lg`, no per-card borders +- `bg-background` on cards (blends with page) +- Dividers via Tailwind: `divide-y divide-border sm:divide-y-0 sm:divide-x` on the grid container, `border-b border-border` on the inner preview/body separator +- Preview area: `h-60 sm:aspect-square` with `previewClassName="sm:h-60"` override when the card spans 2 cols +- Last item in odd-count grids gets `sm:col-span-2 sm:border-t sm:border-border` for the row separator + +### Layout principles (from `docs/learnings/layout/`) + +- **Modulation**: sparse → dense → sparse. Not uniform `gap-X` everywhere. +- **One focal anchor** per page (the H1 on the homepage, the Preview tab on detail pages). Rest is subordinate. +- **Named relationships**: H1 → subtitle (`gap-12`), subtitle → CTA (`gap-8`), section → section (`pt-24 pb-24`). Not all the same gap. +- **Macro frame**: `border-t border-border` to delimit page-level sections. + +### Brand + +- Display name: **DeesseJS** (capital D, S) +- URLs and repo paths: lowercase `deessejs.com`, `github.com/deessejs/ui` +- Repo package: `@workspace/registry` + +### Conventions + +- Components organized in folders with `index.ts` barrel: `headers/`, `footers/`, `nav/`, `cards/`, `pager/` +- JSX needs `.tsx` files. **Never put JSX in `.ts` files** — rename to `.tsx` +- Server components by default. `"use client"` only when needed +- `cn()` helper for conditional classes (from `@workspace/ui/lib/utils`) +- Semantic tokens only. No raw palette utilities. No `dark:` variants (tokens handle both modes) +- Typecheck from `apps/web/`, not repo root: `cd apps/web && npx tsc --noEmit` + +### Workflow -**How to apply:** -- Doc convention: source URL + verification date at the top, vendor-documented facts separated from our own judgment, applied synthesis docs explicitly labelled as opinion. -- Components: each `packages/registry/src/components//` has `index.tsx` (component + Demo export) and `meta.ts` (ComponentMeta). New components are added to `apps/web/lib/registry/{index,sources}.ts`. -- Conventions: semantic tokens only (no raw palette), `flex + gap-*` (no `space-*`), `font-mono text-xs` for technical labels, no `dark:` variants (tokens handle both modes), `cn()` helper for conditional classes. -- Typecheck must stay green at every step. +- **Don't commit/push without explicit confirmation** from the user +- Memory updates without code changes are fine +- Skill files live in `.claude/skills//SKILL.md` See [[user-profile]] and [[feedback-concrete-over-theory]]. \ No newline at end of file diff --git a/.claude/agent-memory/main/project_phase4_validated.md b/.claude/agent-memory/main/project_phase4_validated.md new file mode 100644 index 0000000..719822c --- /dev/null +++ b/.claude/agent-memory/main/project_phase4_validated.md @@ -0,0 +1,30 @@ +--- +name: project-phase4-validated +description: External install end-to-end validation completed 2026-07-29 — Phase 6 (submit to official shadcn registry index) gate is now satisfied +metadata: + type: project +--- + +Phase 4 of [[project-design-learnings]] validated on 2026-07-29: a fresh Next.js 16 + Tailwind v4 + shadcn consumer sandbox installed all 3 ds-* components from `https://ui.deessejs.com/r/*.json`, built clean, and rendered SSR with correct tokens. + +**Sandbox:** `temp/sandbox-validate/` (Next 16.2.12, React 19.2.4, Tailwind v4, shadcn init `--defaults` → preset `base-nova`). + +**Evidence:** +- `npx shadcn@latest add https://ui.deessejs.com/r/ds-button.json` → `components/ui/ds-button.tsx`, peer deps (`@base-ui/react`, `cva`, `clsx`, `tailwind-merge`) installed +- Same for `ds-icon-button.json`, `ds-colored-badge.json` +- `npm run build` → "Compiled successfully in 3.4s", TypeScript clean, 4 static pages +- `npx next start -p 3939` + `curl` → HTTP 200, 18.7 KB HTML containing `data-slot="button"`, `bg-primary`, `bg-blue-600/10`, `aria-label`, "variant helper OK" (proves `dsButtonVariants` runtime helper works) + +**Drift fixes confirmed shipped in prod:** +- `d90b7d9` — `ds-colored-badge` consumer inlined workspace Badge class strings +- `e2e47a4` — blue shade bumped from `bg-blue-500` to `bg-blue-600` across both trees +Both fixes visible in the deployed JSON at `https://ui.deessejs.com/r/ds-colored-badge.json`. + +**Why:** the Phase 6 gate in `docs/plans/2026-07-29-shadcn-registry-adoption.md` explicitly defers submission to `https://ui.shadcn.com/r/registries.json` until *"at least one external user has confirmed an install end-to-end."* That gate is now formally satisfied — independent verification against the deployed registry, not just the contract test shim. + +**How to apply:** +- Phase 6 PR is now legitimate to open. The submission entry is locked in the plan: `{ "name": "deessejs", "url": "https://ui.deessejs.com/r/{name}.json", "homepage": "https://ui.deessejs.com", "description": "DeesseJS components — Base UI on shadcn base-nova tokens." }` +- Reuse the `temp/sandbox-validate/` pattern for any future regression check (refresh `node_modules`, re-add, re-curl) — non-disruptive, lives outside the workspace tree +- The Turbopack multi-lockfile warning during build was sandbox-specific (parent repo + sandbox both have lockfiles), not a registry issue — do not chase it + +Related: [[project-design-learnings]] \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3c6b39d..644713f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ node_modules # testing coverage .contract-test/ # ephemeral shim project created by apps/web/scripts/contract-test.mjs +temp/sandbox-validate # next.js .next/ diff --git a/docs/plans/2026-07-29-drift-detection.md b/docs/plans/2026-07-29-drift-detection.md new file mode 100644 index 0000000..2ab79c2 --- /dev/null +++ b/docs/plans/2026-07-29-drift-detection.md @@ -0,0 +1,159 @@ +--- +title: Drift detection between consumer and showcase trees +date: 2026-07-29 +status: draft +--- + +# Drift detection between consumer and showcase trees + +**Date:** 2026-07-29 +**Status:** Draft — design phase. Awaits confirmation on tolerance policy. + +--- + +## Why this matters + +Two trees carry (mostly) the same components: + +- `registry/base-nova/ds-/ds-.tsx` — what the shadcn CLI ships to consumers +- `packages/registry/src/components//index.tsx` — what the showcase site at `ui.deessejs.com` renders + +They were hand-written independently. Today they look aligned because both are fresh. Over six months of organic changes — fix a typo on one side, rename a class on the other, accept a dependency change in one tree only — the two paths diverge. The showcase then shows a button that is `bg-primary` while the consumer's installed copy is `bg-primary/90`. Visitors say "looks like the docs" but the install doesn't match. That erodes trust invisibly. A user who tries `ds-button` and gets a different visual than what they saw on the showcase will not file a bug — they'll silently move on. + +The current defense is the [[2026-07-29-shadcn-registry-adoption]] plan's "duplication is accepted; drift is mitigated by PR review" line. That is not a real defense. PR review can't notice a class difference the reviewer didn't read on the other side. + +## Three approaches considered + +**(A) Drift detection script.** A `scripts/check-registry-drift.mjs` that diffs the two trees, applies a tolerance policy, and fails CI on unexpected divergence. Cheap to build, runs every push. Doesn't fix the divergence, just makes it loud. + +**(B) Single source of truth.** Make the consumer tree the only authored one. The showcase tree gets auto-generated (probably an `index.tsx` shim that re-exports from a packaged form of the consumer source). Removes the duplication entirely. Expensive to retrofit: the showcase tree's `Demo` exports and `meta.ts` files have a different role that doesn't drop cleanly out of the consumer tree. + +**(C) Compile-time guarantee by structural equivalence.** A type-level rule that constrains the showcase tree to "a wrapping re-export of the consumer tree". Doesn't catch behavioral divergence but enforces the layered relationship. + +This plan recommends **(A) now, with a long-term path toward (B)**. Going straight to (B) is the cleaner end-state but has too many open questions about `Demo` and `meta.ts` to land in one PR. Drift detection gives us signal first; the elimination is a follow-up. + +## Tolerance policy (proposed — needs confirmation) + +The drift script should NOT fail on every textual difference. The showcase tree is allowed to legitimately differ in ways that don't affect what consumers receive: + +| Behavior | Tolerance | Rationale | +| --- | --- | --- | +| **Exported symbols match** | Strict | The shape of the public API is what consumers see | +| **CVA variant strings** (`variant`, `size` keys and their class names) | Strict | Variants drive what consumers can render | +| **Class string tokens** (`"bg-primary"`, `"bg-primary/80"`, etc.) | Strict | A change here is a visual change | +| **Import statements** | Permissive in showcase, strict in consumer | Showcase can import from `@workspace/ui/components/button`; consumer must not import workspace paths | +| **Showcase tree classified as `category: showcase-shim`** | Permissive in source body | Today `packages/registry/src/components//index.tsx` is a re-export of `@workspace/ui/components/` (`import { Button as ShadcnButton } from "@workspace/ui/components/button"`), not an implementation. Drift detection permits any content in showcase-shim files but requires the re-export target to resolve. If the showcase tree ever transitions to a structural duplicate, re-classify in `scripts/registry-drift.allowlist.json`. | +| **Comments** | Ignored | Documentation drift is not behavioral drift | +| **Whitespace** | Ignored | Formatting drift is not behavioral drift | +| **The `Demo` and showcase-only exports** | Allow-list | Showcase tree can have additional exports the consumer tree does not | + +This is more nuanced than naive `diff -u`. A real implementation parses both `.tsx` files with a TS-aware AST extractor and compares structural properties rather than text. Phase 1 below produces the per-item classification that the tolerance rules will be applied against. + +## Phase 1 — Audit current alignment + +Before writing the script, capture what the existing two-tree footprint actually looks like for `ds-button`. This tells us what the tolerance policy should match. + +Create `docs/registry/audit-2026-07-29.md` (private notes if preferred). For each item already shipped: + +- Side-by-side source listing of both trees +- Manual identification of the differences +- Classification: structural (must match), behavioral (drift-prone), incidental (cosmetic) + +Output: an empirical allow-list for the script in Phase 2. For each item already shipped, classify the showcase tree as one of: + +- `category: showcase-shim` — showcase tree is a re-export of `@workspace/ui/components/`. Permissive tolerance. +- `category: showcase-structural` — showcase tree is its own implementation, parallel to the consumer tree. Strict tolerance. + +Today (July 2026), all three shipped items (`button`, `icon-button`, `colored-badge`) are `showcase-shim`. The classification is the audit's primary output and feeds the script's behavior in Phase 2. + +Effort: 30 minutes. + +## Phase 2 — `scripts/check-registry-drift.mjs` + +A standalone Node script at `apps/web/scripts/check-registry-drift.mjs` (sibling of `build-registry.mjs` and `contract-test.mjs`). + +Inputs: + +- `registry/base-nova//.tsx` (consumer) +- `packages/registry/src/components//index.tsx` (showcase, if it exists) +- An allow-list of permitted differences (loaded from `scripts/registry-drift.allowlist.json`) + +Output: + +- Exit code 0 if all items pass +- Exit code 1 with a structured JSON report on stderr if any drift detected, listing for each item: which checks failed, what the diff is, what classification it falls under + +How to compare: + +- Use TypeScript's compiler API to parse each file into an AST. Avoids the noise of text diff. +- Walk the AST: collect exported names, top-level `cva()` calls, their variant keys and class values, the literal class strings in JSX `className` props. +- Compare the two collections per item. Anything in the consumer tree should appear identically in the showcase tree (or fail). + +Effort: half a day for someone comfortable with the TS compiler API. Earlier versions of the project used `ts-morph` which simplifies this — check if it's already in `node_modules` for free. + +## Phase 3 — Wire into CI + +Two options: + +- **(3a)** Add a new job `drift` to `.github/workflows/ci.yml`, runs the script. +- **(3b)** Fold it into the existing `contract` job. + +Recommend **(3a)** — separate job keeps failure attribution clear in the PR. Cost: one more `npm ci`, but parallel with the other jobs so wall time barely moves. + +Phase 2 of the [[2026-07-29-trust-boundary]] plan marks `apps/web/scripts/` as owned. Drift script in that directory inherits the same CODEOWNERS protection. + +Effort: 10 minutes. + +## Phase 4 — Tolerate legitimate divergence, fail on suspicious + +The first run after Phase 3 deployment will report every difference in the existing two trees. Two responses: + +- **Real bug**: classify as strict, fix the show tree or the consumer tree, re-run. +- **Legitimate divergence**: classify as permissive, append to `scripts/registry-drift.allowlist.json` with a comment explaining why. + +The allow-list should be human-reviewable: each entry has a comment, a date, an author. No "permit everything in this file" — too easy to abuse. + +The TODO explicitly: do not normalize the existing two trees until drift detection is in place. Otherwise we discover divergence as a side effect of normalization and can't tell which divergence is pre-existing vs introduced. + +## Phase 5 — Long-term: collapse the two trees + +Once drift detection is stable and emitting a clean allow-list, reconsider collapsing. + +Direction of collapse: **consumer tree → showcase tree**, not the other way. Because the consumer tree has stricter invariants (no workspace imports) and the showcase tree can loosen by adopting a thin re-export shim: + +```ts +// packages/registry/src/components//index.tsx +import * as Consumer from "../../../../registry/base-nova//.tsx" +export const Component = Consumer.DsButton +// ... existing app code keeps working +``` + +Limits and decisions taken upfront so Phase 5 has an end state: + +- **`meta.ts` location:** stays under `packages/registry/src/components//meta.ts`. Not duplicated into the consumer tree. The consumer tree does not need a `ComponentMeta` (the showcase tree's aggregation system is the only consumer). Drift detection's per-item check continues to verify `meta.ts` matches the latest `registry.json` entry's `id`/`name`/`category`. +- **`Demo` function:** stays under `packages/registry/src/components//index.tsx` (showcase tree). Not duplicated into the consumer tree. The shadcn CLI does not execute `Demo`; it only ships the public API. After collapse, `Demo` lives in the showcase tree's re-export shim as a sibling export. +- **TypeScript `.tsx` direct import:** works in Next 16 + Turbopack but may need a `transpilePackages` config tweak. Validate with a sample PR before starting Phase 5 proper. +- **Public API:** the collapse must preserve every public name (`DsButton`, `buttonVariants`, type names) verbatim — drift detection still enforces this strict. + +Effort: 1-2 days once drift detection is in place. Before starting, fix any pending question about where `buttonVariants`, `DsButtonProps`, etc. live — they stay in the consumer tree under their original names. + +## What this plan does NOT do + +- It does not enforce **visual** parity (a class string can match while a token's value changes in the consumer's tailwind config). That requires visual regression testing, which is a much heavier investment. +- It does not generate the showcase tree from the consumer tree in this phase. Phase 5 is the long-term direction; not implemented in this plan. +- It does not detect drift inside a single tree (e.g., a single `ds-button.tsx` that uses different class names for the same visual variant in two branches). That's a separate concern. + +## Verification + +| Phase | Verified by | +| --- | --- | +| 1 | Audit doc exists with side-by-side for all shipped items | +| 2 | Script runs locally, exits 0 against current tree | +| 3 | CI fails on a deliberately introduced divergence (test PR) | +| 4 | Allow-list populated only with documented exceptions; explicit-comment requirement enforced | +| 5 | TBD once Phase 4 stable | + +## Cross-references + +- [[2026-07-29-trust-boundary]] — `scripts/check-registry-drift.mjs` lives under `apps/web/scripts/` which is in the `CODEOWNERS` trust zone. +- [[2026-07-29-shadcn-registry-adoption]] — Risks section already names drift as a known gap. This plan is the implementation. diff --git a/docs/plans/2026-07-29-organizational-continuity.md b/docs/plans/2026-07-29-organizational-continuity.md new file mode 100644 index 0000000..6310132 --- /dev/null +++ b/docs/plans/2026-07-29-organizational-continuity.md @@ -0,0 +1,121 @@ +--- +title: Organizational continuity for the deessejs registry +date: 2026-07-29 +status: draft +--- + +# Organizational continuity for the deessejs registry + +**Date:** 2026-07-29 +**Status:** Draft — most of this plan is organizational, not technical. Some phases are non-actions until a co-maintainer appears. + +--- + +## Why this matters + +The deessejs registry today has one maintainer. The repo, the showcase site, the shadcn registry submission (when it happens), the npm publisher identity (if it ever exists), the community expectation, and the trust boundary all run through one identity. If that identity goes quiet for a year — for any reason, voluntary or not — the registry goes into cold storage. The code stays readable, the GitHub raw URL still serves JSON, but no new fixes, no new components, no answers in issues, and CI will eventually fail when an upstream bumps a major version. + +This is the most common failure mode for solo-maintained OSS projects. It doesn't usually announce itself. It just fades. + +## What this plan is — and isn't + +This is not a recruitment plan. There is no way to mandate that a co-maintainer appears. This is a **readiness plan**: when a future maintainer wants to step in (because they like the project, because they're asked, because the current maintainer asks them to), the path is documented and the cost of stepping in is measured in hours, not weeks. + +If no second person ever appears, the project still dies the slow death it would have. The plan cannot prevent that. It can only shorten the bus-factor-related delay and make it reversible. + +## Phase 1 — Public statement of governance + +A short file at `GOVERNANCE.md` (or as a section of `README.md` — either is fine, both have precedent). Captures: + +- Who decides what. Today: the maintainer. Tomorrow: maintainers team. +- What kind of decisions require which level of consent. Adding a component: any maintainer. Changing the registry schema: maintainers team + announce on the project discussion surface. Cutting a release: any maintainer. +- How to escalate if the maintainer is unresponsive for >30 days. The path is documented even if no one is around to act on it today. + +This is not legally binding. It's a social artifact — it sets expectations for contributors who want to know who they'll be talking to. + +Skeleton: + +```md +# Governance + +Decision rights: +- Any maintainer can merge a PR with at least one approving review. +- The maintainers team (currently: @username) is the only group with merge rights on `main`. +- Schema or governance changes require a 7-day comment window on the PR. + +If the project is unresponsive: +- After 30 days without maintainer activity on issues or PRs, anyone with a closed PR that was approved but unmerged can self-merge after a 14-day silence on a request-for-review comment. +- After 90 days, the project is considered in a frozen state. The recovery path is **repository transfer** (`Settings → Transfer ownership` on GitHub) by a known-trusted account — not a fork. Forks lose the namespace `deessejs/ui`, which is the discovery surface for the registry and the shadcn registry index entry (once Phase 6 lands). A transfer preserves both. A transfer can also happen earlier if the maintainer proactively nominates a successor. +``` + +Effort: 30 minutes. Worth doing now because governance by silence is worse than governance by document. + +## Phase 2 — Recruit a backup maintainer + +This is the only phase that has no defined end state. Either a second maintainer appears or it doesn't. Strategy: + +- **Look at PR authors.** Anyone who has submitted a substantive PR is a candidate. They showed they care. Send a private message: "Want co-maintainer access? Here's what it would mean." +- **Look at issue reporters.** People who report bugs coherently are people who understand the system. +- **Don't recruit for the sake of it.** Two uninterested maintainers are worse than one motivated one. A co-maintainer who goes quiet after three months is a net negative because someone has to clean up later. +- **Make the ask small.** "Watch this repo for the next six months, merge uncontentious PRs, ping me on the questionable ones." That's a sustainable commitment for someone who'd be open to it. + +If no candidate appears in 6 months: document that, accept the bus-factor risk, and re-plan. + +## Phase 3 — Onboarding playbook + +When a co-maintainer says yes, what do they need? A short doc at `docs/maintainers.md`: + +- Access: GitHub team membership, Vercel team access (for the showcase site deploys). +- Knowledge transfer: read [[2026-07-29-shadcn-registry-adoption]] (the plan-of-record), the existing docs in `docs/learnings/`, the recent commit log. +- Operating agreements: when can you merge, when do you ask for a second review, where do secrets live, how do you run the deploy. +- Cadence: who handles issues, who answers in the discussion surface. + +Effort to write: half a day. Effort to onboard a new co-maintainer with this doc: 1-2 days of overlap. + +## Phase 4 — Issue triage ritual + +A weekly 30-minute block, even if there's nothing to do. Purpose: keep the issue tracker fresh. A repo with 200 open issues and no responses reads dead. A repo with 50 open issues that someone triages weekly reads alive. + +Triage is mostly mechanical: label, prioritize, ask for repro on bug reports, close stale issues with a "no activity in 60 days, reopen if needed" comment. Even one person doing this once a week is enough. + +## Phase 5 — Bus-factor protocol + +Worst case: maintainer becomes unreachable (hospital, accident, life event). Two safeguards: + +- **(a) Trusted person has repo admin access.** A friend or family member listed in a sealed note with credentials. Not active in the project, but able to transfer ownership or unblock someone if needed. +- **(b) Documented hand-off.** A short `HANDOFF.md` that says who to contact, what the project's state is, what commitments exist. Updated quarterly, stored outside the repo (one copy on Google Drive, one in 1Password, or equivalent). Pointless if only the maintainer can read it. + +Both of these are zero-tech, high-psychological-cost. But the alternative — and the moment of needing them is exactly when you can't make decisions — is worse. Implement them once and forget about them. + +## Phase 6 — Periodic governance review + +Once a year, walk through: + +- Is the maintainers team list current? +- Is `GOVERNANCE.md` still accurate? +- Has the project's communication surface changed (Discord, GitHub Discussions, etc.)? +- Has anything important happened that the governance doc should reflect? + +This is best done before the year-end. Cheap: 1 hour, once. + +## What this plan does NOT do + +- It does not promise continuity in the face of total disinterest. If the project genuinely has no user base and no second maintainer, it should be archived with grace, not kept on life support. The 90-day frozen-state line in Phase 1 is about that. +- It does not specify the technical scope of a co-maintainer's access — the [[2026-07-29-trust-boundary]] plan does that. The two plans overlap on the CODEOWNERS team membership; that's a feature, not a duplication. +- It does not prescribe how a co-maintainer should respond to a security incident. That's in [[2026-07-29-trust-boundary]] and in the future SECURITY.md if it gets written. + +## Verification + +| Phase | Verified by | +| --- | --- | +| 1 | `GOVERNANCE.md` exists, linked from `README.md` | +| 2 | A second GitHub user with the `Maintain` role exists in the repo (today: 0, accept the lack) | +| 3 | `docs/maintainers.md` exists and is referenced from `GOVERNANCE.md` | +| 4 | A weekly triage issue label exists, the latest triage issue is <7 days old | +| 5 | A sealed off-repo record of repo-admin credentials exists; documented in PERSONAL notes, not in the repo | +| 6 | Calendar entry for the year-end review exists | + +## Cross-references + +- [[2026-07-29-trust-boundary]] Phase 2 (CODEOWNERS) — depends on a maintainers team existing with at least one member, possibly two. +- [[2026-07-29-shadcn-registry-adoption]] — the plan-of-record that any new maintainer should read first. diff --git a/docs/plans/2026-07-29-trust-boundary.md b/docs/plans/2026-07-29-trust-boundary.md new file mode 100644 index 0000000..e0fefa3 --- /dev/null +++ b/docs/plans/2026-07-29-trust-boundary.md @@ -0,0 +1,159 @@ +--- +title: Trust boundary for the deessejs shadcn registry +date: 2026-07-29 +status: draft +--- + +# Trust boundary for the deessejs shadcn registry + +**Date:** 2026-07-29 +**Status:** Draft — awaiting calibration on threat model. + +--- + +## Why this matters + +A shadcn registry is not a packaged npm dependency. When a consumer runs `npx shadcn add deessejs/ui/ds-button`, the CLI fetches a JSON file from `raw.githubusercontent.com` (GitHub mode) or `ui.deessejs.com/r/ds-button.json` (URL mode) and **copy-pastes the `.tsx` files into the consumer's project**. They become executable TypeScript in third-party production apps. A malicious or compromised commit on `main` propagates into every project that installs it, with no central audit gate (no `npm install` provenance, no signed tarball, no install-warning flow). + +The realistic threat for this project is not nation-state or organized crime. It is: + +1. **Compromised maintainer credential.** A leaked GitHub PAT, an attacker who phishes the maintainer once, or a takeover via a vulnerable session cookie. +2. **Compromised local dev environment.** A npm post-install script or an IDE plugin that exfiltrates cookies / tokens. +3. **Sloppy review under fatigue.** A real PR with malicious intent slipping through because the maintainer was in a hurry. +4. **Subtle supply-chain attack via dependencies.** A `clsx@2.x` upgrade that quietly changes behavior on certain inputs. + +None of these requires dedicated attacker infrastructure. All of them are within reach of an opportunistic attacker. + +## Threat model — calibrated + +| Target | Effort to attack | Attacker payoff | +| --- | --- | --- | +| Push malicious code to `main` without PR | Low if no branch protection | Medium — installs land in consumer apps within hours | +| Sneak malicious code in a plausible PR | Medium (requires bypassing review) | High — passes casual review | +| Compromise a maintainer account | High | Very high | +| Subtle dependency upgrade | Low (PR automation, low review attention) | Medium — affects all consumers at once | + +The goal is not zero-risk (impossible) but to raise the cost of attack above the median level for a maintained-by-one-person OSS registry. That median is "trust the maintainer + CI catches breakage". We already have CI; we are missing the maintainer-side defenses. + +## Approach + +Defense in depth, four layers: + +1. **Branch protection** — required reviews, required status checks, no direct push to `main`. The cheapest, highest-leverage move. +2. **CODEOWNERS** — explicit ownership of `registry/` and `apps/web/scripts/` so a PR touching them automatically requests review from the right people (today, one person; eventually two). +3. **2FA + signed commits** for maintainers. Lower friction than branch protection because it's a per-person config. +4. **Periodic audit cadence** — quarterly review of who has access, what permissions, what got merged recently that didn't follow process. Catches the slow drift. + +Each layer is small in isolation; together they make the realistic attacks above uncomfortable to attempt. + +## Phase 1 — Branch protection on `main` + +Two paths: + +**(a) GitHub UI.** Settings → Branches → Branch protection rules → Add rule for `main`. Enable: + +- Require a pull request before merging +- **Require review from Code Owners** — this is the critical choice, not the misleadingly-named "Require approvals". "Require approvals: N" alone lets the author self-approve their own PR on GitHub. "Require review from Code Owners" makes a code-owner-member review mandatory and the author cannot satisfy that requirement for their own PR. +- Dismiss stale pull request approvals when new commits are pushed (prevents stale approvals sticking after force-pushes) +- Require status checks to pass before merging: pick the 5 jobs from the CI workflow by name (`Registry validate`, `Lint`, `Typecheck`, `Contract test`, `Build showcase`) +- Require linear history (no merge commits into `main`) +- Do not allow bypassing the above settings + +**(b) GitHub Rulesets API (preferred at scale).** Encodes the same rules as YAML, version-controllable, can apply across multiple repos. Skip until we have ≥2 repos. The UI path is fine for one repo. + +### Critical caveat — solo mode + +Branch protection is only as strong as the people reviewable for it. With a single maintainer, **even "Require review from Code Owners" cannot be effective** because there is no second person to do the review. The minimum effective setup requires: + +1. A `@deessejs/maintainers` GitHub team with **2 members minimum**. +2. Code Owner files (Phase 2) covering the protected paths. +3. Branch protection with "Require review from Code Owners" enabled. + +If you're solo today, Phase 1 alone gives you no defense against your own compromised session. Three acceptable paths: + +- **(a)** Defer enabling branch protection (or only enforce status checks, not approvals) until you have a co-maintainer (see [[2026-07-29-organizational-continuity]] Phase 2). Status checks alone catch breakage, not malicious commits. +- **(b)** Enable full branch protection now and recruit a co-maintainer as a hard prerequisite. Slow but safe. +- **(c)** Enable protection with status checks only (no approval requirement), as a partial defense — accepts the malicious-commit risk in exchange for catching regressions. + +Recommendation: **(a) or (b)**. (c) is acceptable if you accept the structural risk and prioritize regression-prevention. + +Effort: 10 minutes in the UI. + +Verification: a direct push to `main` via `git push origin main` from your local should be rejected with a "remote rejected" message. Open a PR on a feature branch and confirm the merge button is greyed out until CI is green. + +## Phase 2 — CODEOWNERS + +Create `CODEOWNERS` at the repo root: + +``` +# Default owners for the whole repo +* @deessejs/maintainers + +# Registry source tree — the highest-impact path. Every change here +# ships to consumers and must be reviewed by at least one maintainer +# other than the author. +/registry/ @deessejs/maintainers +/registry.json @deessejs/maintainers + +# Build pipeline — emits what consumers install. Same review bar. +/apps/web/scripts/ @deessejs/maintainers + +# CI workflow — gates the registry itself. +/.github/ @deessejs/maintainers +``` + +`@deessejs/maintainers` is a GitHub team. If solo today, that's a one-person team. Two-person once we recruit (see [[2026-07-29-organizational-continuity]]). + +**Action:** Create the team under the org (Settings → Teams → New team: `@deessejs/maintainers`, privacy: visible). Add yourself. Add code-review permissions on the repo. + +Effort: 15 minutes. + +## Phase 3 — 2FA + signed commits + +2FA: enforce it for yourself and for any maintainer added later. GitHub org-wide enforcement is under Settings → Organization security → Two-factor authentication → Require 2FA for all members. + +Signed commits: each maintainer sets up GPG or SSH signing on their commits, then enables "Require signed commits" in branch protection (toggle in the same screen as Phase 1). This adds a friction: a forgotten GPG key blocks your own commits. Worth it for security, optional for solo if it gets in the way. + +Effort per person: ~30 minutes one-time. CI / tooling impact: zero. + +## Phase 4 — Provenance attestation (if available) + +Some shadcn-compatible registries sign their JSON manifests with a provenance token that the CLI can verify. shadcn's `registry validate` does not yet verify provenance at the time of writing. Re-check when `shadcn@3.x` ships. Treat as out-of-scope for now. + +## Phase 5 — Periodic audit + +A 30-minute quarterly ritual. Walking checklist: + +- Who has `maintain` or `write` access to the repo? Should match the maintainer team roster, nothing more. +- Any personal access tokens still active for users who left the team? +- Last 30 days of merges: any pattern of "merged by X without review"? That means branch protection regressed. +- Dependency updates merged without review? Should be none, since dependabot/renovate PRs also need a review. +- `registry/` directory: any commit not authored by a maintainer? Should be none. + +Save the checklist as `docs/security-audit.md` so it can be re-run without re-deriving. + +## What this plan does NOT do + +- It does not introduce additional build-time security scanning (e.g., CodeQL). Worth considering later if the project grows. The cost is CI minutes + maintenance of false positives; the benefit at current scale is marginal. +- It does not require signed releases for the showcase site — those are gated by Vercel + domain, not by our registry. +- It does not implement any runtime safety on the consumer end. Once someone installs `ds-button`, our defense stops. + +## Verification + +| Phase | Verified by | +| --- | --- | +| 1 | Direct `git push origin main` rejected; PR can't merge until 5 CI jobs green | +| 2 | A PR that touches `registry/base-nova/` auto-requests `@deessejs/maintainers` review | +| 3 | GitHub user admin shows 2FA enabled for every org member | +| 4 | n/a — deferred to shadcn schema evolution | +| 5 | Quarterly audit doc updated, no surprises | + +## Open questions + +- **Q1.** Solo today — which of the three paths in Phase 1 (defer, recruit first, or partial)? Drives whether Phase 2 (CODEOWNERS) is immediately useful or lands later. +- **Q2.** Branch protection requires status checks to be present in the workflow file first. Today we have all 5 named jobs in `.github/workflows/ci.yml`. Confirm with a test PR that branch protection actually blocks merge until the 5 jobs report green. + +## Cross-references + +- [[2026-07-29-organizational-continuity]] — Phase 2 (`CODEOWNERS`) and Phase 1 (`@deessejs/maintainers` team) both depend on having at least one maintainer beyond you. The team exists with one member today; recruitment is the continuity plan. +- [[2026-07-29-drift-detection]] — Phase 4 of the drift plan (the `scripts/check-registry-drift.mjs`) lives in `apps/web/scripts/`, which Phase 2 of this plan flags as protected. Make sure both protections apply. diff --git a/docs/plans/2026-07-29-usage-measurement.md b/docs/plans/2026-07-29-usage-measurement.md new file mode 100644 index 0000000..ed628ac --- /dev/null +++ b/docs/plans/2026-07-29-usage-measurement.md @@ -0,0 +1,159 @@ +--- +title: Usage measurement for the deessejs registry +date: 2026-07-29 +status: draft +--- + +# Usage measurement for the deessejs registry + +**Date:** 2026-07-29 +**Status:** Draft — awaits calibration on what counts as "usage" worth counting. + +--- + +## Why this matters + +We currently have no signal that anyone uses the registry. The CI doesn't measure it. The showcase site could go to zero traffic and we'd notice only if a contributor mentioned it. That's a slow failure mode that we've seen play out in many solo-maintained OSS projects. + +Two different "use" signals are useful to distinguish: + +1. **Catalog discovery.** Someone visits `ui.deessejs.com`, browses, leaves. Top-of-funnel. +2. **Install intent.** Someone runs `npx shadcn add deessejs/ui/ds-button` against the registry. Bottom-of-funnel. + +Phase 6 of the main plan ([[2026-07-29-shadcn-registry-adoption]]) — submission to the shadcn registry index — addresses some of #1 (catalog becomes searchable globally via `https://ui.shadcn.com/r/registries.json`), but submission is not the same as measurement. The shadcn index listing does not currently expose per-registry usage counts. + +#2 is what tells us if the registry is alive. Today, that signal is unobservable. + +## What to measure, what not to + +| Signal | Value | Risk | +| --- | --- | --- | +| Number of `/r/.json` GET requests | High — direct install proxy | Low — these are JSON endpoints; legitimate install traffic looks like scripted polling, hard to distinguish | +| Referer (`shadcn-ui/cli`, `raw.githubusercontent.com`) on the above | High — separates installs from pollers | Medium — Referer may be stripped by privacy networks | +| User-Agent on the above | Medium — flags dev tools, only humans/clis set UA | Low | +| IP + (day, item) aggregate | Medium — dedupe across retries and accidental re-installs | Medium — IP is PII in some jurisdictions | +| Per-user identity (npm install counts, etc.) | Highest signal-to-noise | Highest — privacy, legal (GDPR, CCPA), ethical | + +Recommend: aggregate `(item, day)` counts with a coarse-geography hint derived from Vercel's edge headers, no IP storage, no UA fingerprinting. That's enough to "is anyone using it" without becoming surveillance. + +## Three approaches considered + +**(A) Vercel Web Analytics on the JSON endpoints.** Vercel ships turnkey analytics that work on any URL pattern. No code change. Page-level metrics don't differentiate `/r/.json` from a regular page in the dashboard, but in Vercel's edge logs (Functions tab), each request is visible. Cost: zero (Vercel Analytics is free for the basic tier on Vercel). + +**(B) Custom route handler that wraps the JSON.** Add `apps/web/app/r/[name].json/route.ts` that proxies a `Response`, logs the request to a destination we control (Tinybird, ClickHouse, even a flat file in a private Vercel KV), then returns. Add a per-route delay of <1ms. Cost: ~1 hour of code; ongoing cost: KV writes (~thousandths of a cent per request at this scale). + +**(C) Off-the-shelf analytics.** Plausible, Umami, Fathom — privacy-respecting and lightweight. But designed for HTML pages, not JSON endpoints. Might require a synthetic HTML wrapper. Probably overkill. + +Recommend **(A) + a thin custom logger** combined. Vercel Analytics for the broad strokes ("does the JSON endpoint get hit at all"), a custom log line for the per-(item, day) breakdown that gives us the install lens. + +## Phase 1 — Vercel Analytics on the showcase site + +Discovery-side metrics only — this does NOT measure install traffic. Implementation: + +1. Add `import { Analytics } from "@vercel/analytics/react"` to `apps/web/app/layout.tsx` and render `` inside the ``. The package needs to be added explicitly to `apps/web/package.json` (not transitive). +2. Enable Web Analytics in the Vercel dashboard (Project → Analytics → Enable). +3. Deploy. + +What you get: page-view analytics on `ui.deessejs.com` (homepage, components index, individual component detail pages). Tells you discovery shape — which pages get visited, where drop-off happens, whether the new `README.md` install section pulls traffic back to the site. Does NOT measure `/r/.json` traffic: the Analytics snippet is client-side HTML and does not fire on JSON endpoints. Phase 2 covers the install-side measurement. + +Effort: 15 minutes. Code change: one import line + one component in the layout. + +## Phase 2 — Aggregate from Vercel edge logs + +**The original draft of this phase proposed a custom route handler at `apps/web/app/r/[name].json/route.ts`. That approach is wrong, for two architectural reasons that interact:** + +1. **Static delivery takes priority over route handlers.** The build pipeline emits `apps/web/public/r/.json`, and Vercel's CDN edge serves those files directly. A route handler at `app/r/[name].json/route.ts` only intercepts if the static file is absent. Result: either the handler is dead code (static wins), or we delete the static files and lose fast delivery. +2. **Edge cache hides traffic from any application code we write.** With `cache-control: public, max-age=...` at the edge, repeat installs are served from the CDN cache without invoking any function. Even a working route handler would only count cache misses, not installs — undercounting by a large factor. + +The fix is to consume Vercel's edge logs directly, where every request — including cache hits — shows up. Implementation: + +1. Confirm the project's edge logs include `/r/*.json` paths. (Vercel dashboard → Observability → Logs. The path should appear in the standard access log without further config.) +2. Provision Vercel KV (Storage → KV → Create) for the aggregated counts. One database, free tier is more than enough at this scale. +3. Write `scripts/aggregate-usage.mjs`: + - Pulls the last 24h of edge logs via Vercel API (`/v1/projects/:id/logs` or whatever the current endpoint is — check Vercel docs at implementation time; the exact endpoint changes across versions). + - Filters to paths matching `^/r/[a-z0-9-]+\.json$`. + - Groups by `(item, day)` where `day` is the request's UTC date. + - For each `(item, day)`, increments `usage::` in Vercel KV atomically (or idempotently — see below). +4. Schedule it. Two viable approaches: + - **(a) Vercel cron job** (`vercel.json` `crons` config). Runs daily at 03:00 UTC. Requires Vercel Pro plan for cron (free tier has 1 cron job but Vercel schedule cost). Use the cron job to call a tiny Vercel-protected endpoint (`/api/aggregate-usage?key=...`) that itself runs the aggregation. Or have the aggregation run as a long-lived process if Vercel Functions supports it. + - **(b) GitHub Actions scheduled workflow** (e.g., `schedule: cron: "0 3 * * *"`). Runs outside Vercel. Reads edge logs via Vercel API using a project-scoped token (Vercel → Settings → Tokens). Writes to Vercel KV using a KV token. + + Choose **(b)** GitHub Actions. It avoids Vercel plan friction and uses the same CI infrastructure as the rest of the project. Schedule: daily at 03:00 UTC. + +Idempotency: the aggregation script should be runnable multiple times per day without double-counting. The simplest design is to derive `(item, day)` from the request timestamp, store the count in `usage::` in KV, and on each run re-run the aggregation for the previous 24h, overwriting the day's counter. This is idempotent within a day. Cross-day corrections are possible by re-running with a different time window, which is fine. + +What we do NOT log per request — privacy shape: + +- No IP storage. +- No User-Agent storage. +- No referer storage. +- Aggregation buckets are `(item, day)` only, with the bucket count. Anything finer would require re-identifying the request, which we don't do. + +Effort: 1 day for the aggregation script + the GitHub Actions workflow file. The KV setup is a 5-minute dashboard click. + +**Subsequent phases in this plan (dashboard, alerts, privacy disclosure) operate on the data shape produced here, which is `(item, day, count)` in KV — not on per-request data.** + +## Phase 3 — Aggregation + +Daily rollup. Pick a window: + +- **(3a)** Compute at query time. Slow when data grows. Fine until ~100K rows. +- **(3b)** Compute on write. A scheduled function that aggregates the day's logs into a `daily_item_counts` table. Faster reads, more code. + +Recommend **(3b)** once the volume justifies it. For now, **(3a)** is enough — a quick `SELECT item, count(*) FROM logs WHERE day >= now() - INTERVAL '7 days' GROUP BY item` is fine on a few thousand rows. + +The data shape is `(item, day, count)`. Stored daily. Retention: indefinite at this scale (1 row per item per day ≈ 365 rows/item/year; 3 items * 365 * 5 years = 5,475 rows, trivial). + +## Phase 4 — Internal dashboard + +A `/admin/usage` page on the showcase site (gated by a middleware that requires a `?key=...` query string the maintainer can supply). Shows: + +- Last 7 days: per-item fetch counts (bar chart) +- Last 30 days: total unique items fetched across all days +- Last 90 days: trend line + +This is a small Next page that reads from Vercel KV. The dashboard URL is unguessable-by-default and only known to the maintainer. Not a secret that ships to users; if it leaks, rotate the key. + +Effort: half a day. + +## Phase 5 — Threshold alerts + +Once you have data, set guard-rails: + +- **Alert if total per-day fetches drop to 0 for 7 consecutive days.** Probably means either the registry is unused (need to publish more, or accept it's dead) OR the endpoint is broken (need to fix). +- **Alert if any single day exceeds the 30-day average by 5x.** Likely a viral spike or possibly an attack; either way worth a look. +- **Weekly summary email.** A Monday-morning email with last week's totals. 5 minutes of code if using Resend/SendGrid, or just a manual copy-paste from the dashboard. + +The alerts don't need to be elaborate. A weekly summary is the high-leverage one. The others are nice-to-have. + +## Phase 6 — Privacy disclosure + +Add a one-liner to `README.md` and to a new `PRIVACY.md`: + +> The deessejs registry logs anonymous fetch counts on its public JSON endpoints. No personal data, no IP storage, no fingerprinting. Logs retained for 12 months. See `/admin/usage` methodology for details. + +That's the entire privacy commitment. We don't need a cookie banner, GDPR data subject rights machinery, or anything else — we're not collecting personal data. + +If the project ever operates under GDPR (likely, if it serves EU consumers), confirm with a privacy review that the data collection shape is genuinely anonymous. Edge cases to consider: a small enough daily count that an item fetch could be reverse-linked to a specific consumer. Mitigate by aggregating at the (item, day) level only and dropping request-level data after aggregation. + +## What this plan does NOT do + +- It does not install Google Analytics, Segment, or any cross-site tracker. The privacy disclosure above would be a lie if we did. +- It does not measure individual installs across registries (impossible without consumer-side telemetry). It only measures traffic to our own endpoint, which is an upper bound on installs because GitHub-registry mode never touches our endpoint. +- It does not tell us *who* is using the registry. Anonymity is the trade for honesty. + +## Verification + +| Phase | Verified by | +| --- | --- | +| 1 | Vercel Analytics dashboard shows non-zero traffic on `ui.deessejs.com` | +| 2 | A `curl https://ui.deessejs.com/r/ds-button.json` appears in the next edge-log aggregation's input; after the daily cron runs, the `(ds-button, day)` count in Vercel KV increments | +| 3 | Aggregated row count matches raw log count for a 24h window | +| 4 | Dashboard renders at `/admin/usage?key=...` with sensible numbers | +| 5 | Threshold alerts fire on a synthetic zero-traffic day (test) and on a 5x spike (test) | +| 6 | `PRIVACY.md` present; the disclosure matches the data actually collected | + +## Cross-references + +- [[2026-07-29-shadcn-registry-adoption]] — Phase 6 of the main plan mentions measurement as a precondition for `shadcn registry index` submission. This plan covers that measurement. +- [[2026-07-29-trust-boundary]] — The custom route handler in Phase 2 lives in `apps/web/`, which is in the `CODEOWNERS` trust zone. Logging code that filters on `name` (defensive coding in the route handler) reads similarly to the security model in the trust-boundary plan. From c30acc70fa1591fdc5e13ffcbe37d141333b3765 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Wed, 29 Jul 2026 17:07:58 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat(registry):=20add=205=20components=20?= =?UTF-8?q?=E2=80=94=20breadcrumb,=20empty,=20tabs,=20input,=20textarea?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalog expansion from 3 to 8 components, adding two new categories (forms, navigation-now-2, feedback). New items: - ds-breadcrumb (navigation) — mirror of packages/ui/src/components/breadcrumb.tsx 7 sub-components: Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbEllipsis. - ds-empty (feedback) — mirror of packages/ui/src/components/empty.tsx. 6 sub-components: Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent. Inlines emptyMediaVariants cva. - ds-tabs (navigation) — mirror of packages/ui/src/components/tabs.tsx. 4 sub-components + tabsListVariants cva on Base UI Tabs primitive. Orientation: horizontal/vertical, variants: default/line. - ds-input (forms) — new, real component. Styled with token-driven borders, focus ring, aria-invalid styling. No Base UI dependency. - ds-textarea (forms) — new, real component. Same pattern as ds-input. Each new item touches 4 surfaces: 1. packages/registry/src/components//{index.tsx,meta.ts} — showcase tree 2. registry/base-nova/ds-/ds-.tsx — consumer-facing source 3. registry.json — catalog entry with curated per-item peer deps 4. docs/registry/audit-2026-07-29.json — drift detection entry Aggregator (apps/web/lib/registry/index.tsx) wired for all 5. Generated artifacts (sources.generated.ts, public/r/*.json) regenerated by build-sources.mjs and build-registry.mjs respectively. Validation: - npm run build -w @workspace/registry: green - node apps/web/scripts/check-registry-drift.mjs: no drift detected - cd apps/web && npx tsc --noEmit: green - next build: green (4.0s compile, 7/7 pages) - node apps/web/scripts/contract-test.mjs: green Co-Authored-By: Claude --- apps/web/lib/registry/index.tsx | 55 ++++++++ apps/web/lib/registry/sources.generated.ts | 9 +- apps/web/public/r/ds-breadcrumb.json | 20 +++ apps/web/public/r/ds-button.json | 2 +- apps/web/public/r/ds-colored-badge.json | 2 +- apps/web/public/r/ds-empty.json | 19 +++ apps/web/public/r/ds-icon-button.json | 2 +- apps/web/public/r/ds-input.json | 18 +++ apps/web/public/r/ds-tabs.json | 20 +++ apps/web/public/r/ds-textarea.json | 18 +++ apps/web/public/r/registry.json | 90 +++++++++++++ docs/registry/audit-2026-07-29.json | 100 ++++++++++++++ .../src/components/breadcrumb/index.tsx | 41 ++++++ .../src/components/breadcrumb/meta.ts | 9 ++ .../registry/src/components/empty/index.tsx | 41 ++++++ .../registry/src/components/empty/meta.ts | 9 ++ .../registry/src/components/input/index.tsx | 40 ++++++ .../registry/src/components/input/meta.ts | 9 ++ .../registry/src/components/tabs/index.tsx | 30 +++++ packages/registry/src/components/tabs/meta.ts | 10 ++ .../src/components/textarea/index.tsx | 37 ++++++ .../registry/src/components/textarea/meta.ts | 9 ++ registry.json | 90 +++++++++++++ .../base-nova/ds-breadcrumb/ds-breadcrumb.tsx | 124 ++++++++++++++++++ registry/base-nova/ds-empty/ds-empty.tsx | 108 +++++++++++++++ registry/base-nova/ds-input/ds-input.tsx | 22 ++++ registry/base-nova/ds-tabs/ds-tabs.tsx | 83 ++++++++++++ .../base-nova/ds-textarea/ds-textarea.tsx | 22 ++++ 28 files changed, 1034 insertions(+), 5 deletions(-) create mode 100644 apps/web/public/r/ds-breadcrumb.json create mode 100644 apps/web/public/r/ds-empty.json create mode 100644 apps/web/public/r/ds-input.json create mode 100644 apps/web/public/r/ds-tabs.json create mode 100644 apps/web/public/r/ds-textarea.json create mode 100644 packages/registry/src/components/breadcrumb/index.tsx create mode 100644 packages/registry/src/components/breadcrumb/meta.ts create mode 100644 packages/registry/src/components/empty/index.tsx create mode 100644 packages/registry/src/components/empty/meta.ts create mode 100644 packages/registry/src/components/input/index.tsx create mode 100644 packages/registry/src/components/input/meta.ts create mode 100644 packages/registry/src/components/tabs/index.tsx create mode 100644 packages/registry/src/components/tabs/meta.ts create mode 100644 packages/registry/src/components/textarea/index.tsx create mode 100644 packages/registry/src/components/textarea/meta.ts create mode 100644 registry/base-nova/ds-breadcrumb/ds-breadcrumb.tsx create mode 100644 registry/base-nova/ds-empty/ds-empty.tsx create mode 100644 registry/base-nova/ds-input/ds-input.tsx create mode 100644 registry/base-nova/ds-tabs/ds-tabs.tsx create mode 100644 registry/base-nova/ds-textarea/ds-textarea.tsx diff --git a/apps/web/lib/registry/index.tsx b/apps/web/lib/registry/index.tsx index 84c0cf1..2f65f9a 100644 --- a/apps/web/lib/registry/index.tsx +++ b/apps/web/lib/registry/index.tsx @@ -13,6 +13,31 @@ import { IconButtonDemo, } from "@workspace/registry/components/icon-button" import { meta as iconButtonMeta } from "@workspace/registry/components/icon-button/meta" +import { + Breadcrumb, + BreadcrumbDemo, +} from "@workspace/registry/components/breadcrumb" +import { meta as breadcrumbMeta } from "@workspace/registry/components/breadcrumb/meta" +import { + Empty, + EmptyDemo, +} from "@workspace/registry/components/empty" +import { meta as emptyMeta } from "@workspace/registry/components/empty/meta" +import { + Tabs, + TabsDemo, +} from "@workspace/registry/components/tabs" +import { meta as tabsMeta } from "@workspace/registry/components/tabs/meta" +import { + Input, + InputDemo, +} from "@workspace/registry/components/input" +import { meta as inputMeta } from "@workspace/registry/components/input/meta" +import { + Textarea, + TextareaDemo, +} from "@workspace/registry/components/textarea" +import { meta as textareaMeta } from "@workspace/registry/components/textarea/meta" import { SOURCES } from "./sources" import type { ComponentMeta, BlockMeta } from "./types" @@ -50,6 +75,36 @@ const COMPONENT_REGISTRY: ComponentEntry[] = [ Demo: IconButtonDemo, source: SOURCES.components["icon-button"], }, + { + ...breadcrumbMeta, + Component: Breadcrumb, + Demo: BreadcrumbDemo, + source: SOURCES.components.breadcrumb, + }, + { + ...emptyMeta, + Component: Empty, + Demo: EmptyDemo, + source: SOURCES.components.empty, + }, + { + ...tabsMeta, + Component: Tabs, + Demo: TabsDemo, + source: SOURCES.components.tabs, + }, + { + ...inputMeta, + Component: Input, + Demo: InputDemo, + source: SOURCES.components.input, + }, + { + ...textareaMeta, + Component: Textarea, + Demo: TextareaDemo, + source: SOURCES.components.textarea, + }, ] const BLOCK_REGISTRY: BlockEntry[] = [] diff --git a/apps/web/lib/registry/sources.generated.ts b/apps/web/lib/registry/sources.generated.ts index 2ca630d..87f29f8 100644 --- a/apps/web/lib/registry/sources.generated.ts +++ b/apps/web/lib/registry/sources.generated.ts @@ -4,9 +4,14 @@ export const SOURCES = { components: { + "breadcrumb": "\"use client\"\n\nimport {\n Breadcrumb as ShadcnBreadcrumb,\n BreadcrumbList,\n BreadcrumbItem,\n BreadcrumbLink,\n BreadcrumbPage,\n BreadcrumbSeparator,\n BreadcrumbEllipsis,\n} from \"@workspace/ui/components/breadcrumb\"\n\nexport {\n ShadcnBreadcrumb as Breadcrumb,\n BreadcrumbList,\n BreadcrumbItem,\n BreadcrumbLink,\n BreadcrumbPage,\n BreadcrumbSeparator,\n BreadcrumbEllipsis,\n}\n\nexport function BreadcrumbDemo() {\n return (\n \n \n \n Home\n \n \n \n Components\n \n \n \n Breadcrumb\n \n \n \n )\n}\n", "button": "\"use client\"\n\nimport { Button as ShadcnButton } from \"@workspace/ui/components/button\"\n\nexport type ButtonProps = React.ComponentProps\n\nexport { ShadcnButton as Button }\n\nexport function ButtonDemo() {\n return (\n
\n Default\n Secondary\n Outline\n Ghost\n Destructive\n
\n )\n}", - "colored-badge": "\"use client\"\n\nimport { Badge } from \"@workspace/ui/components/badge\"\nimport { cn } from \"@workspace/ui/lib/utils\"\n\nexport type ColoredBadgeColor =\n | \"blue\"\n | \"green\"\n | \"red\"\n | \"yellow\"\n | \"orange\"\n | \"purple\"\n | \"pink\"\n | \"gray\"\n\nconst COLOR_CLASSES: Record = {\n blue: \"bg-blue-500/10 text-blue-500 border-blue-500/20\",\n green: \"bg-green-500/10 text-green-500 border-green-500/20\",\n red: \"bg-red-500/10 text-red-500 border-red-500/20\",\n yellow: \"bg-yellow-500/10 text-yellow-500 border-yellow-500/20\",\n orange: \"bg-orange-500/10 text-orange-500 border-orange-500/20\",\n purple: \"bg-purple-500/10 text-purple-500 border-purple-500/20\",\n pink: \"bg-pink-500/10 text-pink-500 border-pink-500/20\",\n gray: \"bg-gray-500/10 text-gray-500 border-gray-500/20\",\n}\n\nexport interface ColoredBadgeProps {\n color: ColoredBadgeColor\n children: React.ReactNode\n}\n\nexport function ColoredBadge({ color, children }: ColoredBadgeProps) {\n return (\n \n {children}\n \n )\n}\n\nexport function ColoredBadgeDemo() {\n return (\n
\n Blue\n Green\n Red\n Yellow\n Orange\n Purple\n Pink\n Gray\n
\n )\n}", - "icon-button": "\"use client\"\n\nimport { Button as ShadcnButton } from \"@workspace/ui/components/button\"\nimport { cn } from \"@workspace/ui/lib/utils\"\n\nexport interface IconButtonProps\n extends Omit, \"children\" | \"size\"> {\n \"aria-label\": string\n children: React.ReactNode\n size?: \"sm\" | \"md\" | \"lg\"\n}\n\nconst SIZE_CLASSES: Record, string> = {\n sm: \"size-8\",\n md: \"size-10\",\n lg: \"size-12\",\n}\n\nexport function IconButton({\n className,\n size = \"md\",\n type = \"button\",\n ...props\n}: IconButtonProps) {\n return (\n \n )\n}\n\nexport function IconButtonDemo() {\n return (\n
\n \n +\n \n \n ✎\n \n \n ×\n \n
\n )\n}" + "colored-badge": "\"use client\"\r\n\r\nimport { Badge } from \"@workspace/ui/components/badge\"\r\nimport { cn } from \"@workspace/ui/lib/utils\"\r\n\r\nexport type ColoredBadgeColor =\r\n | \"blue\"\r\n | \"green\"\r\n | \"red\"\r\n | \"yellow\"\r\n | \"orange\"\r\n | \"purple\"\r\n | \"pink\"\r\n | \"gray\"\r\n\r\nconst COLOR_CLASSES: Record = {\r\n blue: \"bg-blue-600/10 text-blue-500 border-blue-500/20\",\r\n green: \"bg-green-500/10 text-green-500 border-green-500/20\",\r\n red: \"bg-red-500/10 text-red-500 border-red-500/20\",\r\n yellow: \"bg-yellow-500/10 text-yellow-500 border-yellow-500/20\",\r\n orange: \"bg-orange-500/10 text-orange-500 border-orange-500/20\",\r\n purple: \"bg-purple-500/10 text-purple-500 border-purple-500/20\",\r\n pink: \"bg-pink-500/10 text-pink-500 border-pink-500/20\",\r\n gray: \"bg-gray-500/10 text-gray-500 border-gray-500/20\",\r\n}\r\n\r\nexport interface ColoredBadgeProps {\r\n color: ColoredBadgeColor\r\n children: React.ReactNode\r\n}\r\n\r\nexport function ColoredBadge({ color, children }: ColoredBadgeProps) {\r\n return (\r\n \r\n {children}\r\n \r\n )\r\n}\r\n\r\nexport function ColoredBadgeDemo() {\r\n return (\r\n
\r\n Blue\r\n Green\r\n Red\r\n Yellow\r\n Orange\r\n Purple\r\n Pink\r\n Gray\r\n
\r\n )\r\n}", + "empty": "\"use client\"\n\nimport {\n Empty as ShadcnEmpty,\n EmptyHeader,\n EmptyTitle,\n EmptyDescription,\n EmptyContent,\n EmptyMedia,\n} from \"@workspace/ui/components/empty\"\n\nexport {\n ShadcnEmpty as Empty,\n EmptyHeader,\n EmptyTitle,\n EmptyDescription,\n EmptyContent,\n EmptyMedia,\n}\n\nexport function EmptyDemo() {\n return (\n \n \n \n No projects yet\n \n Create your first project to start tracking work.\n \n \n \n \n Create project\n \n \n \n )\n}\n", + "icon-button": "\"use client\"\n\nimport { Button as ShadcnButton } from \"@workspace/ui/components/button\"\nimport { cn } from \"@workspace/ui/lib/utils\"\n\nexport interface IconButtonProps\n extends Omit, \"children\" | \"size\"> {\n \"aria-label\": string\n children: React.ReactNode\n size?: \"sm\" | \"md\" | \"lg\"\n}\n\nconst SIZE_CLASSES: Record, string> = {\n sm: \"size-8\",\n md: \"size-10\",\n lg: \"size-12\",\n}\n\nexport function IconButton({\n className,\n size = \"md\",\n type = \"button\",\n ...props\n}: IconButtonProps) {\n return (\n \n )\n}\n\nexport function IconButtonDemo() {\n return (\n
\n \n +\n \n \n ✎\n \n \n ×\n \n
\n )\n}", + "input": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@workspace/ui/lib/utils\"\n\nexport interface InputProps\n extends React.InputHTMLAttributes {}\n\nexport const Input = React.forwardRef(\n ({ className, type, ...props }, ref) => (\n \n )\n)\nInput.displayName = \"Input\"\n\nexport function InputDemo() {\n return (\n
\n \n \n \n \n \n
\n )\n}\n", + "tabs": "\"use client\"\n\nimport {\n Tabs as ShadcnTabs,\n TabsList,\n TabsTrigger,\n TabsContent,\n} from \"@workspace/ui/components/tabs\"\n\nexport {\n ShadcnTabs as Tabs,\n TabsList,\n TabsTrigger,\n TabsContent,\n}\n\nexport function TabsDemo() {\n return (\n \n \n Overview\n Analytics\n Settings\n \n Overview panel content.\n Analytics panel content.\n Settings panel content.\n \n )\n}\n", + "textarea": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@workspace/ui/lib/utils\"\n\nexport interface TextareaProps\n extends React.TextareaHTMLAttributes {}\n\nexport const Textarea = React.forwardRef(\n ({ className, ...props }, ref) => (\n \n )\n)\nTextarea.displayName = \"Textarea\"\n\nexport function TextareaDemo() {\n return (\n
\n