diff --git a/.changeset/eleven-otters-drum.md b/.changeset/eleven-otters-drum.md new file mode 100644 index 0000000..def626d --- /dev/null +++ b/.changeset/eleven-otters-drum.md @@ -0,0 +1,5 @@ +--- +"brand-shell": minor +--- + +architecture change to build artifacts and app build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f2177a..427cceb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,43 +50,52 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} starter-canary: - name: Starter Canary (npm published) + name: Starter Canary (local pack) runs-on: ubuntu-latest needs: - release if: ${{ needs.release.outputs.published == 'true' }} permissions: contents: read - id-token: write steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 - with: - node-version: 24.13.0 - registry-url: https://registry.npmjs.org - - name: Setup Bun uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 with: bun-version: 1.3.6 - - name: Resolve published version - id: published - env: - PUBLISHED_PACKAGES: ${{ needs.release.outputs.publishedPackages }} - run: | - set -euo pipefail - node -e 'const raw=process.env.PUBLISHED_PACKAGES||"[]"; const list=JSON.parse(raw); const hit=list.find(p=>p.name==="brand-shell"); if(!hit) process.exit(2); process.stdout.write(`VERSION=${hit.version}\n`);' >> "$GITHUB_OUTPUT" + - name: Install and build + run: bun install --frozen-lockfile && bun run build - - name: Install starter dependencies + - name: Pack + run: bun pack + + - name: Install react-npm starter run: bun install --cwd starters/react-npm - - name: Pin brand-shell to published version - run: bun add brand-shell@${{ steps.published.outputs.VERSION }} --cwd starters/react-npm --exact + - name: Pin brand-shell in react-npm starter + run: bun add "$(ls brand-shell-*.tgz)" --cwd starters/react-npm --exact - - name: Build starter + - name: Build react-npm starter run: bun run --cwd starters/react-npm build + + - name: Install vue-npm starter + run: bun install --cwd starters/vue-npm + + - name: Pin brand-shell in vue-npm starter + run: bun add "$(ls brand-shell-*.tgz)" --cwd starters/vue-npm --exact + + - name: Build vue-npm starter + run: bun run --cwd starters/vue-npm build + + - name: Install svelte-npm starter + run: bun install --cwd starters/svelte-npm + + - name: Pin brand-shell in svelte-npm starter + run: bun add "$(ls brand-shell-*.tgz)" --cwd starters/svelte-npm --exact + + - name: Build svelte-npm starter + run: bun run --cwd starters/svelte-npm build diff --git a/ARCHITECTURE_ANALYSIS.md b/ARCHITECTURE_ANALYSIS.md new file mode 100644 index 0000000..e7df37d --- /dev/null +++ b/ARCHITECTURE_ANALYSIS.md @@ -0,0 +1,240 @@ +# brand-shell — Architectural Analysis + +> Authored: 2026-02-20 +> Scope: Full codebase review as a senior principal engineer + +--- + +## Overview + +brand-shell is a framework-agnostic UI component library (v0.11.0) that ships a `Header` and `Footer` with a single shared contract across React, Vue 3.5+, Svelte 5, Web Components, Next.js App Router, and TanStack Router. ESM-only, built with tsdown, zero runtime dependencies. + +--- + +## Layer Model + +``` +src/core/ ← Pure TypeScript: types, validation, normalization, theming + ↓ +src/ ← React components (direct implementation using core) + ↓ +src/web/ ← Web Components (custom elements, standalone) + ↓ +src/vue/ ← Vue 3.5+ wrappers around Web Components +src/svelte/ ← Svelte 5 action (use:brandShell) wrapping Web Components +``` + +Vue and Svelte adapters do NOT re-implement logic — they delegate to the Web Components layer. +Only React has its own direct implementation. + +--- + +## What the Library Gets Right + +- **Layer separation is sound.** Core is pure TypeScript with zero DOM coupling — testable in isolation and framework-portable. +- **Security posture is good.** URL sanitization (`normalizeSafeHref`), automatic `rel="noopener noreferrer"` enforcement, dev-only validation that throws early. +- **Theming model is elegant.** Per-instance CSS variable injection via inline styles means two Header components on the same page can have different themes. WCAG contrast auto-calculation for button text is a nice touch. +- **Package boundary is clean.** One npm package, four entry points, one CSS import, zero runtime deps. +- **Dev/prod guard.** Validation is a dev-only cost (`shouldValidateInDev`), production builds skip it entirely. + +--- + +## Configuration Flow: Consumer to Component + +``` +Consumer provides BrandDetails + BrandTheme + │ + ▼ +Dev-mode validation (validateBrandDetails / validateBrandTheme) + → throws BrandShellValidationError with path-prefixed messages + │ + ▼ +normalizeBrandDetails → NormalizedBrandDetails + ∙ homeHref/website/social: normalizeSafeHref (strips unsafe, keeps safe) + ∙ gmail: normalizeGmailHref (ensures mailto: prefix) + ∙ navLinks: target defaults to _self, rel auto-enforced for _blank + ∙ actions: variant resolved, rel enforced + +normalizeBrandTheme → BrandTheme | null + ∙ trims whitespace, strips empty strings, strips unknown keys + │ + ▼ +buildShellViewModel → { navLinks, ctaLinks, socialLinks } + ∙ CTAs: ordered secondary → primary, variant defaulted + ∙ socialLinks: ordered by platform precedence + │ + ▼ +themeToCssVariables → CSS custom properties + ∙ button text color auto-calculated from hex primaryColor if needed + │ + ▼ +Render (React JSX / Web Component DOM / delegated to WC via Vue/Svelte) + ∙ CSS vars applied inline on root element + ∙ data-brand-cta-layout attribute set from theme.ctaLayout + ∙ BEM class structure: .brand-shell-header / .brand-shell-footer +``` + +--- + +## Architectural Weaknesses + +### 1. [HIGH] React `"."` export leaks into non-React projects — **Root cause of Vue Vite error** + +**The problem:** +`package.json` has legacy `"main"` and `"module"` fields both pointing to `dist/index.mjs` — the React adapter: + +```json +"main": "./dist/index.mjs", +"module": "./dist/index.mjs", +``` + +`dist/index.mjs` contains on line 2: +```js +import { jsx, jsxs } from "react/jsx-runtime"; +``` + +When a Vue app (without React installed) imports `brand-shell/vue`, Vite's dep optimizer scans the package. It reads `"main"` / `"module"` / the `"."` export condition — all of which resolve to `dist/index.mjs`. Encountering the React import, Vite tries to resolve it as an optional peer dep. Since React is not installed, it throws: + +``` +Uncaught Error: Could not resolve "react/jsx-runtime" imported by "brand-shell". Is it installed? + at optional-peer-dep:__vite-optional-peer-dep:react/jsx-runtime:brand-shell:false +``` + +**Consumer workaround (immediate):** +Add to the Vue app's `vite.config.ts`: +```ts +optimizeDeps: { + exclude: ['brand-shell'] +} +``` + +**Library fix (proper):** Remove `"main"` and `"module"` legacy fields from `package.json`. The package is `"type": "module"` and has a complete `exports` map — modern bundlers (Vite, webpack 5, Rollup) all use `exports` and never fall back to `"main"`/`"module"`. Removing them prevents Vite from reading the React entry point when a Vue consumer installs the package. + +**Breaking change:** No. + +--- + +### 2. [HIGH] No router-aware link rendering + +Every nav link and CTA is a plain `` tag. In any SPA (React Router, Next.js, TanStack Router, Vue Router), these cause full page navigations. There is no render prop, slot, or hook to substitute a router's `` component. + +**Impact:** Any serious SPA consumer must intercept `click` events globally or accept full-page reloads. +**Fix:** Additive — expose a `renderLink` render prop (React) / `link-component` slot (Vue) / slot (Web Component). +**Breaking change:** No. + +--- + +### 3. [HIGH] Svelte is an action, not a component + +Vue exports `` / `` as proper Vue components. Svelte exports a single `brandShell` action applied to bare `` custom element tags the consumer writes manually. + +This is inconsistent with how Svelte libraries work. Consumers expect `.svelte` component exports. + +**Fix:** Ship `.svelte` component wrappers alongside the action. +**Breaking change:** Minor (additive; action can remain for backwards compat). + +--- + +### 4. [MEDIUM] Closed social platform system + +`SocialPlatform` is a closed union: `website | linkedin | gmail | github | twitter | discord`. No escape hatch for Bluesky, Mastodon, YouTube, Instagram, or custom platforms. Social icon ordering is also hardcoded in `social.ts` and cannot be changed. + +**Fix:** Accept a `customSocialLinks?: { platform: string; href: string; icon: string }[]` in `BrandDetails`. +**Breaking change:** No. + +--- + +### 5. [MEDIUM] `gmail` field name is a semantic mistake + +The field represents any email contact but is named `gmail`. Users with non-Gmail addresses get confused. This name is in the JSON schema, types, and validation. + +**Fix:** Rename to `email`. Deprecate `gmail` with an alias. +**Breaking change:** Yes (minor semver bump, deprecation path available). + +--- + +### 6. [MEDIUM] No i18n / RTL support + +CSS uses physical properties (`left`, `right`, `margin-left`) rather than logical properties (`inline-start`, `inline-end`). RTL layouts are broken without consumer-side overrides. + +**Fix:** Migrate CSS to logical properties. +**Breaking change:** No. + +--- + +### 7. [LOW] Double normalization in React components + +`Header.tsx` and `Footer.tsx` call `normalizeBrandDetails` explicitly, then `buildShellViewModel` calls it again internally. Idempotent but wasteful on every render. + +**Fix:** Have components pass raw details to `buildShellViewModel` and let it do the single normalization pass. +**Breaking change:** No. + +--- + +### 8. [LOW] Contrast auto-calculation only works for hex colors + +`getAccessibleTextColor` in `theme.ts` silently returns `undefined` for `oklch()`, `hsl()`, `rgb()`, or named CSS colors. Consumers using modern color syntax get no automatic button text contrast. This is undocumented. + +**Fix:** Document the limitation; extend parsing to support `rgb()` and `hsl()` as a minimum. +**Breaking change:** No. + +--- + +### 9. [LOW] Layout tokens not exposed in `BrandTheme` + +Several `--brand-*` CSS variables have no `BrandTheme` prop equivalent: +- `--brand-radius` +- `--brand-header-height` +- `--brand-footer-padding` +- `--brand-button-secondary` + +Consumers must drop into raw CSS to change these, which is inconsistent with the theme prop pattern. + +**Fix:** Add to `BrandTheme` and `themeToCssVariables`. +**Breaking change:** No. + +--- + +### 10. [LOW] `website` social platform uses a speech bubble icon + +`MessageCircleIcon` (Lucide speech bubble) is used for a generic website URL. A globe icon is the universal convention. + +**Fix:** Swap to a globe/external-link SVG in both React and Web Component layers. +**Breaking change:** No (visual only). + +--- + +### 11. [LOW] Social links always open `_blank` + +`target="_blank" rel="noopener noreferrer"` is hardcoded for all social icons. Not configurable. `mailto:` links don't meaningfully use `target` but still get it. + +**Fix:** Respect per-link `target`/`rel` if provided, otherwise default to `_blank` for social. +**Breaking change:** No. + +--- + +## Severity & Effort Summary + +| # | Issue | Severity | Breaking | Effort | +|---|-------|----------|----------|--------| +| 1 | React `"."` export leaks into Vue/Svelte apps | **High** | No | XS | +| 2 | No router-aware link rendering | **High** | No | M | +| 3 | Svelte action vs. component | **High** | Minor | S | +| 4 | Closed social platform system | Medium | No | M | +| 5 | `gmail` field naming | Medium | Yes | S | +| 6 | No RTL / i18n | Medium | No | M | +| 7 | Double normalization in React | Low | No | XS | +| 8 | Contrast only works for hex | Low | No | S | +| 9 | Layout tokens not in `BrandTheme` | Low | No | S | +| 10 | `website` icon semantics | Low | No | XS | +| 11 | Social links always `_blank` | Low | No | XS | + +--- + +## Recommended Fix Priority + +1. **Issue #1** — Remove `"main"` and `"module"` from `package.json`. One-line fix, unblocks all Vue/Svelte consumers. Zero risk. +2. **Issue #2** — Router link render prop. Highest consumer impact for SPA use cases. +3. **Issue #3** — Svelte component exports alongside the existing action. +4. **Issue #4** — Extensible social links. Common consumer request. +5. **Issue #5** — Rename `gmail` → `email` with deprecation alias. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8855f10 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Development +bun run dev # Watch mode build +bun run build # Production build (tsdown + CSS copy) +bun run typecheck # TypeScript type checking +bun run check # Full quality gate: lint + test + build + pack:check + +# Testing +bun run test # Run all vitest tests (unit + browser + storybook) +bun run test:smoke # Smoke tests against built output +bun run storybook # Storybook dev server (port 6006) + +# Demo apps +bun run demo:setup # Install example app dependencies +bun run demo:dev:react # Run React example (also: vue, svelte, next, tanstack) + +# Documentation +bun run docs:setup && bun run docs:dev + +# Releases +bun run changeset # Create a changeset before merging +``` + +**Running a single test:** Pass a filename filter to vitest: +```bash +bunx vitest run src/core/validation.test.ts +``` + +Commits must follow **Conventional Commits** (`feat:`, `fix:`, `chore:`, etc.) — enforced by commitlint. PRs also require a changeset file. + +## Architecture + +This is a **framework-agnostic UI component library** that ships a Header and Footer with a single shared contract across React, Vue, Svelte, Web Components, Next.js, and TanStack Router. + +### Layer Model + +``` +src/core/ ← Pure TypeScript: types, validation, normalization, theming + ↓ +src/ ← React components (direct implementation using core) + ↓ +src/web/ ← Web Components (custom elements, standalone) + ↓ +src/vue/ ← Vue 3.5+ wrappers around Web Components +src/svelte/ ← Svelte 5 action (`use:brandShell`) wrapping Web Components +``` + +Vue and Svelte adapters do **not** re-implement logic — they delegate to the Web Components layer. Only React has its own direct implementation. + +### Core (`src/core/`) + +| File | Purpose | +|------|---------| +| `types.ts` | All TypeScript types: `BrandDetails`, `BrandTheme`, `BrandNavLink`, `BrandAction` | +| `validation.ts` | Runtime schema validation with detailed error messages | +| `shell.ts` | Normalizes raw props → `ShellViewModel` consumed by all adapters | +| `links.ts` | Safe URL validation (blocks `javascript:`, `data:`, etc.) | +| `theme.ts` | Converts `BrandTheme` → CSS custom properties | +| `social.ts` | Social platform detection and icon mapping | + +### Package Exports + +``` +"." → dist/index.mjs (React) +"./web" → dist/web.mjs (Web Components) +"./vue" → dist/vue.mjs (Vue) +"./svelte" → dist/svelte.mjs (Svelte) +"./schema" → dist/brand-shell.schema.json +"./default.css" → dist/default.css +``` + +Build is managed by **tsdown** with 4 entry points and ESM-only output. Framework peer deps (react, vue, svelte) are externalized. + +### Test Projects (Vitest) + +- **unit** — Node environment; tests in `src/core/` +- **web-smoke** — Playwright/Chromium browser tests for framework adapters +- **storybook** — Storybook component interaction tests + +### Supporting Directories + +- `apps/docs/` — Documentation site (Vite + React 19) +- `examples/` — Runnable demo apps (react-vite, vue-vite, svelte-vite, next-app, tanstack-vite) +- `starters/` — Canary npm consumer templates used in CI to verify published output +- `.storybook/` — Storybook config with Chromatic visual regression + +### CI / Release Flow + +1. PRs must have a changeset and conventional commit messages +2. Merge to `main` triggers a Release PR (auto-bumps versions) +3. Merging the Release PR publishes to npm via OIDC Trusted Publishing diff --git a/examples/shared/brand-contract.json b/examples/shared/brand-contract.json index 52feba0..be12731 100644 --- a/examples/shared/brand-contract.json +++ b/examples/shared/brand-contract.json @@ -13,7 +13,7 @@ "linkedin": "https://linkedin.com/in/example", "github": "https://github.com/example", "twitter": "https://x.com/example", - "gmail": "hello@example.com", + "email": "hello@example.com", "tagline": "One shared brand shell across frameworks." }, "theme": { diff --git a/package.json b/package.json index 5fb238e..c7df7a0 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,6 @@ "url": "https://github.com/venwork-dev/brand-shell/issues" }, "type": "module", - "main": "./dist/index.mjs", - "module": "./dist/index.mjs", "types": "./dist/index.d.mts", "exports": { ".": { @@ -28,6 +26,7 @@ "types": "./dist/vue.d.mts" }, "./svelte": { + "svelte": "./src/svelte/index.svelte.ts", "import": "./dist/svelte.mjs", "types": "./dist/svelte.d.mts" }, @@ -37,7 +36,10 @@ "./dist/default.css": "./dist/default.css" }, "files": [ - "dist" + "dist", + "src/svelte/BrandHeader.svelte", + "src/svelte/BrandFooter.svelte", + "src/svelte/index.svelte.ts" ], "scripts": { "prepare": "bun run build", diff --git a/schemas/brand-shell.schema.json b/schemas/brand-shell.schema.json index 13460a2..7460501 100644 --- a/schemas/brand-shell.schema.json +++ b/schemas/brand-shell.schema.json @@ -15,6 +15,17 @@ } }, "$defs": { + "CustomSocialLink": { + "type": "object", + "additionalProperties": false, + "required": ["platform", "href", "label"], + "properties": { + "platform": { "type": "string", "minLength": 1 }, + "href": { "type": "string", "minLength": 1 }, + "label": { "type": "string", "minLength": 1 }, + "iconSvg": { "type": "string", "minLength": 1 } + } + }, "Target": { "type": "string", "enum": ["_blank", "_self", "_parent", "_top"] @@ -63,12 +74,16 @@ "primaryAction": { "$ref": "#/$defs/BrandAction" }, "secondaryAction": { "$ref": "#/$defs/BrandAction" }, "linkedin": { "type": "string", "minLength": 1 }, - "gmail": { "type": "string", "minLength": 1 }, + "email": { "type": "string", "minLength": 1 }, "github": { "type": "string", "minLength": 1 }, "twitter": { "type": "string", "minLength": 1 }, "discord": { "type": "string", "minLength": 1 }, "website": { "type": "string", "minLength": 1 }, - "tagline": { "type": "string", "minLength": 1 } + "tagline": { "type": "string", "minLength": 1 }, + "customSocialLinks": { + "type": "array", + "items": { "$ref": "#/$defs/CustomSocialLink" } + } } }, "BrandTheme": { @@ -82,7 +97,11 @@ "linkColor": { "type": "string", "minLength": 1 }, "socialIconSize": { "type": "string", "minLength": 1 }, "buttonTextColor": { "type": "string", "minLength": 1 }, - "ctaLayout": { "type": "string", "enum": ["inline", "stacked"] } + "ctaLayout": { "type": "string", "enum": ["inline", "stacked"] }, + "borderRadius": { "type": "string", "minLength": 1 }, + "headerHeight": { "type": "string", "minLength": 1 }, + "footerPadding": { "type": "string", "minLength": 1 }, + "secondaryButtonBg": { "type": "string", "minLength": 1 } } } } diff --git a/scripts/check-pack.cjs b/scripts/check-pack.cjs index a6f346c..adca94f 100644 --- a/scripts/check-pack.cjs +++ b/scripts/check-pack.cjs @@ -1,7 +1,7 @@ const { execSync } = require("node:child_process"); const ALLOWED_ROOT_FILES = new Set(["package.json", "README.md", "LICENSE"]); -const DIST_PREFIX = "dist/"; +const ALLOWED_PREFIXES = ["dist/", "src/svelte/"]; const MAX_UNPACKED_SIZE_KB = 220; const output = execSync("bun pm pack --dry-run --ignore-scripts", { @@ -21,7 +21,7 @@ const files = packedLines .filter((file) => Boolean(file)); const disallowedFiles = files.filter( - (file) => !file.startsWith(DIST_PREFIX) && !ALLOWED_ROOT_FILES.has(file), + (file) => !ALLOWED_PREFIXES.some((prefix) => file.startsWith(prefix)) && !ALLOWED_ROOT_FILES.has(file), ); if (disallowedFiles.length > 0) { diff --git a/src/Footer.tsx b/src/Footer.tsx index d47681b..3b5eae4 100644 --- a/src/Footer.tsx +++ b/src/Footer.tsx @@ -1,8 +1,9 @@ +import type { ReactElement } from "react"; import type { BrandDetails, BrandTheme } from "./types"; import { assertValidBrandDetails, assertValidBrandTheme, - buildShellViewModel, + buildShellViewModelFromNormalized, normalizeBrandDetails, normalizeBrandTheme, shouldValidateInDev, @@ -13,19 +14,22 @@ import { themeToStyle } from "./react/theme"; import { DiscordIcon, GithubIcon, + GlobeIcon, LinkedinIcon, MailIcon, - MessageCircleIcon, TwitterIcon, } from "./icons"; +import type { LinkRenderProps } from "./Header"; + export interface FooterProps { details: BrandDetails; theme?: BrandTheme | null; className?: string; + renderLink?: (props: LinkRenderProps) => ReactElement; } -export function Footer({ details, theme, className }: FooterProps) { +export function Footer({ details, theme, className, renderLink }: FooterProps) { if (shouldValidateInDev()) { assertValidBrandDetails(details, "brand-shell/Footer details"); assertValidBrandTheme(theme, "brand-shell/Footer theme"); @@ -35,9 +39,15 @@ export function Footer({ details, theme, className }: FooterProps) { const normalizedTheme = normalizeBrandTheme(theme); const ctaLayout = normalizedTheme?.ctaLayout === "stacked" ? "stacked" : "inline"; const style = themeToStyle(normalizedTheme); - const { navLinks, ctaLinks, socialLinks } = buildShellViewModel(normalizedDetails); + const { navLinks, ctaLinks, socialLinks } = buildShellViewModelFromNormalized(normalizedDetails); const combinedClassName = ["brand-shell-footer", className].filter(Boolean).join(" "); + const LinkEl = renderLink + ? renderLink + : ({ href, className: cls, "aria-label": ariaLabel, target, rel, children }: LinkRenderProps) => ( + {children} + ); + return (