diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a96a9a7..7f6edef3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: push: branches: [main] +permissions: + contents: read + concurrency: group: ci-${{ github.head_ref || github.ref_name }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -63,6 +66,25 @@ jobs: - name: Verify component previews resolve to real stories run: pnpm -F @vllnt/ui-registry registry:verify-previews + native: + name: Native Package Gates + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v5 + + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Verify native packages and Expo bundles + run: pnpm ci:native + e2e: name: E2E (Playwright) runs-on: ubuntu-latest diff --git a/.github/workflows/native-canary.yml b/.github/workflows/native-canary.yml new file mode 100644 index 00000000..e59d28aa --- /dev/null +++ b/.github/workflows/native-canary.yml @@ -0,0 +1,272 @@ +name: Native Canary + +on: + push: + branches: [main] + paths: + - "packages/design/**" + - "packages/ui-core/**" + - "packages/ui-native/**" + - "apps/native-catalog/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "turbo.json" + - ".github/workflows/native-canary.yml" + +concurrency: + group: native-canary-main + cancel-in-progress: false + +permissions: + contents: read + +jobs: + quality: + name: Native Quality Gates + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v5 + + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm ci:native + + publish: + name: Publish Native Canary Pair + if: vars.NATIVE_CANARY_PUBLISH_ENABLED == 'true' + needs: quality + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: npm-native-canary + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v5 + + - uses: pnpm/action-setup@v5 + + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + registry-url: https://registry.npmjs.org + + - run: pnpm install --frozen-lockfile + + - name: Build publication artifacts + run: pnpm --filter '@vllnt/ui-native...' build + + - name: Publish synchronized canaries + shell: bash + run: | + set -euo pipefail + + CORE_DIR="packages/ui-core" + NATIVE_DIR="packages/ui-native" + + test "$(node -p "require('./${CORE_DIR}/package.json').name")" = "@vllnt/ui-core" + test "$(node -p "require('./${NATIVE_DIR}/package.json').name")" = "@vllnt/ui-native" + + CORE_BASE="$(node -p "require('./${CORE_DIR}/package.json').version")" + NATIVE_BASE="$(node -p "require('./${NATIVE_DIR}/package.json').version")" + test "$CORE_BASE" = "$NATIVE_BASE" + case "$CORE_BASE" in + *-*) echo "::error::Base package versions must not be prereleases"; exit 1 ;; + esac + + current_main_sha() { + git ls-remote --exit-code origin refs/heads/main | awk '{print $1}' + } + REMOTE_MAIN_SHA="$(current_main_sha)" + test -n "$REMOTE_MAIN_SHA" + if [[ "$REMOTE_MAIN_SHA" != "$GITHUB_SHA" ]]; then + echo "::notice::Skipping superseded canary run for ${GITHUB_SHA}" + exit 0 + fi + + SHORT_SHA="$(printf '%s' "$GITHUB_SHA" | cut -c1-12)" + CANARY_VERSION="${CORE_BASE}-canary.${GITHUB_RUN_NUMBER}.sha${SHORT_SHA}" + STAGING_TAG="run-${GITHUB_RUN_ID}" + + read_tags() { + local package="$1" + local output_file error_file output + output_file="$(mktemp "$RUNNER_TEMP/npm-view-output.XXXXXX")" + error_file="$(mktemp "$RUNNER_TEMP/npm-view-error.XXXXXX")" + if npm view "$package" dist-tags --json >"$output_file" 2>"$error_file"; then + output="$(cat "$output_file")" + rm -f "$output_file" "$error_file" + [[ -n "$output" ]] || output='{}' + if ! jq -e 'type == "object"' <<<"$output" >/dev/null; then + echo "::error::npm returned invalid dist-tag data for ${package}" >&2 + return 1 + fi + printf '%s\n' "$output" + return 0 + fi + if grep -q 'E404' "$output_file" "$error_file"; then + rm -f "$output_file" "$error_file" + printf '{}\n' + return 0 + fi + cat "$output_file" "$error_file" >&2 + rm -f "$output_file" "$error_file" + return 1 + } + tag_value() { + jq -r --arg tag "$2" '.[$tag] // ""' <<<"$1" + } + + CORE_TAGS_BEFORE="$(read_tags @vllnt/ui-core)" + NATIVE_TAGS_BEFORE="$(read_tags @vllnt/ui-native)" + CORE_LATEST_BEFORE="$(tag_value "$CORE_TAGS_BEFORE" latest)" + NATIVE_LATEST_BEFORE="$(tag_value "$NATIVE_TAGS_BEFORE" latest)" + CORE_CANARY_BEFORE="$(tag_value "$CORE_TAGS_BEFORE" canary)" + NATIVE_CANARY_BEFORE="$(tag_value "$NATIVE_TAGS_BEFORE" canary)" + + npm version "$CANARY_VERSION" --prefix "$CORE_DIR" --no-git-tag-version --ignore-scripts + npm version "$CANARY_VERSION" --prefix "$NATIVE_DIR" --no-git-tag-version --ignore-scripts + node - "$NATIVE_DIR/package.json" "$CANARY_VERSION" <<'NODE' + const fs = require("node:fs"); + const [manifestPath, version] = process.argv.slice(2); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + manifest.dependencies["@vllnt/ui-core"] = version; + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + NODE + + PACK_DIR="$RUNNER_TEMP/native-packs" + mkdir -p "$PACK_DIR" + CORE_TARBALL="$(pnpm --dir "$CORE_DIR" pack --pack-destination "$PACK_DIR" | tail -n1)" + NATIVE_TARBALL="$(pnpm --dir "$NATIVE_DIR" pack --pack-destination "$PACK_DIR" | tail -n1)" + + test "$(tar -xOf "$CORE_TARBALL" package/package.json | jq -r '.name')" = "@vllnt/ui-core" + test "$(tar -xOf "$CORE_TARBALL" package/package.json | jq -r '.version')" = "$CANARY_VERSION" + test "$(tar -xOf "$NATIVE_TARBALL" package/package.json | jq -r '.name')" = "@vllnt/ui-native" + test "$(tar -xOf "$NATIVE_TARBALL" package/package.json | jq -r '.version')" = "$CANARY_VERSION" + test "$(tar -xOf "$NATIVE_TARBALL" package/package.json | jq -r '.["react-native"]')" = "./dist/index.js" + tar -tf "$NATIVE_TARBALL" | grep -qx 'package/dist/index.js' + test "$(tar -xOf "$NATIVE_TARBALL" package/package.json | jq -r '.dependencies["@vllnt/ui-core"]')" = "$CANARY_VERSION" + if tar -xOf "$NATIVE_TARBALL" package/package.json | grep -q 'workspace:'; then + echo "::error::Packed native manifest still contains a workspace protocol" + exit 1 + fi + + sed -i '/_authToken/d' "$NPM_CONFIG_USERCONFIG" + unset NODE_AUTH_TOKEN + + PROMOTION_STARTED=false + remove_owned_tag() { + local package="$1" + local tag="$2" + local expected="$3" + local tags current + tags="$(read_tags "$package")" || return 1 + current="$(tag_value "$tags" "$tag")" + [[ -n "$current" ]] || return 0 + if [[ "$current" != "$expected" ]]; then + echo "::error::Refusing to remove ${package} tag ${tag}: expected ${expected}, found ${current}" + return 1 + fi + npx --yes npm@11.18.0 dist-tag rm "$package" "$tag" || return 1 + tags="$(read_tags "$package")" || return 1 + [[ -z "$(tag_value "$tags" "$tag")" ]] + } + cleanup_staging() { + local failed=0 + remove_owned_tag @vllnt/ui-core "$STAGING_TAG" "$CANARY_VERSION" || failed=1 + remove_owned_tag @vllnt/ui-native "$STAGING_TAG" "$CANARY_VERSION" || failed=1 + return "$failed" + } + on_exit() { + local status=$? + local recovery_failed=0 + trap - EXIT + if [[ $status -ne 0 && "$PROMOTION_STARTED" = true ]]; then + restore_pair || recovery_failed=1 + fi + cleanup_staging || recovery_failed=1 + if [[ $recovery_failed -ne 0 ]]; then + echo "::error::Could not fully restore npm dist-tags" + [[ $status -ne 0 ]] || status=1 + fi + exit "$status" + } + trap on_exit EXIT + + if ! npm view "@vllnt/ui-core@${CANARY_VERSION}" version >/dev/null 2>&1; then + npx --yes npm@11.18.0 publish "$CORE_TARBALL" --tag "$STAGING_TAG" --provenance --access public + fi + + for attempt in $(seq 1 12); do + npm view "@vllnt/ui-core@${CANARY_VERSION}" version >/dev/null 2>&1 && break + sleep 5 + done + npm view "@vllnt/ui-core@${CANARY_VERSION}" version >/dev/null + + if ! npm view "@vllnt/ui-native@${CANARY_VERSION}" version >/dev/null 2>&1; then + npx --yes npm@11.18.0 publish "$NATIVE_TARBALL" --tag "$STAGING_TAG" --provenance --access public + fi + + for attempt in $(seq 1 12); do + npm view "@vllnt/ui-native@${CANARY_VERSION}" version >/dev/null 2>&1 && break + sleep 5 + done + npm view "@vllnt/ui-native@${CANARY_VERSION}" version >/dev/null + + REMOTE_MAIN_SHA="$(current_main_sha)" + test -n "$REMOTE_MAIN_SHA" + if [[ "$REMOTE_MAIN_SHA" != "$GITHUB_SHA" ]]; then + echo "::notice::Skipping canary promotion because main advanced to ${REMOTE_MAIN_SHA}" + exit 0 + fi + + restore_tag() { + local package="$1" + local previous="$2" + local tags current + tags="$(read_tags "$package")" || return 1 + current="$(tag_value "$tags" canary)" + [[ "$current" = "$previous" ]] && return 0 + if [[ -n "$current" && "$current" != "$CANARY_VERSION" ]]; then + echo "::error::Refusing to overwrite unexpected ${package} canary ${current}" + return 1 + fi + if [[ -n "$previous" ]]; then + npx --yes npm@11.18.0 dist-tag add "${package}@${previous}" canary || return 1 + else + remove_owned_tag "$package" canary "$CANARY_VERSION" || return 1 + fi + tags="$(read_tags "$package")" || return 1 + [[ "$(tag_value "$tags" canary)" = "$previous" ]] + } + + restore_pair() { + local failed=0 + restore_tag @vllnt/ui-core "$CORE_CANARY_BEFORE" || failed=1 + restore_tag @vllnt/ui-native "$NATIVE_CANARY_BEFORE" || failed=1 + return "$failed" + } + + PROMOTION_STARTED=true + npx --yes npm@11.18.0 dist-tag add "@vllnt/ui-core@${CANARY_VERSION}" canary + npx --yes npm@11.18.0 dist-tag add "@vllnt/ui-native@${CANARY_VERSION}" canary + + CORE_TAGS_AFTER="$(read_tags @vllnt/ui-core)" + NATIVE_TAGS_AFTER="$(read_tags @vllnt/ui-native)" + test "$(tag_value "$CORE_TAGS_AFTER" canary)" = "$CANARY_VERSION" + test "$(tag_value "$NATIVE_TAGS_AFTER" canary)" = "$CANARY_VERSION" + test "$(tag_value "$CORE_TAGS_AFTER" latest)" = "$CORE_LATEST_BEFORE" + test "$(tag_value "$NATIVE_TAGS_AFTER" latest)" = "$NATIVE_LATEST_BEFORE" + + PROMOTION_STARTED=false + exit 0 diff --git a/.gitignore b/.gitignore index 64433312..eeab1a2c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # Build outputs .next/ +.expo/ dist/ .turbo/ coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8480603f..1dd1fa19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Release automation can regenerate this file from Conventional Commits with ### Added +- **Cross-platform foundation** — added the framework-free `@vllnt/ui-core` token/contract package, an experimental source-only `@vllnt/ui-native` renderer with 171 foundation, form, data, content, AI, learning, motion, utility, control, overlay, and navigation modules, plus a private Expo catalog for Android/iOS Metro validation. The URL-driven registry UI, dedicated native hub, component pages, search, llms surfaces, JSON-LD, native manifest, and MCP expose truthful web/native availability. No native npm release exists yet; existing `@vllnt/ui` exports and stable publishing remain unchanged. (#479) - **Component family landing pages** - every component family has a standalone, SEO-oriented landing at `/families/[category]`, plus a `/families` index. One shared template renders a hero with CTAs, per-family SEO sub-groups with diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e60787b..dcbac219 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,8 @@ Key scripts (from repo root): | `pnpm -F @vllnt/ui test:visual` | Playwright CT visual snapshots | | `pnpm check:circular` | Fail on circular imports | | `pnpm doctor` | react-doctor React-health scan | +| `pnpm tokens:check` | Verify generated token artifacts | +| `pnpm ci:native` | Verify core/native packages and Expo bundles | A [react-doctor](https://github.com/millionco/react-doctor) **pre-commit hook** (in `.githooks/`, enabled automatically on `pnpm install`) blocks commits that @@ -58,7 +60,7 @@ with `git commit --no-verify`. See AGENTS.md → *React health* for details. ``` 2. Follow the existing patterns: - - `React.forwardRef` on every component. + - React 19 ref-as-prop support and `displayName` on every named component. - `cn()` from `src/lib/utils.ts` for class merging. - Radix primitives for accessible behavior where applicable. - CVA for variants (`class-variance-authority`). @@ -78,6 +80,25 @@ with `git commit --no-verify`. See AGENTS.md → *React health* for details. pnpm lint && pnpm test:once && pnpm -F @vllnt/ui test:visual && pnpm build ``` +For native changes, also run: + +```bash +pnpm -F @vllnt/ui-native generate:index:check +pnpm -F @vllnt/ui-native boundaries:check +pnpm -F @vllnt/ui-native pack:check +pnpm ci:native +``` + +## Adding a native component + +1. Add `packages/ui-native/src/components/{name}/{name}.tsx` using React Native core primitives, semantic theme tokens, native accessibility APIs, controlled/uncontrolled state where applicable, and caller-owned selection IDs. +2. Do not import DOM, Radix, Tailwind, NativeWind, or browser globals. Inject capabilities such as clipboard and file selection when React Native core does not provide a portable service. +3. Add the component to `packages/ui-native/registry.json` in alphabetical order with honest `portable-options` or `native-adapted` compatibility and its native source path. +4. Run `pnpm -F @vllnt/ui-native generate:index`; never hand-maintain the generated barrel. +5. Add interaction/accessibility tests and run the native checks listed below. Update the Expo catalog when the new family needs integration proof. + +Native remains source-only until the manifest reports package availability. Do not describe the planned canary command as installable before publication. + ## Code style - TypeScript **strict** via `@vllnt/typescript`. @@ -87,15 +108,9 @@ with `git commit --no-verify`. See AGENTS.md → *React health* for details. ## Releases -Releases are cut via `workflow_dispatch` on `.github/workflows/publish.yml`. Maintainers pick `patch` / `minor` / `major` and the workflow: - -1. Bumps `packages/ui/package.json`. -2. Generates release notes from commits. -3. Pushes an annotated tag `v{x.y.z}` back to `main`. -4. Publishes to the public npm registry with OIDC-signed provenance. -5. Creates the GitHub release. +Stable `@vllnt/ui` versions are prepared in a normal version-bump PR. A maintainer then dispatches `.github/workflows/publish.yml` from `main`; the workflow validates the pre-bumped version, publishes with OIDC-signed provenance, tags it, and creates the GitHub release. Web canaries publish automatically after pushes to `main`. -Canary builds ship automatically on every push to `main`. +`@vllnt/ui-core` and `@vllnt/ui-native` are experimental. `.github/workflows/native-canary.yml` publishes them as a synchronized pair only on the `canary` tag. It has no manual dispatch, stable tag, Git tag, or GitHub Release path. Enabling a stable native release requires a separate reviewed workflow change. See [docs/RELEASING.md](docs/RELEASING.md). ## Reporting bugs / requesting features diff --git a/README.md b/README.md index 7682f3ac..6b260a40 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ - **shadcn-compatible registry** — install individual components with `shadcn add` - **TypeScript strict** — fully typed with exported prop interfaces - **Tested** — unit tests (Vitest) + visual regression (Playwright CT) + Storybook +- **React Native source preview** — 171 experimental native component modules in a separate renderer with shared tokens, native accessibility, and no web runtime dependency ## Install @@ -45,6 +46,28 @@ Or by `@vllnt-ui` namespace once it's in the [shadcn registry index](https://ui. pnpm dlx shadcn@latest add @vllnt-ui/button ``` +## React Native source preview + +The experimental native renderer is separate so React DOM and Radix dependencies never enter Metro. It currently exists in repository source only; `@vllnt/ui-native` has not been published to npm. The planned command becomes actionable only after the native manifest reports package availability: + +```bash +pnpm add @vllnt/ui-native@canary +``` + +```tsx +import { Button, ThemeProvider } from "@vllnt/ui-native"; + +export function NativeExample() { + return ( + + + + ); +} +``` + +The source catalog contains 171 foundation, form, data, content, AI, learning, motion, utility, control, overlay, and navigation modules. Browse the [React Native hub](https://ui.vllnt.com/native), [React Native guide](https://ui.vllnt.com/docs/native), or [machine-readable native manifest](https://ui.vllnt.com/r/native/registry.json). `@vllnt/ui` remains the stable web renderer with its existing API and release path. + ## Quick Start ```tsx @@ -97,6 +120,8 @@ pnpm dev | `pnpm lint` | Lint all packages | | `pnpm test:once` | Run tests (single run) | | `pnpm check:circular` | Detect circular imports | +| `pnpm ci:native` | Verify core/native packages and Expo bundles | +| `pnpm tokens:check` | Check generated web/native token drift | ## Theming @@ -104,8 +129,8 @@ Override CSS variables after importing styles: ```css :root { - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; + --primary: 0.45 0.16 255; + --primary-foreground: 0.98 0 0; } ``` diff --git a/ROADMAP.md b/ROADMAP.md index 02770323..a3ca8f88 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,10 +2,10 @@ > **Goal:** the design-system foundation for building UI fast — the @vllnt/ui component registry (web + native), the `@vllnt/ui-cli` DX gate, and `@vllnt/front-studio` + `@vllnt/ui-toolbar` (verify · review · author, API-first) — for humans and agents alike. > **Now:** `component-sidebar` — finish `.5`/`.6`, then ship `@vllnt/ui@0.4.0`. -> **Next:** `agent-ui-cli` (MVP) · `ai-elements-parity`. -> **Horizon (gated):** `native-parity` (needs an RN consumer) · the front-studio line (`studio` → `studio-hub` — after the CLI ships + a real need). +> **Next:** `native-parity` pilot validation · `agent-ui-cli` (MVP) · `ai-elements-parity`. +> **Horizon (gated):** the front-studio line (`studio` → `studio-hub` — after the CLI ships + a real need). > **Visibility track:** SEO/GEO phases — `search-consolidation` → `ai-toolchain-registration` → `visibility-measurement` → `seo-content-engine` → `backlink-authority`. Diagnosis: infra is DONE (`agent-surface`) but GSC shows indexed-yet-buried (141 pages, 2 clicks/90d, 0 AI-query visibility) + a dead `.com` twin outranking the live `.ai`. Full plan: [strategy dossier](https://claude.ai/code/artifact/6e2359db-a626-4226-aa54-a9e53ecfd766). -> **Last updated:** 2026-07-02 +> **Last updated:** 2026-09-03 > **Channels:** `@latest` = `0.3.0` · `@canary` = `0.4.0-canary.` (auto-publishes on every merge to main). Tracking: [milestone 0.3.0](https://github.com/vllnt/ui/milestone/1) Convention: phases are kebab-case outcome slugs, ordered DONE → ACTIVE → PLANNED. Tasks carry stable `.` IDs; functional tasks pair with a `Validate`/`E2E` task. History is never deleted. Shipped 0.3.0 detail lives in `CHANGELOG.md` and the 197 closed issues; phases below summarize it. @@ -66,28 +66,25 @@ Single-pane drill-down (chosen over accordion-single-open and a two-pane family - [ ] component-sidebar.6 Directional slide transition with `prefers-reduced-motion` instant fallback; persist last-family + scroll (localStorage) - [~] component-sidebar.7 Validate component-sidebar.1–6: Playwright E2E (desktop + mobile + keyboard) — auto-drill, back, breadcrumb sync, global filter, ⌘K, persistence (E2E); core drill-down (`.1`/`.2`/`.4`) covered by `sidebar-drilldown.spec.ts` — pending `.5`/`.6` -## native-parity [PLANNED] - -**Goal:** Make all 309 @vllnt/ui components iso web↔native — install once from the single `ui.vllnt.com` registry, one import, platform-correct render — with no second registry. -**Exit criteria:** Every component ships a `.native.tsx` twin + a shared `.variants.ts`; one `npx shadcn add @vllnt-ui/` from `ui.vllnt.com/r/.json` installs both files; each renders correctly on a Next.js web app AND an Expo device; each is stamped `parity: full|api-only` in `meta.json` with the badge shown on the site. -**Verify:** a consumer dev runs `npx shadcn add @vllnt-ui/button` once → `import { Button }` renders on web (Radix/DOM) and on an Expo device (rn-primitives) with an identical variant API across all 12 families; the overlay family is documented `api-only` where Portal/keyboard can't map. Personas: consumer dev (web + Expo device, keyboard), maintainer (adds a native twin + variants contract), agent (reads `parity` from the registry JSON). - -**Gated by:** a confirmed Expo/RN consumer (`native-parity.1`) — **Horizon** until resolved (flagged since 2026-06; still open). - -One registry, not two — platform is resolved by the bundler (Metro picks `.native.tsx`, web picks `.tsx`), so `ui.vllnt.com` stays the single source. Stack maps 1:1: Radix → @rn-primitives, Tailwind `className` → NativeWind, CVA + `cn()` unchanged, lucide-react → lucide-react-native. Only the render body forks; `.variants.ts` (CVA + a platform-neutral prop contract) is shared. Iso *API* is always achievable; iso *visual result* is a per-component property (the `parity` badge) — overlays / hover / keyboard degrade to api-only. Proven prior art: **react-native-reusables** (@rn-primitives + NativeWind, 8.4k★, active 2026) already publishes a shadcn-format `registry.json` for RN — one schema spans web+native, validating the single-registry bet. Open gate: a confirmed Expo consumer (`.1`); OKLCH-on-native is resolved — NativeWind v4 (stable) has no on-device `oklch()`, so an HSL fallback is required (`.2`). - -- [ ] native-parity.1 Decide: confirm an Expo/RN consumer app + the native stack (NativeWind + @rn-primitives + lucide-react-native) -- [ ] native-parity.2 Emit an HSL fallback channel for the native theme (RESEARCHED 2026-07): NativeWind v4 (stable) has no on-device `oklch()` — RNR themes in HSL; native OKLCH is gated on NativeWind v5 (preview). Spike confirms the down-convert on a device; tweakcn already exports OKLCH+HSL from one source -- [ ] native-parity.3 Decide: one registry, multi-file items (`.tsx` + `.native.tsx`, bundler-resolved) — single `ui.vllnt.com`, no second namespace -- [ ] native-parity.4 Token codegen: emit the RN/NativeWind theme from `tokens.json` alongside the web CSS vars (single source) -- [ ] native-parity.5 Establish the iso pattern: extract `.variants.ts` (CVA + platform-neutral prop contract) + ship a 5-component reference set (button, input, card, badge, dialog) -- [ ] native-parity.6 Extend `apps/registry/scripts/inline-component-source.ts` to emit `.native.tsx` + the shared variants per registry item; stamp `parity: full|api-only` in `meta.json`; surface the badge on `ui.vllnt.com` -- [ ] native-parity.7 Port core + form + utility families (115) to native twins -- [ ] native-parity.8 Port data + data-display + content families (109) to native twins -- [ ] native-parity.9 Port navigation + learning + educational + billing + ai families (70) to native twins -- [ ] native-parity.10 Port overlay family (15) — api-only parity where Portal/keyboard can't map; document the degradation -- [ ] native-parity.11 Validate native-parity.5–10: one `shadcn add @vllnt-ui/` from `ui.vllnt.com` → identical import renders on Next web + Expo device across all 12 families; parity badges accurate (E2E) -- [ ] native-parity.12 Validate native-parity.4: an off-token native color fails; `tokens.json` stays the sole source across web CSS + RN theme (E2E) +## native-parity [ACTIVE — experimental source preview] + +**Goal:** Add a platform-correct React Native renderer without changing the stable `@vllnt/ui` web contract. Shared tokens and semantic option contracts live in framework-free `@vllnt/ui-core`; implementations remain separate in `@vllnt/ui` and `@vllnt/ui-native`. +**Exit criteria:** the canonical token source generates unchanged web CSS and native-safe values; an Expo catalog bundles the native source catalog on Android and iOS; registry JSON, docs, search, and MCP expose renderer availability; native packages can publish synchronized canaries but cannot publish `latest`. +**Verify:** existing web gates and exports stay green; `pnpm ci:native` passes contract tests, package boundaries, generated export/manifest drift, Expo Doctor, and both Metro exports; `/components?platform=native`, `/r/native/registry.json`, and `search_components({ platform: "native" })` return the same catalog set. + +The earlier co-located `.native.tsx` proposal is superseded by the package boundary tracked in #479. Separate renderers prevent DOM/Radix dependencies from entering Metro and let native APIs use `onPress`, `style`, and native accessibility semantics. The shared layer contains data and portable option names, not renderer props. Foundational native components use React Native primitives and `StyleSheet`; NativeWind and `@rn-primitives` remain possible adapters for later complex families when a demonstrated need justifies their consumer configuration and runtime cost. + +- [x] native-parity.1 Establish `@vllnt/ui-core` and separate `@vllnt/ui-native` package boundaries while keeping `@vllnt/ui` dependency-free from canary packages +- [x] native-parity.2 Generate web CSS and native sRGB/point tokens from `packages/design/tokens.json`; fail CI on drift +- [x] native-parity.3 Define portable Button, Text, Heading, Badge, and Card contracts and verify web compatibility at compile time +- [x] native-parity.4 Ship the initial five-component React Native slice plus light/dark/system theme support and an Expo catalog +- [x] native-parity.5 Add explicit `platforms`, compatibility, source, and availability metadata across registry JSON, docs, search, JSON-LD, llms surfaces, and MCP +- [x] native-parity.6 Add native quality gates and a separate synchronized canary-only workflow with no stable publish path +- [~] native-parity.7 Validate the renderer on CI and physical Expo devices; keep native source-only and experimental until publication plus Android/iOS/VoiceOver/TalkBack gates pass +- [x] native-parity.8 Expand the source catalog to 171 foundation, form, data, content, AI, learning, motion, utility, control, overlay, and navigation modules +- [x] native-parity.9 Add native interaction infrastructure and adapters only where platform behavior requires them +- [ ] native-parity.10 Complete physical-device and assistive-technology validation, then enable the first synchronized canary without moving `latest` +- [ ] native-parity.11 Define stable-version policy and migration notes in a separately reviewed release change ## typography-primitives [DONE 2026-07] diff --git a/apps/native-catalog/App.test.tsx b/apps/native-catalog/App.test.tsx new file mode 100644 index 00000000..4d57782f --- /dev/null +++ b/apps/native-catalog/App.test.tsx @@ -0,0 +1,25 @@ +import { fireEvent, render, screen } from "@testing-library/react-native"; + +import App from "./App"; + +describe("native catalog", () => { + it("renders the source catalog and proves interaction", () => { + render(); + + expect(screen.getByText("VLLNT UI Native")).toBeOnTheScreen(); + expect(screen.getByText("Renderer boundary")).toBeOnTheScreen(); + expect(screen.getByText("Interactive composites")).toBeOnTheScreen(); + expect(screen.getByText("1 of 2 modules reviewed")).toBeOnTheScreen(); + expect(screen.getByText("Interaction count: 0")).toBeOnTheScreen(); + + fireEvent.press( + screen.getByRole("checkbox", { + name: "Complete Native accessibility", + }), + ); + fireEvent.press(screen.getByRole("button", { name: "Try interaction" })); + + expect(screen.getByText("Catalog review complete")).toBeOnTheScreen(); + expect(screen.getByText("Interaction count: 1")).toBeOnTheScreen(); + }); +}); diff --git a/apps/native-catalog/App.tsx b/apps/native-catalog/App.tsx new file mode 100644 index 00000000..274653ab --- /dev/null +++ b/apps/native-catalog/App.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; + +import { + Heading, + Text, + ThemeProvider, + type ThemeSelection, + useTheme, +} from "@vllnt/ui-native"; +import { StatusBar } from "expo-status-bar"; +import { ScrollView, View } from "react-native"; + +import { + CardSection, + CompositeSection, + DataSection, + FormSection, + FoundationSection, + NavigationSection, + OverlaySection, + ThemeSection, +} from "./catalog-sections"; + +function CatalogContent({ + onThemeChange, + themeSelection, +}: { + readonly onThemeChange: (selection: ThemeSelection) => void; + readonly themeSelection: ThemeSelection; +}) { + const theme = useTheme(); + const [presses, setPresses] = useState(0); + const [alertsEnabled, setAlertsEnabled] = useState(true); + const incrementPresses = () => { + setPresses((value) => value + 1); + }; + + return ( + + + + + + VLLNT UI Native + + + Source-only experimental renderer · 171 native component modules. + + + + + + + + + + + + + ); +} +CatalogContent.displayName = "CatalogContent"; + +export default function App() { + const [themeSelection, setThemeSelection] = + useState("system"); + + return ( + + + + ); +} diff --git a/apps/native-catalog/README.md b/apps/native-catalog/README.md new file mode 100644 index 00000000..49b9a013 --- /dev/null +++ b/apps/native-catalog/README.md @@ -0,0 +1,15 @@ +# VLLNT UI native catalog + +Private Expo integration consumer for the experimental, source-only `@vllnt/ui-native` renderer. It exercises representative foundation, form, data, feedback, navigation, overlay, AI, learning, motion, and theme surfaces from the package barrel. + +```bash +pnpm -F @vllnt/ui-native-catalog dev +pnpm -F @vllnt/ui-native-catalog lint +pnpm -F @vllnt/ui-native-catalog typecheck +pnpm -F @vllnt/ui-native-catalog test:once +pnpm -F @vllnt/ui-native-catalog build +``` + +The build exports Android and iOS JavaScript bundles in CI. This validates Expo/Metro workspace resolution without publishing or requiring a simulator. It does not replace physical Android/iOS, VoiceOver, or TalkBack validation. + +`@vllnt/ui-native` is not available from npm yet. The catalog consumes repository source and must stay aligned with `packages/ui-native/registry.json` and the generated package barrel. diff --git a/apps/native-catalog/app.json b/apps/native-catalog/app.json new file mode 100644 index 00000000..50c67bb1 --- /dev/null +++ b/apps/native-catalog/app.json @@ -0,0 +1,16 @@ +{ + "expo": { + "name": "VLLNT UI Native Catalog", + "slug": "vllnt-ui-native-catalog", + "version": "0.1.0", + "orientation": "portrait", + "userInterfaceStyle": "automatic", + "android": { + "package": "com.vllnt.uinativecatalog" + }, + "ios": { + "bundleIdentifier": "com.vllnt.uinativecatalog", + "supportsTablet": true + } + } +} diff --git a/apps/native-catalog/babel.config.cjs b/apps/native-catalog/babel.config.cjs new file mode 100644 index 00000000..54751c2d --- /dev/null +++ b/apps/native-catalog/babel.config.cjs @@ -0,0 +1,3 @@ +module.exports = { + presets: ["babel-preset-expo"], +}; diff --git a/apps/native-catalog/catalog-sections.tsx b/apps/native-catalog/catalog-sections.tsx new file mode 100644 index 00000000..3802d112 --- /dev/null +++ b/apps/native-catalog/catalog-sections.tsx @@ -0,0 +1,322 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + AIChatInput, + Alert, + AlertDescription, + AlertTitle, + Avatar, + AvatarFallback, + Badge, + Banner, + Button, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + Checkbox, + Checklist, + DataList, + Dialog, + EmptyState, + Heading, + Input, + NumberTicker, + ProgressBar, + SearchBar, + Separator, + Spinner, + StatusIndicator, + Switch, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + Text, + Textarea, + type ThemeSelection, + useTheme, +} from "@vllnt/ui-native"; +import type { ReactNode } from "react"; +import { View } from "react-native"; + +const themeSelections: readonly ThemeSelection[] = ["system", "light", "dark"]; + +function Row({ children }: { readonly children: ReactNode }) { + const theme = useTheme(); + return ( + + {children} + + ); +} +Row.displayName = "Row"; + +function Section({ + children, + title, +}: { + readonly children: ReactNode; + readonly title: string; +}) { + const theme = useTheme(); + return ( + + + {title} + + {children} + + ); +} +Section.displayName = "Section"; + +export function ThemeSection({ + onChange, + selection, +}: { + readonly onChange: (selection: ThemeSelection) => void; + readonly selection: ThemeSelection; +}) { + return ( +
+ + {themeSelections.map((value) => ( + + ))} + +
+ ); +} + +export function FoundationSection({ + onPress, + presses, +}: { + readonly onPress: () => void; + readonly presses: number; +}) { + return ( +
+ + + Experimental + + + AL + + + + + + Native semantic tokens + + Interaction count: {presses} + + +
+ ); +} + +export function FormSection({ + enabled, + onEnabledChange, +}: { + readonly enabled: boolean; + readonly onEnabledChange: (value: boolean) => void; +}) { + return ( +
+ +