From 2c7887ba935775e44536ec3ff1a0fbbd60fda3f7 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Thu, 23 Apr 2026 11:47:26 -0700 Subject: [PATCH 1/3] docs(plugin): add phase 1 extraction + registry bootstrap spec (#2243) Resolves the design gap where the parent registry spec assumes six author-owned repos but today only two exist (both as legacy 3.x plugins). Phase 1 now extracts four monorepo packages into their own repos, bootstraps the wheels-packages registry with those four seeds, and removes the packages/ directory from the monorepo. seo-suite and i18n tracked as separate 3.x->4.0 conversion issues. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...heels-packages-phase1-extraction-design.md | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-23-wheels-packages-phase1-extraction-design.md diff --git a/docs/superpowers/specs/2026-04-23-wheels-packages-phase1-extraction-design.md b/docs/superpowers/specs/2026-04-23-wheels-packages-phase1-extraction-design.md new file mode 100644 index 0000000000..c98b1f83b7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-wheels-packages-phase1-extraction-design.md @@ -0,0 +1,207 @@ +# Wheels Packages Registry — Phase 1 Extraction & Bootstrap + +**Date:** 2026-04-23 +**Status:** Draft — awaiting user approval +**Relates to:** [#2243](https://github.com/wheels-dev/wheels/issues/2243) (Phase 1 specifically), [#2244](https://github.com/wheels-dev/wheels/issues/2244) (guides, sequenced after this), [#2249](https://github.com/wheels-dev/wheels/issues/2249) (sequenced after #2244) +**Parent spec:** `2026-04-22-wheels-packages-registry-design.md` (on branch `claude/infallible-davinci-58997d`, not yet on `develop`) +**Audit:** `2026-04-22-v4-ga-architectural-review.md` P4 + +--- + +## Purpose + +The parent registry spec assumes every first-party package lives in a standalone author-owned repo with SemVer tags. Today's reality differs: + +- Four "first-party packages" (`sentry`, `hotwire`, `basecoat`, `legacyadapter`) are inline in this monorepo's `packages/` directory with no external repo. +- Two existing external repos (`wheels-seo-suite`, `wheels-i18n`) are Wheels 3.x plugins, not 4.0 packages — they have `box.json`, no `package.json`, no `wheelsVersion`, no `tests/`. They'd fail the registry's `validate.yml`. + +Result: Phase 1 as written can't seed anything. This spec resolves that. + +## Scope + +This phase delivers the registry bootstrap (#2243 Phase 1) plus the repo extractions required to make seeding actually work. + +**In scope:** + +1. Extract four monorepo packages to standalone `wheels-dev/` repos with preserved git history. +2. Tag each extracted repo at `v1.0.0`; cut GH Releases. +3. Create `wheels-dev/wheels-packages` registry repo with: schema, CI validation workflow, tarball-mirror workflow scaffold, seed manifests for the four extracted packages, CONTRIBUTING, README. +4. Remove `packages/` directory from the monorepo (D1-c: 4.0 is pre-release, impact minimal). +5. Remove `packages/` per-package test job from `.github/workflows/compat-matrix.yml`; each extracted repo tests itself. +6. Update in-repo docs that reference `packages/...` to point at either `vendor/...` (activated state) or the extracted repos' GitHub URLs. +7. Open follow-up issues for `wheels-seo-suite` and `wheels-i18n` 3.x → 4.0 package conversion. + +**Out of scope (deferred to later phases of #2243):** + +- `mirror-tarball.yml` implementation (structure scaffolded, logic deferred to Phase 2). +- `wheels packages list|install|update|remove` CLI commands (Phase 3). +- Web UI at `wheels.dev/packages` and `/wheels/packages` (Phase 4). +- 3.x → 4.0 conversion of seo-suite and i18n. +- P9 checksum-code removal from `PackageLoader.cfc` (separate audit item). + +## Decisions + +| # | Decision | Choice | Rationale | +|---|---|---|---| +| D1 | `packages/` in monorepo after extraction | **Delete** | 4.0 is pre-release; `packages/` introduced in 4.0 itself — no long-lived users to break. | +| D2 | Starting version on extracted repos | **`v1.0.0` across all four** | Registry publication is the real "1.0" milestone. Pre-registry in-monorepo versions (0.1.0 / 1.0.0 mix) are meaningless externally. | +| D3 | Git history preservation | **`git subtree split`** | Shallow history (5–8 commits per package); trivial to preserve author/date metadata. | +| D4 | `wheels-seo-suite` / `wheels-i18n` handling | **Drop from Phase 1 seed; follow-up issues** | Both are 3.x plugins requiring real conversion work (author `package.json`, port `index.cfm` lifecycle, add tests). That work doesn't belong in a bootstrap phase. | +| D5 | Authorization | **Granted** | User explicitly confirmed `gh repo create` + push + tag + release across five new repos. | + +## Architecture + +### Repos after this phase + +``` +wheels-dev/ +├── wheels ← monorepo (packages/ removed) +├── wheels-packages ← NEW registry +├── wheels-sentry ← NEW, extracted from packages/sentry +├── wheels-hotwire ← NEW, extracted from packages/hotwire +├── wheels-basecoat ← NEW, extracted from packages/basecoat +├── wheels-legacy-adapter ← NEW, extracted from packages/legacyadapter +├── wheels-seo-suite ← unchanged (follow-up conversion) +└── wheels-i18n ← unchanged (follow-up conversion) +``` + +### Per extracted repo, the delivered state + +``` +wheels-/ +├── LICENSE (Apache 2.0, matching wheels parent) +├── README.md (existing package README) +├── CLAUDE.md (existing, if present) +├── package.json (existing manifest; version bumped to 1.0.0, wheelsVersion → ">=4.0") +├── box.json (existing — kept for legacy CommandBox users) +├── .cfc (main entry) +├── index.cfm (existing bootstrap) +└── tests/ (existing suite) +``` + +Tag `v1.0.0` on `main` at extraction commit, then GH Release cut with a stock "Initial release from Wheels monorepo extraction" body. + +### `wheels-packages` registry layout + +``` +wheels-packages/ +├── LICENSE Apache 2.0 +├── README.md registry purpose, install pattern, link to CONTRIBUTING +├── CONTRIBUTING.md submission workflow, review criteria +├── schema/ +│ └── manifest.schema.json JSONSchema — enforced by validate.yml +├── packages/ +│ ├── wheels-sentry/ +│ │ ├── manifest.json seed entry with v1.0.0 +│ │ └── README.md listing blurb (one paragraph) +│ ├── wheels-hotwire/ +│ ├── wheels-basecoat/ +│ └── wheels-legacy-adapter/ +└── .github/ + └── workflows/ + ├── validate.yml schema + name uniqueness + tag resolvability + file-type allowlist + size cap + └── mirror-tarball.yml SCAFFOLD (runs no-op for now, logic in Phase 2) +``` + +### Seed manifest shape + +```json +{ + "name": "wheels-sentry", + "description": "Sentry error tracking for Wheels with framework-aware context enrichment", + "homepage": "https://github.com/wheels-dev/wheels-sentry", + "documentation": "https://wheels.dev/packages/wheels-sentry", + "license": "Apache-2.0", + "maintainers": ["@bpamiri"], + "tags": ["monitoring", "errors", "observability"], + "source": { + "type": "github", + "repo": "wheels-dev/wheels-sentry" + }, + "versions": [ + { + "version": "1.0.0", + "publishedAt": "2026-04-23T00:00:00Z", + "wheelsVersion": ">=4.0", + "sourceTag": "v1.0.0", + "tarball": null, + "sha256": null + } + ] +} +``` + +`tarball` and `sha256` remain `null` in Phase 1. Phase 2's `mirror-tarball.yml` will fill them on merge. The JSONSchema accepts null for both at Phase 1; Phase 2 tightens it to require both once the mirror workflow ships. + +### `validate.yml` responsibilities (Phase 1) + +- JSON schema check against `schema/manifest.schema.json`. +- Manifest `name` matches parent directory name under `packages/`. +- `name` globally unique across the registry. +- `source.repo` resolves via GitHub API. +- `versions[*].sourceTag` resolves on `source.repo`. +- Clone author repo at tag → validate `package.json` present, `name`/`version` match manifest, `wheelsVersion` present. +- File-type allowlist: `.cfc .cfm .cfml .md .json .js .mjs .ts .css .scss .html .txt .sql .yml .yaml .gitkeep`. Anything else → PR comment flagging it for reviewer justification. +- Size cap: 10 MB uncompressed. +- Basic smell checks: no shell-out or process-invocation tags in shipped code without a reviewer annotation. + +`mirror-tarball.yml` is stubbed (a workflow file with a single `echo "Phase 2 — not yet implemented"` step) so the referenced workflow file exists and the registry README can point at it. + +### Monorepo cleanup (`wheels` repo PR) + +Single PR on `develop`: + +- Delete `packages/`. +- Remove the "Run per-package tests" step from `.github/workflows/compat-matrix.yml` (lines ~429–500). +- Update docs that reference `packages/`: + - `web/sites/guides/src/content/docs/v4-0-0-snapshot/digging-deeper/packages.mdx` — rewrite activation examples to install from `wheels-packages` registry; keep the manifest-schema and lifecycle sections. + - `CLAUDE.md` — Package System section: drop `packages/` staging concept; redirect to registry URL; keep `vendor/` activation model. + - Tutorial and upgrade docs that mention `cp -r packages/...` — replace with the future `wheels packages install` idiom marked "coming in 4.1." + - `cli/lucli/templates/app/app/plugins/README.md` — remove `cp -r packages/hotwire ...` reference. +- `CHANGELOG` / release notes entry. + +### Follow-up issues opened at end of phase + +- `wheels-seo-suite: convert to 4.0 package format (package.json, lifecycle, tests)` +- `wheels-i18n: convert to 4.0 package format (package.json, lifecycle, tests)` +- `wheels-packages Phase 2 — tarball mirror CI` (child of #2243) +- `wheels-packages Phase 3 — CLI commands` (child of #2243) +- `wheels-packages Phase 4 — web UI` (child of #2243) + +## Execution sequence + +1. **Stage locally (no public actions).** Under `/tmp/wheels-packages-staging/` create five git repos (four extractions via `git subtree split` + registry). Populate all files. Print a tree-view summary. +2. **User review gate.** Present local state to user. On approval only, continue. +3. **Push pass A — extractions.** For each of four repos: `gh repo create wheels-dev/ --public --source=. --push`. Then `git tag v1.0.0 && git push --tags`. Then `gh release create v1.0.0`. +4. **Push pass B — registry.** `gh repo create wheels-dev/wheels-packages --public --source=. --push`. Confirm `validate.yml` runs green on the four seed manifests. +5. **Monorepo PR.** Branch `peter/2243-phase1-remove-packages-dir` off `develop`. Delete `packages/`, rewrite `compat-matrix.yml`, update docs. Open PR with link to this spec. +6. **Spec + follow-up issues.** Commit this spec and the parent design spec (if not already merged) to `develop`. Open the five follow-up issues listed above. + +## Risks and mitigations + +| Risk | Mitigation | +|---|---| +| Extracted repo missing a file (e.g., LICENSE not previously per-package) | Pre-push staging review; add LICENSE explicitly. | +| `git subtree split` preserves too little (author attribution only, no cross-package commits) | Acceptable; alternative (`git filter-repo`) adds tooling dep without material benefit at this history depth. | +| `validate.yml` fails on seed manifests because `source.tag` not yet pushed | Sequence enforces: extraction push + tag happens *before* registry push. | +| Monorepo PR breaks CI by removing the per-package test step while some dev branches still assume `packages/` exists | Announce in #it_builds; merge window after hours. All active branches already build clean without the packages step (it's a `continue`-on-missing-tests loop). | +| `packages/` removal confuses 4.0-snapshot users who downloaded the nightly | Upgrade-guide note; dev-only tarballs. 4.0 hasn't shipped as a release — all known users are internal. | +| Extracted repos diverge from monorepo versions (future bug fixes) | Out of scope for Phase 1. Owner-of-record for each extracted repo is documented in its README. | + +## Acceptance criteria + +- Five new repos live, public, buildable: + - `wheels-dev/wheels-sentry`, `wheels-hotwire`, `wheels-basecoat`, `wheels-legacy-adapter` each at `v1.0.0` with a GH Release. + - `wheels-dev/wheels-packages` with passing `validate.yml` on all four seed manifests. +- Monorepo PR merged on `develop`: + - `packages/` gone. + - `compat-matrix.yml` per-package step removed. + - Docs updated; no remaining broken links to `packages/`. +- Five follow-up issues filed and cross-linked. +- This spec plus the parent `2026-04-22-wheels-packages-registry-design.md` both present on `develop`. + +## Unresolved questions + +- Maintainer handle in seed manifests: `@bpamiri` or `@wheels-dev` team handle? (Defaulting to `@bpamiri` unless told otherwise.) +- `documentation` URL field for manifests — `wheels.dev/packages/` does not yet render (blocked on Phase 4). Leave populated anyway so links activate automatically when Phase 4 ships? +- Should the extracted repos carry CHANGELOG.md? (Lightweight; easy to add. Default: yes, minimal entry.) From dfd01e3c9ea5171ebbf59e1473a2b394f4041d21 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Thu, 23 Apr 2026 11:55:16 -0700 Subject: [PATCH 2/3] docs(plugin): add phase 1 extraction + registry bootstrap plan (#2243) Bite-sized implementation plan for the spec at docs/superpowers/specs/2026-04-23-wheels-packages-phase1-extraction-design.md. 9 tasks covering: staging workspace + four git subtree extractions, normalization, registry scaffold with schema + validate.yml + seeds, user review gate, push + tag + release across 5 repos, monorepo cleanup PR, and 5 follow-up issues. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...04-23-wheels-packages-phase1-extraction.md | 1447 +++++++++++++++++ 1 file changed, 1447 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-23-wheels-packages-phase1-extraction.md diff --git a/docs/superpowers/plans/2026-04-23-wheels-packages-phase1-extraction.md b/docs/superpowers/plans/2026-04-23-wheels-packages-phase1-extraction.md new file mode 100644 index 0000000000..94e13e5026 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-wheels-packages-phase1-extraction.md @@ -0,0 +1,1447 @@ +# Wheels Packages Phase 1 — Extraction & Registry Bootstrap + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract four monorepo packages (sentry, hotwire, basecoat, legacy-adapter) into standalone `wheels-dev/*` repos with preserved git history, bootstrap a new `wheels-dev/wheels-packages` registry seeded with those four, and remove `packages/` from the monorepo. + +**Architecture:** Use `git subtree split` to produce history-preserving branches per package, create new standalone repos, tag `v1.0.0`, cut GH Releases. Registry is a plain git repo with JSONSchema-enforced manifests + a `validate.yml` GH Actions workflow. Monorepo cleanup lands as a single PR against `develop`. + +**Tech Stack:** bash, `git`, `gh` CLI, GitHub Actions, JSONSchema (via `ajv-cli`), Apache 2.0 license. + +**Spec:** [`docs/superpowers/specs/2026-04-23-wheels-packages-phase1-extraction-design.md`](../specs/2026-04-23-wheels-packages-phase1-extraction-design.md) + +**Refs:** [#2243](https://github.com/wheels-dev/wheels/issues/2243) (Phase 1) + +--- + +## Prerequisites + +- `gh auth status` shows logged in to `bpamiri` account with write access to `wheels-dev/` org +- Working directory: `/Users/peter/GitHub/wheels-dev/wheels/.claude/worktrees/fervent-jepsen-e89804` +- Clean working tree (spec already committed at `2c7887ba9`) +- Staging area: `/tmp/wheels-packages-staging/` (will be created in Task 1) +- All implementation work happens on branch `claude/fervent-jepsen-e89804` (current branch) + +## File Structure + +### New files (staging — `/tmp/wheels-packages-staging/`) + +``` +/tmp/wheels-packages-staging/ +├── wheels-sentry/ git repo (history-preserved from monorepo) +├── wheels-hotwire/ git repo (history-preserved from monorepo) +├── wheels-basecoat/ git repo (history-preserved from monorepo) +├── wheels-legacy-adapter/ git repo (history-preserved from monorepo) +└── wheels-packages/ git repo (new registry) + ├── LICENSE + ├── README.md + ├── CONTRIBUTING.md + ├── schema/manifest.schema.json + ├── packages/wheels-sentry/manifest.json + ├── packages/wheels-sentry/README.md + ├── packages/wheels-hotwire/manifest.json + ├── packages/wheels-hotwire/README.md + ├── packages/wheels-basecoat/manifest.json + ├── packages/wheels-basecoat/README.md + ├── packages/wheels-legacy-adapter/manifest.json + ├── packages/wheels-legacy-adapter/README.md + └── .github/workflows/validate.yml + └── .github/workflows/mirror-tarball.yml (stub) +``` + +### Modified files (monorepo) + +- Delete: `packages/` (entire directory) +- Modify: `.github/workflows/compat-matrix.yml` (remove "Run per-package tests" step, lines ~429–500) +- Modify: `CLAUDE.md` (Package System section) +- Modify: `web/sites/guides/src/content/docs/v4-0-0-snapshot/digging-deeper/packages.mdx` +- Modify: `web/sites/guides/src/content/docs/v4-0-0-snapshot/start-here/tutorial/03-crud-scaffold.mdx` +- Modify: `web/sites/guides/src/content/docs/v4-0-0-snapshot/upgrading/3x-to-4x.mdx` +- Modify: `web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/observability-and-logging.mdx` +- Modify: `cli/lucli/templates/app/app/plugins/README.md` + +### Testing approach + +This work is infrastructure, not application code — there's no unit test to write. Verification is: + +- **Per extracted repo:** `git log --oneline` shows preserved history; `cat package.json` shows version `1.0.0`, `wheelsVersion` `>=4.0`; `ls` shows expected files; test suite runs via the Wheels rig after activation. +- **Registry:** `ajv validate` runs against all four seed manifests locally before push; GH Actions `validate.yml` runs green on first push. +- **Monorepo PR:** `.github/workflows/compat-matrix.yml` passes; documentation renders (astro build); no dead links to `packages/` paths. + +--- + +## Task 1: Create local staging workspace & extract first package (sentry) + +**Files:** +- Create: `/tmp/wheels-packages-staging/` (directory) +- Create: `/tmp/wheels-packages-staging/wheels-sentry/` (git repo via subtree split) + +- [ ] **Step 1: Create staging directory** + +```bash +rm -rf /tmp/wheels-packages-staging +mkdir -p /tmp/wheels-packages-staging +``` + +Expected: directory created, empty. + +- [ ] **Step 2: Produce a history-preserving branch for packages/sentry from the monorepo** + +From the wheels monorepo working directory (`/Users/peter/GitHub/wheels-dev/wheels/.claude/worktrees/fervent-jepsen-e89804`): + +```bash +cd /Users/peter/GitHub/wheels-dev/wheels/.claude/worktrees/fervent-jepsen-e89804 +git subtree split --prefix=packages/sentry -b split/wheels-sentry +``` + +Expected: outputs a commit SHA (the tip of the history-preserved branch). The working tree is unchanged. + +- [ ] **Step 3: Clone that branch into the staging dir as a standalone repo** + +```bash +git clone -b split/wheels-sentry . /tmp/wheels-packages-staging/wheels-sentry +cd /tmp/wheels-packages-staging/wheels-sentry +git remote remove origin +git log --oneline | head -10 +``` + +Expected: `wheels-sentry/` directory is a git repo with history from `packages/sentry/` only (no monorepo parent commits). 5 commits. + +- [ ] **Step 4: Delete the temporary split branch from the monorepo** + +```bash +cd /Users/peter/GitHub/wheels-dev/wheels/.claude/worktrees/fervent-jepsen-e89804 +git branch -D split/wheels-sentry +``` + +Expected: branch deleted, no trace in the monorepo. + +- [ ] **Step 5: Verify extracted repo contents** + +```bash +cd /tmp/wheels-packages-staging/wheels-sentry +ls +``` + +Expected output includes: `Sentry.cfc`, `SentryClient.cfc`, `README.md`, `CLAUDE.md`, `box.json`, `index.cfm`, `package.json`, `tests/`. + +- [ ] **Step 6: Commit progress note (no code change yet — extraction only)** + +No commit in this task — the extracted repo content is exactly what was in the monorepo. Normalization happens in Task 3. + +--- + +## Task 2: Extract remaining three packages + +**Files:** +- Create: `/tmp/wheels-packages-staging/wheels-hotwire/` +- Create: `/tmp/wheels-packages-staging/wheels-basecoat/` +- Create: `/tmp/wheels-packages-staging/wheels-legacy-adapter/` + +- [ ] **Step 1: Extract hotwire** + +```bash +cd /Users/peter/GitHub/wheels-dev/wheels/.claude/worktrees/fervent-jepsen-e89804 +git subtree split --prefix=packages/hotwire -b split/wheels-hotwire +git clone -b split/wheels-hotwire . /tmp/wheels-packages-staging/wheels-hotwire +cd /tmp/wheels-packages-staging/wheels-hotwire && git remote remove origin +cd /Users/peter/GitHub/wheels-dev/wheels/.claude/worktrees/fervent-jepsen-e89804 +git branch -D split/wheels-hotwire +``` + +Expected: `/tmp/wheels-packages-staging/wheels-hotwire` git repo with 8 commits preserved. + +- [ ] **Step 2: Extract basecoat** + +```bash +cd /Users/peter/GitHub/wheels-dev/wheels/.claude/worktrees/fervent-jepsen-e89804 +git subtree split --prefix=packages/basecoat -b split/wheels-basecoat +git clone -b split/wheels-basecoat . /tmp/wheels-packages-staging/wheels-basecoat +cd /tmp/wheels-packages-staging/wheels-basecoat && git remote remove origin +cd /Users/peter/GitHub/wheels-dev/wheels/.claude/worktrees/fervent-jepsen-e89804 +git branch -D split/wheels-basecoat +``` + +Expected: 8 commits. + +- [ ] **Step 3: Extract legacy-adapter (note dir rename from `legacyadapter` → `wheels-legacy-adapter`)** + +```bash +cd /Users/peter/GitHub/wheels-dev/wheels/.claude/worktrees/fervent-jepsen-e89804 +git subtree split --prefix=packages/legacyadapter -b split/wheels-legacy-adapter +git clone -b split/wheels-legacy-adapter . /tmp/wheels-packages-staging/wheels-legacy-adapter +cd /tmp/wheels-packages-staging/wheels-legacy-adapter && git remote remove origin +cd /Users/peter/GitHub/wheels-dev/wheels/.claude/worktrees/fervent-jepsen-e89804 +git branch -D split/wheels-legacy-adapter +``` + +Expected: 6 commits. + +- [ ] **Step 4: Verify all four extractions** + +```bash +for d in sentry hotwire basecoat legacy-adapter; do + echo "=== wheels-$d ===" + cd /tmp/wheels-packages-staging/wheels-$d + git log --oneline | wc -l + ls package.json README.md 2>&1 +done +``` + +Expected: each repo prints commit count (5/8/8/6), shows `package.json` and `README.md` present. + +--- + +## Task 3: Normalize extracted repos (version bump, LICENSE, wheelsVersion, CHANGELOG) + +Each extracted repo needs: version `1.0.0`, `wheelsVersion` `>=4.0`, Apache 2.0 `LICENSE`, minimal `CHANGELOG.md`. The homepage/author fields in `box.json` also need updating (sentry currently says `paiindustries/sentry-for-wheels`). + +**Files per extracted repo:** +- Create: `LICENSE` +- Create: `CHANGELOG.md` +- Modify: `package.json` (version, wheelsVersion) +- Modify: `box.json` (homepage, author, version) + +- [ ] **Step 1: Copy LICENSE from wheels monorepo into each extracted repo** + +```bash +for d in sentry hotwire basecoat legacy-adapter; do + cp /Users/peter/GitHub/wheels-dev/wheels/.claude/worktrees/fervent-jepsen-e89804/LICENSE \ + /tmp/wheels-packages-staging/wheels-$d/LICENSE +done +``` + +Expected: each repo has an identical Apache 2.0 LICENSE file. + +- [ ] **Step 2: Write package.json normalizer script** + +Create `/tmp/wheels-packages-staging/normalize.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +for d in sentry hotwire basecoat legacy-adapter; do + repo_dir="/tmp/wheels-packages-staging/wheels-$d" + pkg_json="$repo_dir/package.json" + + # Bump version to 1.0.0 and wheelsVersion to >=4.0 + python3 -c " +import json +with open('$pkg_json') as f: + pkg = json.load(f) +pkg['version'] = '1.0.0' +pkg['wheelsVersion'] = '>=4.0' +with open('$pkg_json', 'w') as f: + json.dump(pkg, f, indent=4) + f.write('\n') +" + echo "Normalized $pkg_json" +done +``` + +Make executable and run: + +```bash +chmod +x /tmp/wheels-packages-staging/normalize.sh +/tmp/wheels-packages-staging/normalize.sh +``` + +Expected: each `package.json` has `"version": "1.0.0"` and `"wheelsVersion": ">=4.0"`. + +- [ ] **Step 3: Verify normalization** + +```bash +for d in sentry hotwire basecoat legacy-adapter; do + echo "=== wheels-$d/package.json ===" + grep -E '"version"|"wheelsVersion"' /tmp/wheels-packages-staging/wheels-$d/package.json +done +``` + +Expected: all four show `"version": "1.0.0"` and `"wheelsVersion": ">=4.0"`. + +- [ ] **Step 4: Update box.json homepage/version for sentry (points at old paiindustries URL)** + +```bash +python3 -c " +import json +path = '/tmp/wheels-packages-staging/wheels-sentry/box.json' +with open(path) as f: b = json.load(f) +b['homepage'] = 'https://github.com/wheels-dev/wheels-sentry' +b['version'] = '1.0.0' +with open(path, 'w') as f: json.dump(b, f, indent=4); f.write('\n') +" +grep -E '"homepage"|"version"' /tmp/wheels-packages-staging/wheels-sentry/box.json +``` + +Expected: homepage points at `wheels-dev/wheels-sentry`, version `1.0.0`. + +- [ ] **Step 5: Bump box.json version on other three** + +```bash +for d in hotwire basecoat legacy-adapter; do + python3 -c " +import json +path = '/tmp/wheels-packages-staging/wheels-$d/box.json' +with open(path) as f: b = json.load(f) +b['version'] = '1.0.0' +with open(path, 'w') as f: json.dump(b, f, indent=4); f.write('\n') +" +done +``` + +Expected: each box.json shows `"version": "1.0.0"`. + +- [ ] **Step 6: Write minimal CHANGELOG.md per repo** + +```bash +for d in sentry hotwire basecoat legacy-adapter; do + cat > /tmp/wheels-packages-staging/wheels-$d/CHANGELOG.md <=4.0 (registry era begins at Wheels 4.1) +- Add Apache 2.0 LICENSE (previously inherited from monorepo) +- Add CHANGELOG with extraction note +- Update box.json homepage to wheels-dev org (sentry only) + +Extracted from wheels-dev/wheels via git subtree split. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +done +``` + +Expected: each repo has one new commit on top of the preserved history. + +--- + +## Task 4: Stage wheels-packages registry repo locally + +**Files:** +- Create: `/tmp/wheels-packages-staging/wheels-packages/` (git repo) +- Create: `LICENSE`, `README.md`, `CONTRIBUTING.md` +- Create: `schema/manifest.schema.json` +- Create: `packages/wheels-{sentry,hotwire,basecoat,legacy-adapter}/manifest.json` +- Create: `packages/wheels-{sentry,hotwire,basecoat,legacy-adapter}/README.md` +- Create: `.github/workflows/validate.yml` +- Create: `.github/workflows/mirror-tarball.yml` (stub) + +- [ ] **Step 1: Initialize repo** + +```bash +mkdir -p /tmp/wheels-packages-staging/wheels-packages +cd /tmp/wheels-packages-staging/wheels-packages +git init -b main +``` + +Expected: empty repo on branch `main`. + +- [ ] **Step 2: Write LICENSE** + +```bash +cp /Users/peter/GitHub/wheels-dev/wheels/.claude/worktrees/fervent-jepsen-e89804/LICENSE \ + /tmp/wheels-packages-staging/wheels-packages/LICENSE +``` + +Expected: Apache 2.0 LICENSE. + +- [ ] **Step 3: Write README.md** + +Create `/tmp/wheels-packages-staging/wheels-packages/README.md`: + +```markdown +# Wheels Packages + +The official registry for [Wheels](https://wheels.dev) packages. This repo holds package manifests and hosts their distribution tarballs as GitHub Release assets. + +## What lives here + +``` +packages/ + wheels-sentry/ + manifest.json ← authoritative metadata, version history + README.md ← listing blurb, shown on wheels.dev/packages + wheels-hotwire/ + wheels-basecoat/ + wheels-legacy-adapter/ +schema/ + manifest.schema.json ← JSONSchema, CI-enforced +.github/workflows/ + validate.yml ← runs on every PR + mirror-tarball.yml ← packages + uploads release asset on merge (Phase 2) +``` + +## How users install packages + +Once the CLI ships in Wheels 4.1: + +```bash +wheels packages list +wheels packages install wheels-sentry +wheels packages install wheels-sentry@1.0.0 +wheels packages update wheels-sentry +``` + +The CLI reads manifests from this repo, downloads the tarball listed in the manifest (hosted as a GH Release asset on this repo), verifies the sha256, and activates the package into `vendor/` in the consumer's Wheels app. + +## How authors submit packages + +See [`CONTRIBUTING.md`](CONTRIBUTING.md). + +## License + +Registry tooling and manifests: Apache 2.0. Each listed package carries its own license, declared in its manifest. +``` + +Expected: README.md written. + +- [ ] **Step 4: Write CONTRIBUTING.md** + +Create `/tmp/wheels-packages-staging/wheels-packages/CONTRIBUTING.md`: + +```markdown +# Contributing to Wheels Packages + +This registry is the official distribution channel for Wheels packages. This document explains how to submit a new package, publish a new version of an existing one, and what the review process looks like. + +## Before you submit + +Your package must: + +1. **Live in its own public git repo** on GitHub. Monorepos and subpath-based submissions are not accepted in Phase 1. +2. **Have a `package.json` manifest** at the repo root with `name`, `version`, `wheelsVersion`, and either `provides.mixins` or at least one of `provides.services` / `provides.middleware`. +3. **Declare `wheelsVersion` as a SemVer range** (e.g., `">=4.0"`). Packages that don't declare this will be rejected. +4. **Be tagged at the version you're submitting.** The tag name must match `v` — e.g., manifest version `1.2.0` → tag `v1.2.0`. +5. **Ship only allowlisted file types:** `.cfc`, `.cfm`, `.cfml`, `.md`, `.json`, `.js`, `.mjs`, `.ts`, `.css`, `.scss`, `.html`, `.txt`, `.sql`, `.yml`, `.yaml`, `.gitkeep`. Anything else will block CI until a maintainer reviews and approves. +6. **Stay under 10 MB uncompressed.** Packages larger than this need explicit maintainer approval. +7. **Declare a license** in the manifest's `license` field (SPDX identifier). + +## Submitting a new package + +1. **Fork this repo.** +2. **Create a new directory** under `packages/` named exactly after your package — e.g., `packages/wheels-foo/`. +3. **Add `manifest.json`** following the schema at `schema/manifest.schema.json`. Leave `versions[].tarball` and `versions[].sha256` as `null` — CI fills these on merge. +4. **Add `README.md`** — a one-paragraph listing blurb. Shown on `wheels.dev/packages/`. +5. **Open a PR** with title `Add wheels-foo v1.0.0`. +6. **CI runs** — validates schema, name uniqueness, source-repo resolvability, tag existence, file-type allowlist, size cap. +7. **Maintainer reviews** — confirms author, glances at the author's repo, merges if everything looks good. +8. **Mirror workflow fires on merge** (Phase 2) — packages the tagged source into a deterministic tarball, uploads to this repo's Releases, computes sha256, commits both back into your manifest. +9. **Users can now install** via `wheels packages install wheels-foo`. + +## Publishing a new version + +1. **Tag the new version in your own repo** (`v1.1.0` etc.). +2. **Open a PR here** that appends to `packages/wheels-foo/manifest.json`'s `versions[]` array. Do not modify previous entries. +3. **CI and review** same as above. +4. **Users run `wheels packages update wheels-foo`** — opt-in only; no auto-pull. + +## Review criteria + +Maintainers look at: + +- Does the author repo look real (commits, README, tests)? +- Does the manifest match the schema? +- Does the tag at `source.repo` resolve? +- Does the package fit the ecosystem (Wheels app augmentation, not e.g. a general-purpose CFML library masquerading as a package)? + +## What we don't do + +- **We don't host source code.** Your repo stays authoritative. +- **We don't auto-update.** Version bumps require an explicit PR. +- **We don't accept author-hosted tarball URLs** (Attack A defense — see the registry design spec). +- **We don't support private packages yet.** Post-4.1. + +## Getting help + +Open an issue on this repo or ping `#wheels-packages` on Discord. +``` + +Expected: CONTRIBUTING.md written. + +- [ ] **Step 5: Write JSONSchema** + +Create `/tmp/wheels-packages-staging/wheels-packages/schema/manifest.schema.json`: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/wheels-dev/wheels-packages/schema/manifest.schema.json", + "title": "Wheels Package Manifest", + "description": "Metadata entry for a package listed in the Wheels packages registry", + "type": "object", + "required": ["name", "description", "license", "source", "versions"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "pattern": "^wheels-[a-z0-9][a-z0-9-]*$", + "description": "Package identifier, lowercase, prefixed with 'wheels-'" + }, + "description": { + "type": "string", + "minLength": 10, + "maxLength": 300 + }, + "homepage": { "type": "string", "format": "uri" }, + "documentation": { "type": "string", "format": "uri" }, + "license": { + "type": "string", + "description": "SPDX identifier (e.g., 'Apache-2.0', 'MIT')" + }, + "maintainers": { + "type": "array", + "items": { "type": "string", "pattern": "^@[a-zA-Z0-9-]+$" }, + "minItems": 1 + }, + "tags": { + "type": "array", + "items": { "type": "string" } + }, + "source": { + "type": "object", + "required": ["type", "repo"], + "additionalProperties": false, + "properties": { + "type": { "enum": ["github"] }, + "repo": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+/[a-zA-Z0-9._-]+$" + } + } + }, + "versions": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["version", "publishedAt", "wheelsVersion", "sourceTag"], + "additionalProperties": false, + "properties": { + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(-[a-zA-Z0-9.-]+)?$" + }, + "publishedAt": { "type": "string", "format": "date-time" }, + "wheelsVersion": { "type": "string" }, + "sourceTag": { "type": "string", "minLength": 1 }, + "tarball": { + "type": ["string", "null"], + "description": "GH Release asset URL, null until Phase 2 mirror runs" + }, + "sha256": { + "type": ["string", "null"], + "pattern": "^([a-f0-9]{64})?$" + } + } + } + } + } +} +``` + +Expected: schema written with `null` permitted on `tarball` and `sha256` (Phase 1 allowance). + +- [ ] **Step 6: Write seed manifests (four packages)** + +Use this template, substituting per-package fields. Write one file per package. + +`packages/wheels-sentry/manifest.json`: + +```json +{ + "name": "wheels-sentry", + "description": "Sentry error tracking for Wheels with framework-aware context enrichment.", + "homepage": "https://github.com/wheels-dev/wheels-sentry", + "documentation": "https://wheels.dev/packages/wheels-sentry", + "license": "Apache-2.0", + "maintainers": ["@bpamiri"], + "tags": ["monitoring", "errors", "observability"], + "source": { + "type": "github", + "repo": "wheels-dev/wheels-sentry" + }, + "versions": [ + { + "version": "1.0.0", + "publishedAt": "2026-04-23T00:00:00Z", + "wheelsVersion": ">=4.0", + "sourceTag": "v1.0.0", + "tarball": null, + "sha256": null + } + ] +} +``` + +`packages/wheels-hotwire/manifest.json`: + +```json +{ + "name": "wheels-hotwire", + "description": "Hotwire infrastructure for Wheels: Turbo Drive, Turbo Frames, Turbo Streams, Stimulus helpers, and Hotwire Native mobile support.", + "homepage": "https://github.com/wheels-dev/wheels-hotwire", + "documentation": "https://wheels.dev/packages/wheels-hotwire", + "license": "Apache-2.0", + "maintainers": ["@bpamiri"], + "tags": ["hotwire", "turbo", "stimulus", "spa"], + "source": { + "type": "github", + "repo": "wheels-dev/wheels-hotwire" + }, + "versions": [ + { + "version": "1.0.0", + "publishedAt": "2026-04-23T00:00:00Z", + "wheelsVersion": ">=4.0", + "sourceTag": "v1.0.0", + "tarball": null, + "sha256": null + } + ] +} +``` + +`packages/wheels-basecoat/manifest.json`: + +```json +{ + "name": "wheels-basecoat", + "description": "Basecoat UI component helpers for Wheels. shadcn/ui-quality design using plain HTML + Tailwind CSS. No React required.", + "homepage": "https://github.com/wheels-dev/wheels-basecoat", + "documentation": "https://wheels.dev/packages/wheels-basecoat", + "license": "Apache-2.0", + "maintainers": ["@bpamiri"], + "tags": ["ui", "components", "tailwind", "forms"], + "source": { + "type": "github", + "repo": "wheels-dev/wheels-basecoat" + }, + "versions": [ + { + "version": "1.0.0", + "publishedAt": "2026-04-23T00:00:00Z", + "wheelsVersion": ">=4.0", + "sourceTag": "v1.0.0", + "tarball": null, + "sha256": null + } + ] +} +``` + +`packages/wheels-legacy-adapter/manifest.json`: + +```json +{ + "name": "wheels-legacy-adapter", + "description": "Backward compatibility adapter for migrating Wheels 3.x applications to 4.0. Provides deprecation logging, API shims, and a migration scanner.", + "homepage": "https://github.com/wheels-dev/wheels-legacy-adapter", + "documentation": "https://wheels.dev/packages/wheels-legacy-adapter", + "license": "Apache-2.0", + "maintainers": ["@bpamiri"], + "tags": ["migration", "upgrade", "compatibility", "3x"], + "source": { + "type": "github", + "repo": "wheels-dev/wheels-legacy-adapter" + }, + "versions": [ + { + "version": "1.0.0", + "publishedAt": "2026-04-23T00:00:00Z", + "wheelsVersion": ">=4.0", + "sourceTag": "v1.0.0", + "tarball": null, + "sha256": null + } + ] +} +``` + +Bash for creating directories: + +```bash +cd /tmp/wheels-packages-staging/wheels-packages +mkdir -p packages/wheels-sentry packages/wheels-hotwire packages/wheels-basecoat packages/wheels-legacy-adapter schema .github/workflows +``` + +Then write each manifest file using the content above. + +Expected: four manifest files under `packages//manifest.json`. + +- [ ] **Step 7: Write per-package README listing blurbs** + +`packages/wheels-sentry/README.md`: + +```markdown +# wheels-sentry + +Sentry error tracking for Wheels apps with framework-aware context enrichment. Captures exceptions with request, user, and route context. Mixes into controllers. + +- **Source:** https://github.com/wheels-dev/wheels-sentry +- **License:** Apache-2.0 +- **Wheels:** >= 4.0 +``` + +`packages/wheels-hotwire/README.md`: + +```markdown +# wheels-hotwire + +Hotwire integration for Wheels: Turbo Drive for full-page SPA feel, Turbo Frames for scoped updates, Turbo Streams for real-time DOM updates, and Stimulus helpers for JavaScript sprinkles. Also supports Hotwire Native for mobile. Mixes into controllers and views. + +- **Source:** https://github.com/wheels-dev/wheels-hotwire +- **License:** Apache-2.0 +- **Wheels:** >= 4.0 +``` + +`packages/wheels-basecoat/README.md`: + +```markdown +# wheels-basecoat + +shadcn/ui-quality UI components rendered as plain HTML with Tailwind CSS — no React required. Drop-in form controls, buttons, cards, alerts, and more. Mixes into controllers and views. + +- **Source:** https://github.com/wheels-dev/wheels-basecoat +- **License:** Apache-2.0 +- **Wheels:** >= 4.0 +``` + +`packages/wheels-legacy-adapter/README.md`: + +```markdown +# wheels-legacy-adapter + +Backward compatibility shim for migrating Wheels 3.x applications to 4.0. Deprecation logging surfaces every 3.x idiom touched at runtime; API shims keep older code paths alive during migration; the migration scanner flags 3.x patterns in your codebase. Mixes into controllers. + +- **Source:** https://github.com/wheels-dev/wheels-legacy-adapter +- **License:** Apache-2.0 +- **Wheels:** >= 4.0 +``` + +Expected: four README files under `packages//README.md`. + +- [ ] **Step 8: Write `validate.yml` workflow** + +Create `/tmp/wheels-packages-staging/wheels-packages/.github/workflows/validate.yml`: + +```yaml +name: validate + +on: + pull_request: + paths: + - "packages/**" + - "schema/**" + push: + branches: [main] + paths: + - "packages/**" + - "schema/**" + +jobs: + schema: + name: JSONSchema validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install ajv-cli + run: npm install -g ajv-cli ajv-formats + + - name: Validate every manifest against schema + run: | + set -euo pipefail + FAIL=0 + for manifest in packages/*/manifest.json; do + echo "Validating $manifest" + ajv validate -c ajv-formats -s schema/manifest.schema.json -d "$manifest" --strict=false || FAIL=1 + done + exit $FAIL + + structure: + name: Directory + name consistency + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check name matches directory + run: | + set -euo pipefail + FAIL=0 + for manifest in packages/*/manifest.json; do + dir=$(basename "$(dirname "$manifest")") + name=$(jq -r .name "$manifest") + if [ "$dir" != "$name" ]; then + echo "MISMATCH: directory '$dir' but manifest.name '$name'" + FAIL=1 + fi + done + exit $FAIL + + - name: Check name uniqueness + run: | + set -euo pipefail + duplicates=$(jq -r .name packages/*/manifest.json | sort | uniq -d) + if [ -n "$duplicates" ]; then + echo "Duplicate names: $duplicates" + exit 1 + fi + + source-resolvable: + name: source.repo + sourceTag resolve on GitHub + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Verify each source.repo and its tags exist + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + FAIL=0 + for manifest in packages/*/manifest.json; do + repo=$(jq -r .source.repo "$manifest") + echo "Checking $repo ..." + if ! gh repo view "$repo" >/dev/null 2>&1; then + echo "MISSING REPO: $repo" + FAIL=1 + continue + fi + jq -r '.versions[].sourceTag' "$manifest" | while read -r tag; do + if ! gh api "repos/$repo/git/refs/tags/$tag" >/dev/null 2>&1; then + echo "MISSING TAG: $repo#$tag" + exit 1 + fi + done || FAIL=1 + done + exit $FAIL + + content-safety: + name: File-type allowlist + size cap + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Clone each source tag & scan + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + ALLOWED='\.cfc$|\.cfm$|\.cfml$|\.md$|\.json$|\.js$|\.mjs$|\.ts$|\.css$|\.scss$|\.html$|\.txt$|\.sql$|\.yml$|\.yaml$|\.gitkeep$|LICENSE$|CHANGELOG$' + MAX_BYTES=$((10 * 1024 * 1024)) + FAIL=0 + for manifest in packages/*/manifest.json; do + repo=$(jq -r .source.repo "$manifest") + latest_tag=$(jq -r '.versions[-1].sourceTag' "$manifest") + echo "Scanning $repo@$latest_tag" + tmp=$(mktemp -d) + gh repo clone "$repo" "$tmp/src" -- --depth=1 --branch="$latest_tag" + # Size cap + size=$(du -sb "$tmp/src" --exclude=.git | awk '{print $1}') + if [ "$size" -gt "$MAX_BYTES" ]; then + echo "OVER CAP: $repo = $size bytes" + FAIL=1 + fi + # File-type allowlist + disallowed=$(find "$tmp/src" -type f -not -path "*/.git/*" | grep -vE "$ALLOWED" || true) + if [ -n "$disallowed" ]; then + echo "DISALLOWED FILE TYPES in $repo:" + echo "$disallowed" + FAIL=1 + fi + rm -rf "$tmp" + done + exit $FAIL +``` + +Expected: workflow file written. + +- [ ] **Step 9: Write stub `mirror-tarball.yml`** + +Create `/tmp/wheels-packages-staging/wheels-packages/.github/workflows/mirror-tarball.yml`: + +```yaml +name: mirror-tarball + +# Phase 2 workflow — not yet implemented. Scaffolded so validate.yml can +# reference the file path and the registry README can point at it. +# When implemented, this will run on PR merge: +# - clone source.repo at sourceTag +# - produce deterministic tarball (tar --sort=name --mtime=@0) +# - upload as GH Release asset on wheels-packages +# - compute sha256 +# - commit tarball URL + sha256 back into the manifest + +on: + workflow_dispatch: + +jobs: + noop: + runs-on: ubuntu-latest + steps: + - run: echo "Phase 2 — not yet implemented. See the registry design spec." +``` + +Expected: stub file written. + +- [ ] **Step 10: Validate manifests locally with ajv-cli before proceeding** + +```bash +cd /tmp/wheels-packages-staging/wheels-packages +npm install --no-save ajv-cli ajv-formats +for manifest in packages/*/manifest.json; do + echo "=== $manifest ===" + npx ajv validate -c ajv-formats -s schema/manifest.schema.json -d "$manifest" --strict=false +done +``` + +Expected: each manifest prints "valid". + +- [ ] **Step 11: Commit registry repo initial state** + +```bash +cd /tmp/wheels-packages-staging/wheels-packages +rm -rf node_modules package-lock.json # ajv install artifacts +git add . +git -c user.email="noreply@anthropic.com" -c user.name="Peter Amiri" \ + commit -m "feat: bootstrap wheels-packages registry with four seed manifests + +Seeds: +- wheels-sentry v1.0.0 +- wheels-hotwire v1.0.0 +- wheels-basecoat v1.0.0 +- wheels-legacy-adapter v1.0.0 + +Ships: +- schema/manifest.schema.json (JSONSchema, CI-enforced) +- .github/workflows/validate.yml (schema + name uniqueness + source resolvability + file-type allowlist + size cap) +- .github/workflows/mirror-tarball.yml (Phase 2 stub) +- CONTRIBUTING.md + README.md +- Apache 2.0 LICENSE + +All four seed packages are extracted from the wheels monorepo's +packages/ directory via git subtree split into wheels-dev/wheels- +standalone repos, each tagged v1.0.0. + +Ref: wheels-dev/wheels#2243 Phase 1 + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +Expected: one commit in the new registry repo with all the bootstrap contents. + +--- + +## Task 5: User review gate + +**This task is not automatable. The implementation agent must pause and hand control to the user.** + +- [ ] **Step 1: Print local staging tree for review** + +```bash +echo "=== Staging summary ===" +for r in wheels-sentry wheels-hotwire wheels-basecoat wheels-legacy-adapter wheels-packages; do + echo "" + echo "--- $r ---" + cd /tmp/wheels-packages-staging/$r + git log --oneline | head -5 + echo "Files:" + git ls-files | head -20 +done +``` + +- [ ] **Step 2: Display to user and await approval** + +Post to the user: "Local staging complete. Five repos staged under `/tmp/wheels-packages-staging/`. Review the tree output and the contents of any file you'd like. Approve (yes/no) before I push to GitHub." + +Do not proceed to Task 6 until explicit user approval is received. + +--- + +## Task 6: Push four extraction repos + tag + release + +For each of the four extracted repos: create GH repo, push, tag v1.0.0, cut release. + +**Files:** +- Creates GitHub repos: `wheels-dev/wheels-sentry`, `wheels-dev/wheels-hotwire`, `wheels-dev/wheels-basecoat`, `wheels-dev/wheels-legacy-adapter` + +- [ ] **Step 1: Create + push `wheels-sentry`** + +```bash +cd /tmp/wheels-packages-staging/wheels-sentry +gh repo create wheels-dev/wheels-sentry \ + --public \ + --description "Sentry error tracking for Wheels with framework-aware context enrichment" \ + --homepage "https://wheels.dev/packages/wheels-sentry" \ + --source=. \ + --push +``` + +Expected: repo created, commits pushed to `main`. + +- [ ] **Step 2: Tag v1.0.0 and push the tag** + +```bash +cd /tmp/wheels-packages-staging/wheels-sentry +git tag -a v1.0.0 -m "v1.0.0 — initial standalone release from wheels monorepo extraction" +git push origin v1.0.0 +``` + +Expected: tag visible in `gh release list`. + +- [ ] **Step 3: Cut GH Release** + +```bash +cd /tmp/wheels-packages-staging/wheels-sentry +gh release create v1.0.0 \ + --title "wheels-sentry v1.0.0" \ + --notes "Initial standalone release, extracted from the [Wheels monorepo](https://github.com/wheels-dev/wheels) at \`packages/sentry\` with preserved git history. + +Published to the [wheels-dev/wheels-packages](https://github.com/wheels-dev/wheels-packages) registry for installation via \`wheels packages install wheels-sentry\` (coming in Wheels 4.1). + +See [CHANGELOG.md](CHANGELOG.md) for details." +``` + +Expected: GH Release at `wheels-dev/wheels-sentry/releases/tag/v1.0.0`. + +- [ ] **Step 4: Repeat for `wheels-hotwire`** + +Same three commands, substituting `sentry` → `hotwire`, with description "Hotwire infrastructure for Wheels: Turbo Drive, Turbo Frames, Turbo Streams, Stimulus helpers, and Hotwire Native mobile support". + +- [ ] **Step 5: Repeat for `wheels-basecoat`** + +Substitute description "Basecoat UI component helpers for Wheels. shadcn/ui-quality design using plain HTML + Tailwind CSS. No React required." + +- [ ] **Step 6: Repeat for `wheels-legacy-adapter`** + +Substitute description "Backward compatibility adapter for migrating Wheels 3.x applications to 4.0. Provides deprecation logging, API shims, and a migration scanner." + +- [ ] **Step 7: Verify all four repos + releases** + +```bash +for r in wheels-sentry wheels-hotwire wheels-basecoat wheels-legacy-adapter; do + echo "=== $r ===" + gh release view v1.0.0 --repo wheels-dev/$r --json name,tagName,isPrerelease +done +``` + +Expected: four releases each at `v1.0.0`, non-prerelease. + +--- + +## Task 7: Push wheels-packages registry + validate CI runs green + +**Files:** +- Creates GitHub repo: `wheels-dev/wheels-packages` + +- [ ] **Step 1: Create + push registry repo** + +```bash +cd /tmp/wheels-packages-staging/wheels-packages +gh repo create wheels-dev/wheels-packages \ + --public \ + --description "Official registry for Wheels packages. Manifests and distribution tarballs for the Wheels ecosystem." \ + --homepage "https://wheels.dev/packages" \ + --source=. \ + --push +``` + +Expected: repo created, one commit on main. + +- [ ] **Step 2: Wait for validate.yml to complete on first push** + +```bash +sleep 15 +gh run list --repo wheels-dev/wheels-packages --limit 5 +``` + +Expected: `validate` workflow in progress or completed. + +- [ ] **Step 3: Check run status** + +```bash +gh run watch --repo wheels-dev/wheels-packages $(gh run list --repo wheels-dev/wheels-packages --workflow validate.yml --limit 1 --json databaseId --jq '.[0].databaseId') +``` + +Expected: all four jobs (`schema`, `structure`, `source-resolvable`, `content-safety`) pass. + +- [ ] **Step 4: If any job fails, diagnose and fix** + +If CI fails, read the logs: + +```bash +gh run view --log --repo wheels-dev/wheels-packages $(gh run list --repo wheels-dev/wheels-packages --workflow validate.yml --limit 1 --json databaseId --jq '.[0].databaseId') +``` + +Likely failure modes: +- `source-resolvable` fails: one of the extracted repos didn't push cleanly → re-run Task 6 for the missing one. +- `content-safety` fails: an extracted repo has files outside the allowlist (likely `.gitattributes`, `.editorconfig`, etc.). Either extend the allowlist in `validate.yml` or remove the files from the extracted repo + retag. +- `schema` fails: a manifest has a subtle schema violation. Fix and push. + +Commit any fix to `main` on `wheels-packages`. + +- [ ] **Step 5: Confirm CI green** + +```bash +gh run list --repo wheels-dev/wheels-packages --workflow validate.yml --limit 1 +``` + +Expected: most recent run shows `completed success`. + +--- + +## Task 8: Monorepo PR — delete packages/, rewrite CI, update docs + +**Files:** +- Delete: `packages/` (everything under it) +- Modify: `.github/workflows/compat-matrix.yml` (remove the "Run per-package tests" step) +- Modify: `CLAUDE.md` +- Modify: `web/sites/guides/src/content/docs/v4-0-0-snapshot/digging-deeper/packages.mdx` +- Modify: `web/sites/guides/src/content/docs/v4-0-0-snapshot/start-here/tutorial/03-crud-scaffold.mdx` +- Modify: `web/sites/guides/src/content/docs/v4-0-0-snapshot/upgrading/3x-to-4x.mdx` +- Modify: `web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/observability-and-logging.mdx` +- Modify: `cli/lucli/templates/app/app/plugins/README.md` + +- [ ] **Step 1: Verify branch + clean tree** + +```bash +cd /Users/peter/GitHub/wheels-dev/wheels/.claude/worktrees/fervent-jepsen-e89804 +git status +git branch --show-current +``` + +Expected: branch `claude/fervent-jepsen-e89804`, clean tree (spec commit already present). + +- [ ] **Step 2: Delete packages/ directory** + +```bash +git rm -rf packages +``` + +Expected: `git status` shows all files under `packages/` staged as deleted. + +- [ ] **Step 3: Remove per-package test step from compat-matrix.yml** + +Open `.github/workflows/compat-matrix.yml`. Locate the `- name: Run per-package tests` step (search for that string). Delete the entire step (from `- name: Run per-package tests` through the end of that step's `run:` block — in practice, lines ~429 through ~500 or wherever the step ends and the next `- name:` begins). + +Exact boundaries: find the line starting with ` - name: Run per-package tests` and find the next ` - name:` that follows; delete everything in between (inclusive of the first line, exclusive of the second). + +If there is no next `- name:` (i.e., it's the last step in the job), delete everything from that line to the end of the `steps:` list. + +Verify with: + +```bash +grep -n "per-package tests" .github/workflows/compat-matrix.yml +``` + +Expected: no matches. + +- [ ] **Step 4: Update CLAUDE.md Package System section** + +Open `CLAUDE.md`. Find the `## Package System` section. Replace it with: + +```markdown +## Package System + +Optional modules are distributed via the [`wheels-dev/wheels-packages`](https://github.com/wheels-dev/wheels-packages) registry. Users install them via `wheels packages install ` (coming in Wheels 4.1); packages land under `vendor/` and are auto-discovered on startup by `PackageLoader.cfc` with per-package error isolation. + +``` +vendor/ + wheels/ # Framework core (excluded from package discovery) + wheels-sentry/ # Installed package (extracted to standalone repo) + ... +plugins/ # DEPRECATED: legacy plugins still work with warning +``` + +### package.json Manifest + +(Schema reference unchanged — keep this block.) + +### Distribution + +Each package is a public git repo under `wheels-dev/` (e.g., `wheels-dev/wheels-sentry`). The `wheels-packages` registry holds manifests pointing at those source repos + distribution tarballs as GH Release assets. Installation flow (once CLI ships in Wheels 4.1): + +```bash +wheels packages list +wheels packages install wheels-sentry +wheels packages update wheels-sentry +``` + +Until the CLI ships, manual install: + +```bash +gh repo clone wheels-dev/wheels-sentry vendor/wheels-sentry +# or: download the GH Release tarball and extract into vendor/ +``` + +Restart or reload the app after install. +``` + +Keep the existing "Error Isolation" and "Testing Packages" subsections unchanged — they still apply. + +Delete any paragraph that mentions `packages/` as a staging area. + +- [ ] **Step 5: Update `packages.mdx` guide** + +In `web/sites/guides/src/content/docs/v4-0-0-snapshot/digging-deeper/packages.mdx`: + +- Replace the "activation model" section: drop the "packages/ is a staging area" framing. Replace with: "Packages are installed into `vendor//` via the `wheels packages` CLI (coming in Wheels 4.1) or by cloning the package's repo directly." +- Replace the `cp -r packages/hotwire vendor/hotwire` examples with `gh repo clone wheels-dev/wheels-hotwire vendor/wheels-hotwire` (interim workflow until CLI ships). +- Update the "First-party packages" list — each entry now links to `https://github.com/wheels-dev/wheels-`. +- Delete the `packages/` symlink example; symlinks now point at a cloned-elsewhere dir if authors want live development. + +Use Edit tool with exact old_string / new_string pairs since the file is 253 lines. + +- [ ] **Step 6: Update tutorial `03-crud-scaffold.mdx`** + +In `web/sites/guides/src/content/docs/v4-0-0-snapshot/start-here/tutorial/03-crud-scaffold.mdx`: + +Find any `packages/` reference. Most likely a "copy basecoat from packages/" step. Replace with instructions to install via `gh repo clone wheels-dev/wheels-basecoat vendor/wheels-basecoat`. + +- [ ] **Step 7: Update `3x-to-4x.mdx` upgrade guide** + +In `web/sites/guides/src/content/docs/v4-0-0-snapshot/upgrading/3x-to-4x.mdx`: + +Replace `packages/legacyadapter` → `wheels-dev/wheels-legacy-adapter` external repo. Update activation instructions to reflect registry install pattern. + +- [ ] **Step 8: Update `observability-and-logging.mdx`** + +In `web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/observability-and-logging.mdx`: + +Replace any `packages/sentry` references with `wheels-dev/wheels-sentry` + registry install instructions. + +- [ ] **Step 9: Update `cli/lucli/templates/app/app/plugins/README.md`** + +Replace the `cp -r packages/hotwire vendor/hotwire` line with `gh repo clone wheels-dev/wheels-hotwire vendor/wheels-hotwire` (or the `wheels packages install` line with a note that it's 4.1+). + +- [ ] **Step 10: Grep for any remaining `packages/` references** + +```bash +grep -rn "packages/sentry\|packages/hotwire\|packages/basecoat\|packages/legacyadapter" \ + --include="*.md" --include="*.mdx" --include="*.cfm" --include="*.cfc" \ + . 2>/dev/null | grep -v "^\./docs/superpowers/" +``` + +Expected: zero matches (excluding historical plan/spec docs under `docs/superpowers/` which we intentionally preserve as history). + +If any matches appear, edit each to use the new pattern. + +- [ ] **Step 11: Run local doc build to confirm no broken links** + +```bash +cd web/sites/guides +npm install +npm run build 2>&1 | tail -30 +``` + +Expected: build succeeds. Any "broken link" warnings must be addressed before commit. + +- [ ] **Step 12: Commit monorepo changes** + +```bash +cd /Users/peter/GitHub/wheels-dev/wheels/.claude/worktrees/fervent-jepsen-e89804 +git add -A +git commit -m "$(cat <<'EOF' +feat(plugin): remove packages/ directory, redirect to wheels-packages registry + +The four inline first-party packages (sentry, hotwire, basecoat, legacyadapter) +have been extracted to standalone wheels-dev/ repos and registered in the new +wheels-dev/wheels-packages registry. This commit removes the monorepo staging +copies and redirects all docs to the new installation path. + +Changes: +- Delete packages/ (sentry, hotwire, basecoat, legacyadapter subdirs) +- Remove "Run per-package tests" step from compat-matrix.yml — extracted + repos now test themselves in their own CI +- CLAUDE.md: rewrite Package System section to reflect registry-based + distribution; drop packages/ staging concept +- digging-deeper/packages.mdx: replace activation examples with registry + install pattern; update first-party package list to external-repo links +- Tutorial, upgrade guide, and observability guide: redirect all packages/ + references to external repos +- CLI template README: redirect packages/hotwire reference + +The new external repos: +- https://github.com/wheels-dev/wheels-sentry (v1.0.0) +- https://github.com/wheels-dev/wheels-hotwire (v1.0.0) +- https://github.com/wheels-dev/wheels-basecoat (v1.0.0) +- https://github.com/wheels-dev/wheels-legacy-adapter (v1.0.0) + +All four registered in wheels-dev/wheels-packages under packages//manifest.json. + +Closes #2243 Phase 1. + +Refs design spec: docs/superpowers/specs/2026-04-23-wheels-packages-phase1-extraction-design.md + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +Expected: single commit on the current branch. + +- [ ] **Step 13: Push and open PR** + +```bash +git push -u origin claude/fervent-jepsen-e89804 +gh pr create \ + --base develop \ + --title "feat(plugin): remove packages/ directory, redirect to wheels-packages registry (#2243 Phase 1)" \ + --body "$(cat <<'EOF' +## Summary + +Phase 1 of [#2243](https://github.com/wheels-dev/wheels/issues/2243) — extracts the four inline first-party packages into standalone `wheels-dev/*` repos and bootstraps the new `wheels-dev/wheels-packages` registry. This PR removes the monorepo staging copies and redirects all docs to the new registry-based install path. + +**Design spec:** `docs/superpowers/specs/2026-04-23-wheels-packages-phase1-extraction-design.md` + +### What landed outside this repo + +- [wheels-dev/wheels-sentry v1.0.0](https://github.com/wheels-dev/wheels-sentry/releases/tag/v1.0.0) +- [wheels-dev/wheels-hotwire v1.0.0](https://github.com/wheels-dev/wheels-hotwire/releases/tag/v1.0.0) +- [wheels-dev/wheels-basecoat v1.0.0](https://github.com/wheels-dev/wheels-basecoat/releases/tag/v1.0.0) +- [wheels-dev/wheels-legacy-adapter v1.0.0](https://github.com/wheels-dev/wheels-legacy-adapter/releases/tag/v1.0.0) +- [wheels-dev/wheels-packages](https://github.com/wheels-dev/wheels-packages) (registry with the four seeds, passing validate.yml) + +### Follow-ups (filed as separate issues) + +- `wheels-seo-suite` → convert to 4.0 package format +- `wheels-i18n` → convert to 4.0 package format +- `#2243` Phase 2 — tarball mirror CI +- `#2243` Phase 3 — CLI commands +- `#2243` Phase 4 — web UI + +## Test plan + +- [ ] compat-matrix.yml is green after removing the per-package-test step +- [ ] `npm run build` in `web/sites/guides/` succeeds with no broken-link warnings +- [ ] `grep -rn "packages/(sentry|hotwire|basecoat|legacyadapter)"` returns zero code/doc matches (excluding `docs/superpowers/` history) +- [ ] CLAUDE.md Package System section reads correctly + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Expected: PR opened against `develop`. + +- [ ] **Step 14: Wait for CI** + +```bash +gh pr checks --watch +``` + +Expected: compat-matrix + other required checks green. If any fail, diagnose and push fix commits to the same branch. + +--- + +## Task 9: File follow-up issues + +- [ ] **Step 1: File `wheels-seo-suite` conversion issue** + +```bash +gh issue create --repo wheels-dev/wheels --title "packages: convert wheels-seo-suite to 4.0 package format" --body "$(cat <<'EOF' +Follow-up from [#2243](https://github.com/wheels-dev/wheels/issues/2243) Phase 1. + +`wheels-dev/wheels-seo-suite` is currently a Wheels 3.x plugin (has `box.json` with `type: "cfwheels-plugins"`, classic `index.cfm` bootstrap, no `package.json`, no `tests/`). + +To be registerable in the `wheels-packages` registry, it needs 4.0 conversion: + +### Scope +- [ ] Author `package.json` (name, version, wheelsVersion, provides.mixins) +- [ ] Port `index.cfm` lifecycle to the package CFC init() hook +- [ ] Add `tests/` suite (use WheelsTest BDD) +- [ ] Update README to reflect 4.0 installation via `wheels packages install wheels-seo-suite` +- [ ] Tag `v2.0.0` (major bump — 4.0 is breaking vs 3.x plugin format) and cut GH release +- [ ] Open PR on `wheels-dev/wheels-packages` adding `packages/wheels-seo-suite/manifest.json` + +### Acceptance +- Registry's `validate.yml` passes on the new manifest +- `wheels packages install wheels-seo-suite` works (once Phase 3 CLI ships) +EOF +)" +``` + +- [ ] **Step 2: File `wheels-i18n` conversion issue** + +Same template as Step 1, substituting `wheels-seo-suite` → `wheels-i18n` and the feature-list (add i18n-specific scope bullets if appropriate). + +- [ ] **Step 3: File `#2243` Phase 2 child issue (tarball mirror CI)** + +```bash +gh issue create --repo wheels-dev/wheels --title "wheels-packages Phase 2 — tarball mirror CI" --body "$(cat <<'EOF' +Phase 2 of [#2243](https://github.com/wheels-dev/wheels/issues/2243). + +Implement the mirror-tarball.yml workflow on `wheels-dev/wheels-packages`. Currently a stub. + +### Scope +- [ ] On PR merge: clone `source.repo` at `sourceTag` → deterministic tar (sort + mtime=0) → upload as GH Release asset on wheels-packages → compute sha256 → bot-commit tarball URL + sha256 back into manifest +- [ ] Release tag convention: `-` +- [ ] Tighten schema: `tarball` and `sha256` required post-Phase-2 + +### Acceptance +- Opening a PR on wheels-packages that appends a new version triggers the mirror automatically on merge; the manifest entry is populated with tarball + sha256 without manual edit. +EOF +)" +``` + +- [ ] **Step 4: File `#2243` Phase 3 child issue (CLI)** + +Template similar to Step 3, scoped to CLI commands (`wheels packages list|search|show|install|update|remove|registry refresh|registry info`). + +- [ ] **Step 5: File `#2243` Phase 4 child issue (web UI)** + +Template similar to Step 3, scoped to `wheels.dev/packages` + `/wheels/packages` in-app. + +- [ ] **Step 6: Link follow-up issues in #2243** + +```bash +gh issue comment 2243 --repo wheels-dev/wheels --body "Phase 1 complete. Follow-up issues filed: +- Phase 2 — tarball mirror CI: # +- Phase 3 — CLI: # +- Phase 4 — web UI: # +- External conversions: #, # + +See design spec: [\`2026-04-23-wheels-packages-phase1-extraction-design.md\`](link)" +``` + +Expected: issue #2243 has a summary comment linking all children. + +--- + +## Final verification + +- [ ] **Registry CI green:** `gh run list --repo wheels-dev/wheels-packages --workflow validate.yml --limit 1` → most recent = `success` +- [ ] **All four external repos have v1.0.0 release:** `for r in sentry hotwire basecoat legacy-adapter; do gh release view v1.0.0 --repo wheels-dev/wheels-$r --json name; done` +- [ ] **Monorepo PR merged** to `develop` (code-tier merge per CLAUDE.local.md: full test suite green, no auto-merge) +- [ ] **Five follow-up issues filed** and linked in #2243 +- [ ] **Spec + plan both committed** on `develop` + +## Unresolved from spec — decisions at execution time + +- **Maintainer handle:** plan assumes `@bpamiri`. Change in manifests if user wants `@wheels-dev`. +- **`documentation` URL:** plan leaves `wheels.dev/packages/` populated even though it 404s until Phase 4 ships. Acceptable per spec; noted. +- **CHANGELOG in extracted repos:** plan includes them (Step 3.6). Minimal content; easy to maintain. From 58dfb827f6e2a87f60b0c6b475b0157b014b9595 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Thu, 23 Apr 2026 13:03:32 -0700 Subject: [PATCH 3/3] feat(plugin): remove packages/ directory, redirect to wheels-packages registry The four inline first-party packages (sentry, hotwire, basecoat, legacyadapter) have been extracted to standalone wheels-dev/ repos and registered in the new wheels-dev/wheels-packages registry. This commit removes the monorepo staging copies and redirects all docs to the new installation path. Changes: - Delete packages/ (sentry, hotwire, basecoat, legacyadapter subdirs) - Remove "Run per-package tests" step from compat-matrix.yml - extracted repos now test themselves in their own CI - CLAUDE.md: rewrite Package System section to reflect registry distribution - Guides: redirect all packages/ references to external repos (packages.mdx, contributing, glossary, why-wheels, upgrading, tutorial, 3x-to-4x, observability, security-hardening, modules-and-deps) - CLI template README: redirect packages/hotwire reference The new external repos: - https://github.com/wheels-dev/wheels-sentry (v1.0.0) - https://github.com/wheels-dev/wheels-hotwire (v1.0.0) - https://github.com/wheels-dev/wheels-basecoat (v1.0.0) - https://github.com/wheels-dev/wheels-legacy-adapter (v1.0.0) All four registered in wheels-dev/wheels-packages under packages//manifest.json. Closes #2243 Phase 1. Refs spec: docs/superpowers/specs/2026-04-23-wheels-packages-phase1-extraction-design.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/compat-matrix.yml | 82 -- CLAUDE.md | 31 +- cli/lucli/templates/app/app/plugins/README.md | 6 +- packages/basecoat/.ai/ARCHITECTURE.md | 19 - packages/basecoat/Basecoat.cfc | 724 ---------- packages/basecoat/CLAUDE.md | 277 ---- packages/basecoat/README.md | 139 -- packages/basecoat/box.json | 16 - packages/basecoat/index.cfm | 65 - packages/basecoat/package.json | 13 - .../basecoat/tests/BasecoatComplexSpec.cfc | 352 ----- .../basecoat/tests/BasecoatSimpleSpec.cfc | 198 --- packages/hotwire/.ai/ARCHITECTURE.md | 1191 ----------------- packages/hotwire/CLAUDE.md | 169 --- packages/hotwire/Hotwire.cfc | 300 ----- packages/hotwire/README.md | 199 --- packages/hotwire/box.json | 13 - packages/hotwire/index.cfm | 59 - packages/hotwire/package.json | 13 - packages/hotwire/tests/HotwireSpec.cfc | 182 --- packages/legacyadapter/DeprecationLogger.cfc | 131 -- packages/legacyadapter/LegacyAdapter.cfc | 219 --- packages/legacyadapter/MigrationScanner.cfc | 231 ---- packages/legacyadapter/README.md | 96 -- packages/legacyadapter/box.json | 10 - packages/legacyadapter/index.cfm | 77 -- packages/legacyadapter/package.json | 13 - .../legacyadapter/tests/LegacyAdapterSpec.cfc | 358 ----- packages/sentry/CLAUDE.md | 55 - packages/sentry/README.md | 167 --- packages/sentry/Sentry.cfc | 244 ---- packages/sentry/SentryClient.cfc | 515 ------- packages/sentry/box.json | 13 - packages/sentry/index.cfm | 20 - packages/sentry/package.json | 13 - packages/sentry/tests/SentrySpec.cfc | 200 --- .../core-commands/modules-and-deps.mdx | 2 +- .../v4-0-0-snapshot/contributing/index.mdx | 2 +- .../deployment/observability-and-logging.mdx | 14 +- .../deployment/security-hardening.mdx | 2 +- .../digging-deeper/packages.mdx | 77 +- .../content/docs/v4-0-0-snapshot/glossary.mdx | 2 +- .../start-here/tutorial/03-crud-scaffold.mdx | 10 +- .../v4-0-0-snapshot/start-here/why-wheels.mdx | 4 +- .../v4-0-0-snapshot/upgrading/3x-to-4x.mdx | 32 +- .../docs/v4-0-0-snapshot/upgrading/index.mdx | 2 +- 46 files changed, 92 insertions(+), 6465 deletions(-) delete mode 100644 packages/basecoat/.ai/ARCHITECTURE.md delete mode 100644 packages/basecoat/Basecoat.cfc delete mode 100644 packages/basecoat/CLAUDE.md delete mode 100644 packages/basecoat/README.md delete mode 100644 packages/basecoat/box.json delete mode 100644 packages/basecoat/index.cfm delete mode 100644 packages/basecoat/package.json delete mode 100644 packages/basecoat/tests/BasecoatComplexSpec.cfc delete mode 100644 packages/basecoat/tests/BasecoatSimpleSpec.cfc delete mode 100644 packages/hotwire/.ai/ARCHITECTURE.md delete mode 100644 packages/hotwire/CLAUDE.md delete mode 100644 packages/hotwire/Hotwire.cfc delete mode 100644 packages/hotwire/README.md delete mode 100644 packages/hotwire/box.json delete mode 100644 packages/hotwire/index.cfm delete mode 100644 packages/hotwire/package.json delete mode 100644 packages/hotwire/tests/HotwireSpec.cfc delete mode 100644 packages/legacyadapter/DeprecationLogger.cfc delete mode 100644 packages/legacyadapter/LegacyAdapter.cfc delete mode 100644 packages/legacyadapter/MigrationScanner.cfc delete mode 100644 packages/legacyadapter/README.md delete mode 100644 packages/legacyadapter/box.json delete mode 100644 packages/legacyadapter/index.cfm delete mode 100644 packages/legacyadapter/package.json delete mode 100644 packages/legacyadapter/tests/LegacyAdapterSpec.cfc delete mode 100644 packages/sentry/CLAUDE.md delete mode 100644 packages/sentry/README.md delete mode 100755 packages/sentry/Sentry.cfc delete mode 100755 packages/sentry/SentryClient.cfc delete mode 100755 packages/sentry/box.json delete mode 100644 packages/sentry/index.cfm delete mode 100644 packages/sentry/package.json delete mode 100644 packages/sentry/tests/SentrySpec.cfc diff --git a/.github/workflows/compat-matrix.yml b/.github/workflows/compat-matrix.yml index 4754b26451..175ddc20ae 100644 --- a/.github/workflows/compat-matrix.yml +++ b/.github/workflows/compat-matrix.yml @@ -426,88 +426,6 @@ jobs: exit 1 fi - - name: Run per-package tests - if: success() - run: | - PORT_VAR="PORT_${{ matrix.cfengine }}" - PORT="${!PORT_VAR}" - - PKG_DB="sqlite" - - # Find packages with test directories - if [ ! -d "packages" ]; then - echo "No packages/ directory found, skipping" - exit 0 - fi - - PKG_FAIL=0 - for pkg_dir in packages/*/; do - pkg_name=$(basename "$pkg_dir") - test_dir="${pkg_dir}tests" - - if [ ! -d "$test_dir" ]; then - echo "Package '${pkg_name}' has no tests/ directory, skipping" - continue - fi - - echo "" - echo "==============================================" - echo "Testing package: ${pkg_name} (${{ matrix.cfengine }} + ${PKG_DB})" - echo "==============================================" - - # Activate: copy package to vendor/ - cp -r "${pkg_dir}" "vendor/${pkg_name}" - - # Restart engine for clean app state with the package loaded - docker restart wheels-${{ matrix.cfengine }}-1 - WAIT=0 - while [ "$WAIT" -lt 30 ]; do - WAIT=$((WAIT + 1)) - if curl -s -o /dev/null --connect-timeout 2 --max-time 5 -w "%{http_code}" "http://localhost:${PORT}/" | grep -q "200\|404\|302"; then - break - fi - sleep 5 - done - # Warm-up - curl -s -o /dev/null --max-time 60 "http://localhost:${PORT}/?reload=true" || true - sleep 2 - - # Run the package's tests - TEST_URL="http://localhost:${PORT}/wheels/core/tests?db=${PKG_DB}&format=json&directory=vendor.${pkg_name}.tests" - RESULT_FILE="/tmp/test-results/${{ matrix.cfengine }}-pkg-${pkg_name}.txt" - - HTTP_CODE=$(curl -sL -o "$RESULT_FILE" \ - --max-time 300 \ - --write-out "%{http_code}" \ - "$TEST_URL" || echo "000") - - if [ "$HTTP_CODE" = "200" ]; then - PASS=$(python3 -c "import json; d=json.load(open('$RESULT_FILE')); print(d.get('totalPass',0))" 2>/dev/null || echo "?") - echo "PASSED: package '${pkg_name}' (${PASS} specs)" - else - echo "::warning::Package '${pkg_name}' tests returned HTTP ${HTTP_CODE}" - PKG_FAIL=1 - fi - - # Deactivate: remove from vendor/ - rm -rf "vendor/${pkg_name}" - done - - # Restart engine to restore clean state (no packages) - docker restart wheels-${{ matrix.cfengine }}-1 - WAIT=0 - while [ "$WAIT" -lt 30 ]; do - WAIT=$((WAIT + 1)) - if curl -s -o /dev/null --connect-timeout 2 --max-time 5 -w "%{http_code}" "http://localhost:${PORT}/" | grep -q "200\|404\|302"; then - break - fi - sleep 5 - done - - if [ "$PKG_FAIL" -ne 0 ]; then - echo "::warning::One or more package test suites had issues (non-blocking)" - fi - - name: Generate per-engine summary if: always() run: | diff --git a/CLAUDE.md b/CLAUDE.md index e3764b1061..5b6b7620f2 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ app/migrator/migrations/ app/db/seeds.cfm app/db/seeds/ app/events/ app/global/ app/lib/ app/mailers/ app/jobs/ app/plugins/ app/snippets/ config/settings.cfm config/routes.cfm config/environment.cfm -packages/ plugins/ public/ tests/ vendor/ .env (never commit) +plugins/ public/ tests/ vendor/ .env (never commit) ``` ## Development Tools @@ -334,16 +334,19 @@ Strategies: `fixedWindow` (default), `slidingWindow`, `tokenBucket`. Storage: `m ## Package System -Optional first-party modules ship in `packages/` and are activated by copying to `vendor/`. The framework auto-discovers `vendor/*/package.json` on startup via `PackageLoader.cfc` with per-package error isolation. +Optional first-party modules are distributed as standalone repositories and installed into `vendor//`. The framework auto-discovers `vendor/*/package.json` on startup via `PackageLoader.cfc` with per-package error isolation. + +Four first-party packages live in standalone repos under `wheels-dev/`, indexed by the `wheels-dev/wheels-packages` registry: + +- `wheels-dev/wheels-sentry` — error tracking +- `wheels-dev/wheels-hotwire` — Turbo/Stimulus +- `wheels-dev/wheels-basecoat` — UI components +- `wheels-dev/wheels-legacy-adapter` — 3.x → 4.x compatibility shims ``` -packages/ # Source/staging (NOT auto-loaded) - sentry/ # wheels-sentry — error tracking - hotwire/ # wheels-hotwire — Turbo/Stimulus - basecoat/ # wheels-basecoat — UI components -vendor/ # Runtime: framework core + activated packages +vendor/ # Runtime: framework core + installed packages wheels/ # Framework core (excluded from package discovery) - sentry/ # Activated package (copied from packages/) + wheels-sentry/ # Installed package plugins/ # DEPRECATED: legacy plugins still work with warning ``` @@ -367,14 +370,16 @@ plugins/ # DEPRECATED: legacy plugins still work with warning **`provides.mixins`**: Comma-delimited targets from the allowlist `application,dispatch,controller,mapper,model,base,sqlserver,mysql,postgresql,h2,test`, plus the special values `global` (inject into all targets) and `none` (explicit opt-out). Determines which framework components receive the package's public methods. Default: `none` (explicit opt-in, unlike legacy plugins which default to `global`). Unknown targets (typos, `view`, `service`, etc.) are rejected with a clear error — view helpers belong in `controller` mixins since Wheels views execute in the controller's variables scope. -### Activating a Package +### Installing a Package + +The Wheels 4.1 CLI will ship `wheels packages install ` which resolves names against the `wheels-dev/wheels-packages` registry. Until then, interim install is a manual clone: ```bash -cp -r packages/sentry vendor/sentry # activate -rm -rf vendor/sentry # deactivate +gh repo clone wheels-dev/wheels-sentry vendor/wheels-sentry # install +rm -rf vendor/wheels-sentry # remove ``` -Restart or reload the app after activation. Symlinks also work: `ln -s ../../packages/sentry vendor/sentry`. +Restart or reload the app after install. ### Error Isolation @@ -384,7 +389,7 @@ Each package loads in its own try/catch. A broken package is logged and skipped ```bash # Run a specific package's tests (package must be in vendor/) -curl "http://localhost:60007/wheels/core/tests?db=sqlite&format=json&directory=vendor.sentry.tests" +curl "http://localhost:60007/wheels/core/tests?db=sqlite&format=json&directory=vendor.wheels-sentry.tests" ``` ## Routing Quick Reference diff --git a/cli/lucli/templates/app/app/plugins/README.md b/cli/lucli/templates/app/app/plugins/README.md index 8e2062e7ab..43ebe0904c 100644 --- a/cli/lucli/templates/app/app/plugins/README.md +++ b/cli/lucli/templates/app/app/plugins/README.md @@ -6,10 +6,12 @@ Wheels 3.x used this directory for the plugin system. For v4.0 and beyond, plugi ## What to use instead -Copy a package from `packages/` into `vendor/` to activate it. Example: +Install a package into `vendor//`. First-party packages live under `wheels-dev/` on GitHub and are indexed by the [`wheels-dev/wheels-packages`](https://github.com/wheels-dev/wheels-packages) registry. + +Wheels 4.1 will ship `wheels packages install wheels-hotwire`. Until then, the interim install is a manual clone: ```bash -cp -r packages/hotwire vendor/hotwire +gh repo clone wheels-dev/wheels-hotwire vendor/wheels-hotwire wheels reload ``` diff --git a/packages/basecoat/.ai/ARCHITECTURE.md b/packages/basecoat/.ai/ARCHITECTURE.md deleted file mode 100644 index 744ddd73a4..0000000000 --- a/packages/basecoat/.ai/ARCHITECTURE.md +++ /dev/null @@ -1,19 +0,0 @@ -# Basecoat Plugin Architecture - -See `CLAUDE.md` in the plugin root for the authoritative specification including markup reference, implementation phases, naming conventions, and testing guidance. - -## Component Categories - -- **Simple** (Phase 1): Button, Badge, Icon, Spinner, Skeleton, Progress, Separator, Tooltip -- **Block** (Phase 2): Alert, Card, Dialog -- **Form** (Phase 3): Field (text, email, textarea, select, checkbox, switch) -- **Complex** (Phase 4): Table, Tabs, Dropdown, Pagination -- **Layout** (Phase 5): Sidebar, Breadcrumb - -## Design Principles - -- All helpers return HTML strings for use in views via `#helperName()#` -- Markup matches basecoatui.com v0.3.x patterns exactly -- No JavaScript dependencies for core components (CSS-only where possible) -- Turbo-aware but Hotwire-independent -- Native `` element for modals (no JS library) diff --git a/packages/basecoat/Basecoat.cfc b/packages/basecoat/Basecoat.cfc deleted file mode 100644 index 124ac12b9b..0000000000 --- a/packages/basecoat/Basecoat.cfc +++ /dev/null @@ -1,724 +0,0 @@ -/** - * wheels-basecoat Plugin - * Basecoat UI component helpers for Wheels. - * Generates shadcn/ui-quality HTML using Basecoat CSS classes. - * Works with or without wheels-hotwire. - */ -component mixin="controller,view" output="false" { - - function init() { - this.version = "3.0"; - return this; - } - - // ============================================== - // INCLUDES - // ============================================== - - /** - * Outputs Basecoat CSS and Alpine.js (for interactive components) tags for the layout . - */ - public string function basecoatIncludes( - boolean alpine = true, - string alpineVersion = "3", - string basecoatCSSPath = "/plugins/basecoat/assets/basecoat/basecoat.min.css", - boolean turboAware = true - ) { - var local = {}; - savecontent variable="local.html" { - writeOutput( - (arguments.turboAware ? '' & chr(10) : '') - & '' & chr(10) - & (arguments.alpine ? '' & chr(10) : '') - ); - } - return trim(local.html); - } - - // ============================================== - // BUTTONS - // ============================================== - - /** - * Basecoat button. Variants: primary, secondary, destructive, outline, ghost, link. Sizes: sm, md, lg. - */ - public string function uiButton( - string text = "", - string variant = "primary", - string size = "md", - string icon = "", - string href = "", - string type = "button", - boolean disabled = false, - boolean loading = false, - boolean close = false, - string class = "", - string id = "", - string ariaLabel = "", - string turboConfirm = "", - string turboMethod = "" - ) { - var local = {}; - - // Build Basecoat compound class: btn[-size][-icon][-variant] - local.isIconOnly = len(arguments.icon) && !len(arguments.text); - local.parts = []; - if (arguments.size != "md") arrayAppend(local.parts, arguments.size); - if (local.isIconOnly) arrayAppend(local.parts, "icon"); - if (arguments.variant != "primary") arrayAppend(local.parts, arguments.variant); - - local.cls = arrayLen(local.parts) ? "btn-" & arrayToList(local.parts, "-") : "btn"; - if (len(arguments.class)) local.cls &= " " & arguments.class; - - // Inner content: icon + text - local.inner = ""; - if (arguments.loading) { - local.inner = $uiLucideIcon("loader", 24, 2, "animate-spin"); - } else if (len(arguments.icon)) { - local.inner = $uiLucideIcon(arguments.icon, 24); - } - if (len(arguments.text)) { - if (len(local.inner)) local.inner &= " "; - local.inner &= arguments.text; - } - - // Attributes - local.attrs = 'class="#local.cls#"'; - if (len(arguments.id)) local.attrs &= ' id="#arguments.id#"'; - if (arguments.disabled || arguments.loading) local.attrs &= " disabled"; - if (len(arguments.ariaLabel)) local.attrs &= ' aria-label="#arguments.ariaLabel#"'; - if (arguments.close) local.attrs &= " onclick=""this.closest('dialog').close()"""; - if (len(arguments.turboConfirm)) local.attrs &= ' data-turbo-confirm="#arguments.turboConfirm#"'; - if (len(arguments.turboMethod)) local.attrs &= ' data-turbo-method="#arguments.turboMethod#"'; - - if (len(arguments.href)) - return '#local.inner#'; - return ''; - } - - // ============================================== - // BADGES - // ============================================== - - /** Basecoat badge. Variants: default, secondary, destructive, outline. */ - public string function uiBadge(required string text, string variant = "default", string class = "") { - var cls = (arguments.variant == "default") ? "badge" : "badge-#arguments.variant#"; - if (len(arguments.class)) cls &= " " & arguments.class; - return '#arguments.text#'; - } - - // ============================================== - // ICONS - // ============================================== - - /** Renders a Lucide SVG icon by name. */ - public string function uiIcon(required string name, numeric size = 24, numeric strokeWidth = 2, string class = "") { - return $uiLucideIcon(arguments.name, arguments.size, arguments.strokeWidth, arguments.class); - } - - // ============================================== - // SIMPLE COMPONENTS - // ============================================== - - /** Basecoat loading spinner. */ - public string function uiSpinner(string class = "") { - var cls = "spinner"; - if (len(arguments.class)) cls &= " " & arguments.class; - return '
'; - } - - /** Basecoat skeleton loading placeholder. Specify lines for multiple, or use height/width for custom. */ - public string function uiSkeleton(numeric lines = 1, string height = "h-4", string width = "w-full", string class = "") { - var cls = "skeleton #arguments.height# #arguments.width#"; - if (len(arguments.class)) cls &= " " & arguments.class; - - if (arguments.lines == 1) - return '
'; - - var html = ""; - for (var i = 1; i <= arguments.lines; i++) { - html &= '
'; - if (i < arguments.lines) html &= chr(10); - } - return html; - } - - /** Basecoat progress bar. */ - public string function uiProgress(required numeric value, string class = "") { - var cls = "progress"; - if (len(arguments.class)) cls &= " " & arguments.class; - return '
'; - } - - /** Basecoat horizontal separator. */ - public string function uiSeparator(string class = "") { - var cls = "separator"; - if (len(arguments.class)) cls &= " " & arguments.class; - return '
'; - } - - /** Opens a Basecoat tooltip wrapper. Place trigger element inside, close with uiTooltipEnd(). */ - public string function uiTooltip(required string tip, string class = "") { - var cls = "tooltip"; - if (len(arguments.class)) cls &= " " & arguments.class; - return ''; - } - - public string function uiTooltipEnd() { - return ''; - } - - // ============================================== - // ALERTS - // ============================================== - - /** - * Basecoat alert. Self-closing (returns complete element). Variants: default, destructive. - */ - public string function uiAlert( - string title = "", - string description = "", - string variant = "default", - string icon = "", - string class = "" - ) { - var local = {}; - local.cls = "alert"; - if (arguments.variant == "destructive") local.cls &= " alert-destructive"; - if (len(arguments.class)) local.cls &= " " & arguments.class; - - // Default icons by variant - local.iconName = len(arguments.icon) ? arguments.icon : (arguments.variant == "destructive" ? "alert-triangle" : "info"); - - savecontent variable="local.html" { - writeOutput( - '' - ); - } - return trim(local.html); - } - - // ============================================== - // CARDS - // ============================================== - - /** Opens a Basecoat card. Close with uiCardEnd(). */ - public string function uiCard(string class = "") { - var cls = "card"; - if (len(arguments.class)) cls &= " " & arguments.class; - return '
'; - } - - /** Card header with optional title and description. Self-closing. */ - public string function uiCardHeader(string title = "", string description = "", string class = "") { - var local = {}; - local.cls = "card-header"; - if (len(arguments.class)) local.cls &= " " & arguments.class; - - savecontent variable="local.html" { - writeOutput( - '
' & chr(10) - & (len(arguments.title) ? '

#arguments.title#

' & chr(10) : '') - & (len(arguments.description) ? '

#arguments.description#

' & chr(10) : '') - & '
' - ); - } - return trim(local.html); - } - - /** Opens card content section. Close with uiCardContentEnd(). */ - public string function uiCardContent(string class = "") { - var cls = "card-content"; - if (len(arguments.class)) cls &= " " & arguments.class; - return '
'; - } - - public string function uiCardContentEnd() { - return '
'; - } - - /** Opens card footer section. Close with uiCardFooterEnd(). */ - public string function uiCardFooter(string class = "") { - var cls = "card-footer"; - if (len(arguments.class)) cls &= " " & arguments.class; - return '
'; - } - - public string function uiCardFooterEnd() { - return '
'; - } - - public string function uiCardEnd() { - return '
'; - } - - // ============================================== - // PRIVATE: LUCIDE ICON SVG HELPER - // ============================================== - - /** Returns SVG markup for a Lucide icon. Extend the icon map as needed. */ - private string function $uiLucideIcon(required string name, numeric size = 24, numeric strokeWidth = 2, string class = "") { - var icons = { - "plus": '', - "trash": '', - "pencil": '', - "x": '', - "check": '', - "chevron-right": '', - "chevron-left": '', - "search": '', - "loader": '', - "alert-triangle": '', - "info": '', - "check-circle": '', - "send": '', - "ellipsis": '', - "external-link": '' - }; - - var paths = structKeyExists(icons, arguments.name) ? icons[arguments.name] : ''; - var classAttr = len(arguments.class) ? ' class="#arguments.class#"' : ""; - return '#paths#'; - } - - // ============================================== - // PRIVATE: ID GENERATION - // ============================================== - - /** Generates a short unique ID if none provided. */ - private string function $uiBuildId(string providedId = "", string prefix = "ui") { - if (len(arguments.providedId)) - return arguments.providedId; - return arguments.prefix & "-" & replace(left(createUUID(), 8), "-", "", "all"); - } - - // ============================================== - // DIALOGS - // ============================================== - - /** - * Opens a Basecoat dialog (native element). Close with uiDialogEnd(). - * Optionally renders a trigger button. - */ - public string function uiDialog( - required string title, - string description = "", - string triggerText = "", - string triggerClass = "btn-outline", - string id = "", - string maxWidth = "sm:max-w-[425px]", - string class = "" - ) { - var local = {}; - local.id = $uiBuildId(arguments.id, "dlg"); - local.cls = "dialog w-full #arguments.maxWidth# max-h-[612px]"; - if (len(arguments.class)) local.cls &= " " & arguments.class; - - savecontent variable="local.html" { - writeOutput( - (len(arguments.triggerText) ? '' & chr(10) : '') - & '' & chr(10) - & '
' & chr(10) - & '
' & chr(10) - & '

#arguments.title#

' & chr(10) - & (len(arguments.description) ? '

#arguments.description#

' & chr(10) : '') - & '
' & chr(10) - & '
' - ); - } - return trim(local.html); - } - - /** Closes the dialog content section and opens the footer section. */ - public string function uiDialogFooter() { - return '
'; - } - - /** Closes the dialog footer, adds the close (X) button, and closes the dialog element. */ - public string function uiDialogEnd() { - var xIcon = $uiLucideIcon("x", 24, 2); - return '
'; - } - - // ============================================== - // FORM FIELDS - // ============================================== - - /** - * Basecoat form field. Handles text, email, password, number, tel, url, textarea, select, checkbox, switch. - */ - public string function uiField( - required string label, - required string name, - string type = "text", - string value = "", - string id = "", - string placeholder = "", - string description = "", - string errorMessage = "", - boolean required = false, - boolean disabled = false, - boolean checked = false, - string options = "", - numeric rows = 4, - string class = "" - ) { - var local = {}; - local.id = $uiBuildId(arguments.id, "fld"); - local.hasError = len(arguments.errorMessage); - local.isToggle = (arguments.type == "checkbox" || arguments.type == "switch"); - - // Build input attrs common to all types - local.commonAttrs = 'id="#local.id#" name="#arguments.name#"'; - if (arguments.required) local.commonAttrs &= " required"; - if (arguments.disabled) local.commonAttrs &= " disabled"; - - savecontent variable="local.html" { - // Wrapper - if (local.isToggle) { - writeOutput('
' & chr(10)); - } else { - writeOutput('
' & chr(10)); - } - - // Label before input (non-toggle types) - if (!local.isToggle) - writeOutput('' & chr(10)); - - // Input element by type - if (arguments.type == "textarea") { - writeOutput('' & chr(10)); - } else if (arguments.type == "select") { - writeOutput('' & chr(10)); - } else if (arguments.type == "checkbox") { - writeOutput('' & chr(10)); - } else if (arguments.type == "switch") { - writeOutput('' & chr(10)); - } else { - writeOutput('' & chr(10)); - } - - // Label after input (toggle types) - if (local.isToggle) - writeOutput('' & chr(10)); - - // Description (only when no error) - if (len(arguments.description) && !local.hasError) - writeOutput('

#arguments.description#

' & chr(10)); - - // Error message - if (local.hasError) - writeOutput('

#arguments.errorMessage#

' & chr(10)); - - writeOutput('
'); - } - return trim(local.html); - } - - // ============================================== - // TABLES - // ============================================== - - /** Opens a Basecoat table (table-container + table). Close with uiTableEnd(). */ - public string function uiTable(string class = "") { - var cls = "table"; - if (len(arguments.class)) cls &= " " & arguments.class; - return '
'; - } - - /** Opens the table thead and a tr. Close with uiTableHeaderEnd(). */ - public string function uiTableHeader() { - return ''; - } - - public string function uiTableHeaderEnd() { - return ''; - } - - /** Opens the table tbody. Close with uiTableBodyEnd(). */ - public string function uiTableBody() { - return ''; - } - - public string function uiTableBodyEnd() { - return ''; - } - - /** Opens a table tr. Close with uiTableRowEnd(). */ - public string function uiTableRow(string class = "") { - if (len(arguments.class)) - return ''; - return ''; - } - - public string function uiTableRowEnd() { - return ''; - } - - /** Renders a th cell. */ - public string function uiTableHead(string text = "", string class = "") { - if (len(arguments.class)) - return ''; - return ''; - } - - /** Renders a td cell. */ - public string function uiTableCell(string text = "", string class = "") { - if (len(arguments.class)) - return ''; - return ''; - } - - public string function uiTableEnd() { - return '
#arguments.text##arguments.text##arguments.text##arguments.text#
'; - } - - // ============================================== - // TABS - // ============================================== - - /** Opens a Basecoat tabs container. Close with uiTabsEnd(). */ - public string function uiTabs(string defaultTab = "", string class = "") { - var cls = "tabs"; - if (len(arguments.class)) cls &= " " & arguments.class; - var dataDefault = len(arguments.defaultTab) ? ' data-default="#arguments.defaultTab#"' : ""; - return '
'; - } - - /** Opens the tabs-list container. Close with uiTabListEnd(). */ - public string function uiTabList(string class = "") { - var cls = "tabs-list"; - if (len(arguments.class)) cls &= " " & arguments.class; - return '
'; - } - - public string function uiTabListEnd() { - return '
'; - } - - /** Renders a tab trigger button. */ - public string function uiTabTrigger(required string value, required string text, string class = "") { - var cls = "tabs-trigger"; - if (len(arguments.class)) cls &= " " & arguments.class; - return ''; - } - - /** Opens a tab content panel. Close with uiTabContentEnd(). */ - public string function uiTabContent(required string value, string class = "") { - var cls = "tabs-content"; - if (len(arguments.class)) cls &= " " & arguments.class; - return '
'; - } - - public string function uiTabContentEnd() { - return '
'; - } - - public string function uiTabsEnd() { - return '
'; - } - - // ============================================== - // DROPDOWNS - // ============================================== - - /** Opens a CSS-only dropdown (details/summary). Close with uiDropdownEnd(). */ - public string function uiDropdown(required string text, string triggerClass = "btn-outline", string class = "") { - var cls = "dropdown"; - if (len(arguments.class)) cls &= " " & arguments.class; - return '
#arguments.text#
    '; - } - - /** Renders a dropdown menu item. */ - public string function uiDropdownItem(required string text, string href = "##", string class = "") { - var clsAttr = len(arguments.class) ? ' class="#arguments.class#"' : ""; - return '
  • #arguments.text#
  • '; - } - - /** Renders a separator line inside a dropdown. */ - public string function uiDropdownSeparator() { - return '

  • '; - } - - public string function uiDropdownEnd() { - return '
'; - } - - // ============================================== - // PAGINATION - // ============================================== - - /** - * Renders a pagination nav with prev/next, page window, and ellipsis. - */ - public string function uiPagination( - required numeric currentPage, - required numeric totalPages, - required string baseUrl, - string pageParam = "page", - numeric windowSize = 2, - string class = "" - ) { - var local = {}; - local.cls = "pagination"; - if (len(arguments.class)) local.cls &= " " & arguments.class; - - // URL builder helper - local.sep = (find("?", arguments.baseUrl) > 0) ? "&" : "?"; - local.prevIcon = $uiLucideIcon("chevron-left", 16, 2); - local.nextIcon = $uiLucideIcon("chevron-right", 16, 2); - - savecontent variable="local.html" { - writeOutput(''); - } - return trim(local.html); - } - - // ============================================== - // BREADCRUMB - // ============================================== - - /** Opens a Basecoat breadcrumb nav. Close with uiBreadcrumbEnd(). */ - public string function uiBreadcrumb(string class = "") { - var cls = "breadcrumb"; - if (len(arguments.class)) cls &= " " & arguments.class; - return ''; - } - - // ============================================== - // SIDEBAR - // ============================================== - - /** Opens a Basecoat sidebar (aside + nav). Close with uiSidebarEnd(). */ - public string function uiSidebar(string class = "") { - var cls = "sidebar"; - if (len(arguments.class)) cls &= " " & arguments.class; - return ''; - } - -} diff --git a/packages/basecoat/CLAUDE.md b/packages/basecoat/CLAUDE.md deleted file mode 100644 index 6e15868ccc..0000000000 --- a/packages/basecoat/CLAUDE.md +++ /dev/null @@ -1,277 +0,0 @@ -# wheels-basecoat - -## What This Is - -A Wheels framework package providing CFML view helpers that generate Basecoat UI component markup — shadcn/ui-quality design using plain HTML + Tailwind CSS classes. No React, no build step. Works with or without wheels-hotwire. - -This package is part of the Wheels first-party package collection, hosted in the main Wheels repository under `packages/basecoat/`. Activate by copying to `vendor/basecoat/`. - -## Package Architecture - -Standard Wheels package. The main CFC (`Basecoat.cfc`) contains `init()` and all public methods. Wheels injects every public method into controller and view scopes via PackageLoader. Call them as `#functionName()#` in views. - -## File Structure - -``` -packages/basecoat/ -├── CLAUDE.md # This file (Claude Code reads first) -├── Basecoat.cfc # Main package CFC — ALL helpers here -├── package.json # Package manifest -├── index.cfm # Package UI page (Wheels debug panel) -├── box.json # CommandBox package metadata -└── .ai/ - └── ARCHITECTURE.md # Full architecture doc (long-form context) -``` - -### Single CFC Requirement - -All public helper functions must be methods in `Basecoat.cfc`. Wheels packages inject methods from **one CFC only** (the one matching the directory name). - -## Coding Conventions - -- CFScript syntax (`component { }`, `function name() { }`) -- Typed function parameters with defaults: `string name = "default"` -- `var local = {};` for local variable scopes -- Function names: camelCase with `ui` prefix for components, `basecoat` prefix for infrastructure -- View helpers return strings -- Build multi-line HTML via `savecontent variable="local.html" { writeOutput(...); }` -- Double quotes for HTML attributes -- Generate unique IDs via `replace(left(createUUID(), 8), "-", "", "all")` when caller omits ID - -### Naming Patterns - -- `basecoat*` prefix: infrastructure (basecoatIncludes) -- `ui*` prefix: component open tags (uiButton, uiCard, uiDialog) -- `ui*End` suffix: closing tags for block components (uiCardEnd, uiDialogEnd) -- `ui*Header/Content/Footer/Body` + End: sub-section helpers -- `$ui*` prefix (private): internal utilities ($uiLucideIcon, $uiBuildId) - -## Turbo/Hotwire Awareness - -This plugin does NOT depend on wheels-hotwire, but is **aware** of Turbo patterns: -- `uiButton()` accepts `turboConfirm` and `turboMethod` args (outputs `data-turbo-confirm` / `data-turbo-method` attributes) — these are inert without Turbo present -- `uiButton()` accepts `close=true` for dialog dismiss (native `` API, no Turbo needed) -- Components generate standard HTML that works inside `` elements without modification - -## Basecoat Markup Reference - -All `ui*` helpers MUST generate markup matching these patterns from basecoatui.com v0.3.x: - -### Buttons -```html - - - - - - - - - - -Link as button -``` - -**Class construction:** Parts joined by hyphens in order: `btn` + size? + icon? + variant? -- Primary uses bare `btn` (no variant suffix) -- Default size (md) omits size prefix -- Examples: `btn`, `btn-outline`, `btn-sm`, `btn-sm-outline`, `btn-icon`, `btn-icon-outline`, `btn-lg-icon-destructive` - -### Badges -```html -Default -Secondary -Destructive -Outline -``` - -### Alerts -```html - - -``` - -### Cards -```html -
-
-

Title

-

Description

-
-
- -
-``` - -### Dialogs (Modals) -Native `` with `showModal()`: -```html - - - -
-
-

Title

-

Description

-
-
-
- -
-
-``` - -### Form Fields -```html - -
- - -
- - -
- - -

Description

-
- - -
- - -

Error message

-
- - - - - - - - -
- - -
- - -
- - -
-``` - -### Tables -```html -
- - - -
Header
Cell
-
-``` - -### Tabs (uses Basecoat JS) -```html -
-
- - -
-
Content 1
-
Content 2
-
-``` - -### Progress -```html -
-``` - -### Skeleton -```html -
-``` - -### Spinner -```html -
-``` - -### Tooltip -```html - -``` - -### Separator -```html -
-``` - -## Implementation Order - -### Phase 1: Infrastructure + Simple Components -1. `init()` — version, metadata -2. `basecoatIncludes()` — Basecoat CSS + Alpine.js script tags -3. `uiButton()` — **reference implementation** (already provided in seed) -4. `uiBadge()` — simple single-element (already provided in seed) -5. `uiIcon()` — public wrapper around `$uiLucideIcon()` -6. `uiSpinner()`, `uiSkeleton()`, `uiProgress()` — simple single-element helpers -7. `uiSeparator()` — trivial `
` -8. `uiTooltip()` / `uiTooltipEnd()` - -### Phase 2: Block Components -9. `uiAlert()` — self-closing block (title + description, no End needed) -10. `uiCard()` / `uiCardHeader()` / `uiCardContent()` / `uiCardFooter()` / `uiCardContentEnd()` / `uiCardFooterEnd()` / `uiCardEnd()` -11. `uiDialog()` / `uiDialogFooter()` / `uiDialogEnd()` - -### Phase 3: Form Components -12. `uiField()` — the big one: label + input + description + error, with type switching (text, email, textarea, select, checkbox, switch, etc.) - -### Phase 4: Complex Components -13. `uiTable()` / `uiTableHeader()` / `uiTableBody()` / `uiTableRow()` / `uiTableHead()` / `uiTableCell()` + all End variants -14. `uiTabs()` / `uiTabList()` / `uiTabTrigger()` / `uiTabContent()` + End variants -15. `uiDropdown()` / `uiDropdownItem()` / `uiDropdownSeparator()` / `uiDropdownEnd()` -16. `uiPagination()` - -### Phase 5: Layout Components -17. `uiSidebar()` family -18. `uiBreadcrumb()` family - -## Icon System - -`$uiLucideIcon(name, size, strokeWidth)` is a private method that returns SVG strings for Lucide icons. The seed includes ~12 common icons. Claude Code should extend the icon map as components need them. Each icon is a struct entry mapping name → SVG inner paths. - -`uiIcon()` is the public wrapper: -```cfml -#uiIcon(name="trash", size=16)# -#uiIcon(name="plus", size=20, class="text-muted-foreground")# -``` - -## Testing - -Test helpers by verifying output strings. Focus on: -- Basecoat class construction (especially compound button classes: `btn-sm-icon-destructive`) -- ARIA attributes on dialogs (labelledby, describedby) and form fields (invalid, describedby) -- Unique ID generation when IDs are omitted -- Error state rendering in `uiField()` (border-destructive class, error paragraph) -- Checkbox/switch layout (flex row, label after input) -- Standard vs. checkbox/switch field layout differences diff --git a/packages/basecoat/README.md b/packages/basecoat/README.md deleted file mode 100644 index d118f0acdc..0000000000 --- a/packages/basecoat/README.md +++ /dev/null @@ -1,139 +0,0 @@ -# wheels-basecoat - -A Wheels package that ships CFML view helpers for [Basecoat UI](https://basecoatui.com) — shadcn/ui-quality components rendered as plain HTML + Tailwind CSS classes. No React, no build step. Works with or without [wheels-hotwire](../hotwire/README.md). - -## Requirements - -- Wheels 3.0+ -- Lucee 5+ or Adobe ColdFusion 2018+ -- Basecoat CSS (served from your app — see **Serving Basecoat CSS** below) - -## Installation - -```bash -# Activate the package -cp -r packages/basecoat vendor/basecoat - -# Restart or reload your app -wheels reload -``` - -All `ui*` helpers become available in views and controllers via the package mixin system. - -## Configuration - -Basecoat has no `set()`-style application settings. Configuration is passed to helpers directly — the only helper with knobs is `basecoatIncludes()`: - -```cfml -#basecoatIncludes( - alpine = true, - alpineVersion = "3", - basecoatCSSPath = "/plugins/basecoat/assets/basecoat/basecoat.min.css", - turboAware = true -)# -``` - -| Argument | Default | Description | -|---|---|---| -| `alpine` | `true` | Include the Alpine.js CDN script (needed for dropdowns, dialogs with transitions, tabs). | -| `alpineVersion` | `"3"` | Alpine.js major version. | -| `basecoatCSSPath` | `"/plugins/basecoat/assets/basecoat/basecoat.min.css"` | Path to the Basecoat CSS file your app serves. | -| `turboAware` | `true` | Emit the `` tag — safe default when paired with wheels-hotwire. | - -### Serving Basecoat CSS - -This package ships helpers, not CSS assets. Grab `basecoat.min.css` from the [Basecoat UI releases](https://basecoatui.com) and serve it from your app — for example under `public/assets/basecoat.min.css` — then point `basecoatCSSPath` at it. - -## Usage - -### 1. Include CSS + JS in your layout - -```cfm - - - - - My App - #basecoatIncludes(basecoatCSSPath="/assets/basecoat.min.css")# - - - #includeContent()# - - -``` - -### 2. Render components in views - -```cfm - -#uiButton(text="Save", variant="primary")# -#uiButton(text="Cancel", variant="outline", href="/users")# -#uiButton(text="Delete", variant="destructive", turboConfirm="Are you sure?")# - - -#uiBadge(text="Active", variant="default")# -#uiBadge(text="Archived", variant="secondary")# - - -#uiAlert(title="Heads up", description="Your trial expires in 3 days.", variant="default")# - - -#uiCard()# - #uiCardHeader(title="Order ##1234", description="Placed 2 days ago")# - #uiCardContent()# -

Order details go here.

- #uiCardContentEnd()# - #uiCardFooter()# - #uiButton(text="View", href="/orders/1234")# - #uiCardFooterEnd()# -#uiCardEnd()# - - -#uiField(label="Email", name="user[email]", type="email", required=true)# -#uiField(label="Bio", name="user[bio]", type="textarea", rows=4)# -#uiField(label="Role", name="user[role]", type="select", options="admin:Admin,user:User")# -``` - -### 3. Component reference - -All helpers live on `Basecoat.cfc` and are injected into controllers and views. - -| Category | Helpers | -|---|---| -| Includes | `basecoatIncludes` | -| Buttons & icons | `uiButton`, `uiBadge`, `uiIcon`, `uiSpinner`, `uiSkeleton`, `uiProgress`, `uiSeparator` | -| Tooltip | `uiTooltip` / `uiTooltipEnd` | -| Alert | `uiAlert` | -| Card | `uiCard` / `uiCardHeader` / `uiCardContent` / `uiCardContentEnd` / `uiCardFooter` / `uiCardFooterEnd` / `uiCardEnd` | -| Dialog | `uiDialog` / `uiDialogFooter` / `uiDialogEnd` | -| Forms | `uiField` (text, email, textarea, select, checkbox, switch, …) | -| Table | `uiTable` / `uiTableHeader` / `uiTableBody` / `uiTableRow` / `uiTableHead` / `uiTableCell` + matching `*End` helpers | -| Tabs | `uiTabs` / `uiTabList` / `uiTabTrigger` / `uiTabContent` + matching `*End` helpers | -| Dropdown | `uiDropdown` / `uiDropdownItem` / `uiDropdownSeparator` / `uiDropdownEnd` | -| Pagination | `uiPagination` | -| Layout | `uiBreadcrumb` / `uiBreadcrumbItem` / `uiBreadcrumbSeparator` / `uiBreadcrumbEnd`, `uiSidebar` / `uiSidebarSection` / `uiSidebarItem` + matching `*End` helpers | - -### Turbo-awareness - -Basecoat does not depend on wheels-hotwire, but its helpers are Turbo-friendly: - -- `uiButton()` accepts `turboConfirm` and `turboMethod` — emitted as `data-turbo-confirm` / `data-turbo-method` attributes and ignored when Turbo is absent. -- `uiButton(close=true)` closes the enclosing `` via the native `HTMLDialogElement.close()` API — no JS library required. -- All block components generate markup that renders correctly inside `` elements. - -## Deactivating - -```bash -rm -rf vendor/basecoat -wheels reload -``` - -## Reference - -- `packages/basecoat/CLAUDE.md` — markup reference, naming conventions, component categories -- `packages/basecoat/.ai/ARCHITECTURE.md` — design principles, phased component inventory -- [Basecoat UI](https://basecoatui.com) — upstream CSS component library - -## License - -MIT diff --git a/packages/basecoat/box.json b/packages/basecoat/box.json deleted file mode 100644 index 54ea45a937..0000000000 --- a/packages/basecoat/box.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "wheels-basecoat", - "slug": "wheels-basecoat", - "version": "0.1.0", - "author": "Wheels Core Team", - "homepage": "https://wheels.dev", - "repository": { "type": "git", "URL": "https://github.com/wheels-dev/wheels.git" }, - "bugs": "https://github.com/wheels-dev/wheels/issues", - "keywords": ["wheels", "basecoat", "ui", "components", "shadcn", "tailwind", "design-system"], - "type": "cfwheels-plugins", - "license": [{ "type": "MIT", "URL": "https://opensource.org/licenses/MIT" }], - "optionalDependencies": { - "wheels-hotwire": ">=0.1.0" - }, - "description": "Basecoat UI component helpers for Wheels. shadcn/ui-quality design using plain HTML + Tailwind CSS. No React required." -} diff --git a/packages/basecoat/index.cfm b/packages/basecoat/index.cfm deleted file mode 100644 index ee6b9d1826..0000000000 --- a/packages/basecoat/index.cfm +++ /dev/null @@ -1,65 +0,0 @@ -

wheels-basecoat

-

- Basecoat UI component helpers for Wheels. - shadcn/ui-quality design using plain HTML + Tailwind CSS. No React required. -

- -

Quick Start

-

Add ##basecoatIncludes()## to your layout's <head>. Then use ##uiButton()##, ##uiCard()##, etc. in your views.

- -

Helpers

- -

Includes

-
  • basecoatIncludes() — Basecoat CSS + Alpine.js tags
- -

Buttons & Badges

-
    -
  • uiButton(text, [variant], [size], [icon], [href], ...)
  • -
  • uiBadge(text, [variant])
  • -
- -

Layout

-
    -
  • uiCard() / uiCardHeader() / uiCardContent() / uiCardFooter() / uiCardEnd()
  • -
  • uiAlert(title, description, [variant])
  • -
  • uiSeparator()
  • -
  • uiSidebar() family
  • -
- -

Forms

-
    -
  • uiField(label, name, [type], [value], [errorMessage], [description], ...)
  • -
- -

Data Display

-
    -
  • uiTable() / uiTableHeader() / uiTableBody() / uiTableRow() / uiTableHead() / uiTableCell() + End variants
  • -
  • uiPagination(currentPage, totalPages, baseUrl)
  • -
- -

Interactive

-
    -
  • uiDialog(id, title, trigger, ...) / uiDialogEnd()
  • -
  • uiTabs(default) / uiTabList() / uiTabTrigger() / uiTabContent() + End variants
  • -
  • uiDropdown(trigger) / uiDropdownItem() / uiDropdownEnd()
  • -
  • uiToast(message, [variant])
  • -
  • uiTooltip(tip) / uiTooltipEnd()
  • -
- -

Feedback

-
    -
  • uiProgress(value)
  • -
  • uiSkeleton([lines], [height], [width])
  • -
  • uiSpinner()
  • -
- -

Icons

-
  • uiIcon(name, [size], [strokeWidth]) — Lucide SVG icons
- -

Companion Plugin

-

Pair with wheels-hotwire for Turbo Drive (instant navigation), Turbo Frames (partial page updates), Turbo Streams (multi-target updates), and Hotwire Native (mobile apps).

- -

Links

- diff --git a/packages/basecoat/package.json b/packages/basecoat/package.json deleted file mode 100644 index f1849e281a..0000000000 --- a/packages/basecoat/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "wheels-basecoat", - "version": "0.1.0", - "author": "Wheels Core Team", - "description": "Basecoat UI component helpers for Wheels. shadcn/ui-quality design using plain HTML + Tailwind CSS. No React required.", - "wheelsVersion": ">=3.0", - "provides": { - "mixins": "controller", - "services": [], - "middleware": [] - }, - "dependencies": {} -} diff --git a/packages/basecoat/tests/BasecoatComplexSpec.cfc b/packages/basecoat/tests/BasecoatComplexSpec.cfc deleted file mode 100644 index 64a6787105..0000000000 --- a/packages/basecoat/tests/BasecoatComplexSpec.cfc +++ /dev/null @@ -1,352 +0,0 @@ -component extends="wheels.WheelsTest" { - - function beforeAll() { - variables.bc = createObject("component", "plugins.basecoat.Basecoat").init(); - } - - function run() { - describe("Basecoat Phase 2-5 — Complex Components", () => { - - describe("uiDialog", () => { - - it("includes aria-labelledby matching generated ID", () => { - var html = variables.bc.uiDialog(title="My Dialog"); - // Extract the dialog ID from the html — id="dlg-XXXXXXXX" - var idMatch = reMatch('id="(dlg-[^"]+)"', html); - expect(arrayLen(idMatch)).toBeGTE(1); - var dlgId = reReplace(idMatch[1], 'id="([^"]+)"', "\1"); - expect(html).toMatch('aria-labelledby="#dlgId#-title"'); - expect(html).toMatch('id="#dlgId#-title"'); - }); - - it("includes aria-describedby when description provided", () => { - var html = variables.bc.uiDialog(title="T", description="Some desc"); - var idMatch = reMatch('id="(dlg-[^"]+)"', html); - var dlgId = reReplace(idMatch[1], 'id="([^"]+)"', "\1"); - expect(html).toMatch('aria-describedby="#dlgId#-desc"'); - expect(html).toMatch('id="#dlgId#-desc"'); - }); - - it("omits aria-describedby when no description", () => { - var html = variables.bc.uiDialog(title="T"); - expect(html).notToMatch("aria-describedby"); - }); - - it("renders trigger button when triggerText provided", () => { - var html = variables.bc.uiDialog(title="T", triggerText="Open Dialog"); - expect(html).toMatch('>Open Dialog<'); - expect(html).toMatch("showModal()"); - }); - - it("omits trigger button when triggerText empty", () => { - var html = variables.bc.uiDialog(title="T"); - expect(html).notToMatch("showModal()"); - }); - - it("respects explicit ID", () => { - var html = variables.bc.uiDialog(title="T", id="my-modal"); - expect(html).toMatch('id="my-modal"'); - expect(html).toMatch('aria-labelledby="my-modal-title"'); - }); - - it("uiDialogEnd includes close button with X SVG", () => { - var html = variables.bc.uiDialogEnd(); - expect(html).toMatch('aria-label="Close dialog"'); - expect(html).toMatch("this.closest('dialog').close()"); - expect(html).toMatch(''); - }); - - it("uiDialogFooter closes section and opens footer", () => { - expect(variables.bc.uiDialogFooter()).toBe('
'); - }); - - }); - - describe("uiField", () => { - - it("renders text input with label above", () => { - var html = variables.bc.uiField(label="Name", name="user[name]"); - expect(html).toMatch('
'); - }); - - }); - - describe("uiDropdown family", () => { - - it("uses details/summary pattern", () => { - var html = variables.bc.uiDropdown(text="Menu"); - expect(html).toMatch(''); - }); - - }); - - describe("uiPagination", () => { - - it("renders pagination nav", () => { - var html = variables.bc.uiPagination(currentPage=3, totalPages=10, baseUrl="/posts"); - expect(html).toMatch(' { - var html = variables.bc.uiPagination(currentPage=1, totalPages=5, baseUrl="/posts"); - expect(html).toMatch('opacity-50'); - // prev link should be a span, not an anchor - expect(html).notToMatch('aria-label="Previous page"'); - }); - - it("disables next on last page", () => { - var html = variables.bc.uiPagination(currentPage=5, totalPages=5, baseUrl="/posts"); - expect(html).notToMatch('aria-label="Next page"'); - }); - - it("renders page links in window", () => { - var html = variables.bc.uiPagination(currentPage=5, totalPages=10, baseUrl="/posts", windowSize=2); - expect(html).toMatch('page=3'); - expect(html).toMatch('page=4'); - expect(html).toMatch('page=6'); - expect(html).toMatch('page=7'); - }); - - it("marks current page", () => { - var html = variables.bc.uiPagination(currentPage=3, totalPages=10, baseUrl="/posts"); - expect(html).toMatch('aria-current="page"'); - }); - - }); - - describe("uiBreadcrumb family", () => { - - it("renders nav with aria-label=Breadcrumb", () => { - var html = variables.bc.uiBreadcrumb(); - expect(html).toMatch(''); - }); - - }); - - describe("uiSidebar family", () => { - - it("renders aside.sidebar with nav", () => { - var html = variables.bc.uiSidebar(); - expect(html).toMatch(''); - }); - - }); - - }); - } - -} diff --git a/packages/basecoat/tests/BasecoatSimpleSpec.cfc b/packages/basecoat/tests/BasecoatSimpleSpec.cfc deleted file mode 100644 index a438c76e34..0000000000 --- a/packages/basecoat/tests/BasecoatSimpleSpec.cfc +++ /dev/null @@ -1,198 +0,0 @@ -component extends="wheels.WheelsTest" { - - function beforeAll() { - variables.bc = createObject("component", "plugins.basecoat.Basecoat").init(); - } - - function run() { - describe("Basecoat Phase 1+2 — Simple Components", () => { - - describe("uiButton", () => { - - it("renders primary button with bare btn class", () => { - var html = variables.bc.uiButton(text="Save"); - expect(html).toMatch(' - Cancel - - -``` - -**What happens:** - -1. User clicks "Edit" on contact #5 -2. Turbo intercepts the click, sees it's inside `` -3. Fetches `GET /contacts/5/edit` -4. Server renders the full edit page (with layout and everything) -5. Turbo extracts *only* the `` from the response -6. Swaps it into the existing page — the edit form appears inline -7. User submits the form — same thing happens in reverse -8. Server processes the update, redirects to index -9. Turbo fetches the index, extracts the matching frame, swaps it back - -**The server has no idea this is happening.** It renders full pages every time. Turbo Frames handle the extraction client-side. This is the magic — you don't need special endpoints, partials, or JSON. Your existing Wheels views work with minimal changes (just add the `` wrappers). - -**Lazy loading frames:** You can also use frames to lazy-load parts of the page: - -```html - - -

Loading activity...

-
-``` - -This means the main page renders instantly, and the sidebar loads asynchronously. For Miranda, imagine a production dashboard where the summary loads instantly and the detailed charts lazy-load in parallel. - ---- - -### Turbo Streams — Multi-Target Updates - -**What it does:** While Frames update a single frame, Streams let you update multiple unrelated parts of the page in a single response. They use a simple HTML format with eight actions: `append`, `prepend`, `replace`, `update`, `remove`, `before`, `after`, and `refresh`. - -**When you need Streams instead of Frames:** -- A form submission needs to update a list AND a counter AND clear itself -- A delete action needs to remove an item AND update a total -- A real-time update needs to push content to multiple sections - -**How it works with form submissions:** When your server responds to a form POST with `Content-Type: text/vnd.turbo-stream.html`, Turbo processes the stream actions instead of doing a normal redirect. - -```html - - - - - - - - - - - - - - - - - -``` - -One response, three DOM updates, zero JavaScript. The server decides what changes — the client just executes. - -**Over WebSockets/SSE:** Streams can also be delivered over WebSocket or Server-Sent Events for real-time updates. This is how you'd build a live production dashboard in Miranda — the server pushes Turbo Stream HTML fragments over SSE when machine status changes: - -```html - - - - -``` - -No JavaScript, no WebSocket client code, no state management. Just HTML pushed from the server. - ---- - -### Stimulus — The Minimal JavaScript Layer - -**What it does:** Stimulus is a tiny framework for the JavaScript you *do* need — things like toggling visibility, copying to clipboard, dismissing alerts, or managing complex form interactions that don't warrant a server round-trip. - -**The philosophy:** Stimulus doesn't render HTML. It attaches behavior to HTML that already exists on the page. It uses data attributes to connect HTML elements to JavaScript controllers. - -**How it works:** - -```html - -
- - -
-``` - -```javascript -// toggle_controller.js -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = ["content"] - - switch() { - this.contentTarget.classList.toggle("hidden") - } -} -``` - -**The naming convention:** -- `data-controller="toggle"` → looks for `toggle_controller.js` -- `data-action="click->toggle#switch"` → on click, call the `switch()` method on the toggle controller -- `data-toggle-target="content"` → exposes this element as `this.contentTarget` in the controller - -**Why this matters for Wheels:** Stimulus controllers are reusable, composable, and completely decoupled from your backend. A `toggle` controller works everywhere. A `clipboard` controller works everywhere. You build a small library of these and use them via data attributes in your CFML views. No build step required if you use import maps or a CDN. - -**Stimulus + Turbo work together perfectly** because Stimulus watches for DOM mutations. When Turbo swaps in new HTML (via Drive, Frames, or Streams), Stimulus automatically connects controllers to the new elements. No manual initialization needed. - ---- - -### The Progressive Enhancement Hierarchy - -The key mental model for Hotwire adoption is progressive enhancement: - -``` -HTML (your existing Wheels views) - → add Turbo Drive (instant SPA feel, zero effort) - → add Turbo Frames (partial page updates where useful) - → add Turbo Streams (multi-target updates, real-time) - → add Stimulus (client-side behavior when needed) -``` - -You can stop at any level. Turbo Drive alone transforms the perceived performance of any Wheels app. Most apps only need Drive + Frames for 90% of their interactivity needs. - ---- - -## Part 2: Why Hotwire Benefits Wheels Even Without Mobile - -### 1. It's the Modern Answer to "Should I Use a JavaScript Framework?" - -Every Wheels developer building a new app faces the question: do I need React/Vue/Angular? Hotwire gives Wheels a clear answer: **No.** You keep writing CFML views, add Turbo, and you get SPA-like interactivity without: -- A separate frontend build pipeline -- A JSON API layer -- Duplicated business logic -- A JavaScript developer on the team - -This is a competitive advantage for Wheels against both other CFML frameworks (which have no story here) and against Django/Rails competitors (where Django is also adopting Hotwire). - -### 2. It Aligns With the HTMX Work Already Done - -The Wheels community already has an HTMX plugin. Hotwire/Turbo is philosophically identical — both send HTML over the wire. But Turbo has a much larger ecosystem, active corporate sponsorship (37signals), native mobile support, and the Stimulus companion library. The HTMX plugin validates that the Wheels community wants this pattern. A Hotwire plugin is the more complete version. - -### 3. It Makes Wheels AI-Friendly by Default - -This connects directly to your `.ai` folder work and CLAUDE.md strategy. An AI agent (like Claude Code) working on a Wheels + Hotwire app doesn't need to context-switch between CFML and a JavaScript SPA framework. It writes CFML views with Turbo Frame annotations. The interactivity comes from the markup patterns, not from complex JavaScript state management. This dramatically reduces the cognitive load and context size for AI-assisted development. - -### 4. It Creates the Foundation for Mobile - -Even if someone never builds a mobile app, building their Wheels app with Turbo Frames means their pages are already decomposed into independent, URL-addressable segments. If they later decide to go mobile with Hotwire Native, those framed segments become individual mobile screens with zero additional work. - ---- - -## Part 3: The wheels-hotwire Plugin Architecture - -### Plugin Structure - -``` -wheels-hotwire/ -├── CLAUDE.md # AI context for Claude Code -├── events/ -│ └── onapplicationstart.cfm # Plugin initialization -├── controllers/ -│ └── HotwireController.cfc # Base controller mixin -├── helpers/ -│ ├── TurboStreamHelper.cfc # turboStream() view helper -│ ├── TurboFrameHelper.cfc # turboFrame() view helper -│ └── HotwireNativeHelper.cfc # hotwireNativeApp() detection -├── views/ -│ └── hotwire/ -│ ├── _turbo_includes.cfm # Script tags for layout inclusion -│ └── pathConfiguration.cfm # Native path config JSON endpoint -├── config/ -│ ├── settings.cfm # Plugin defaults -│ └── path-configuration.json # Default Hotwire Native path rules -├── assets/ -│ └── stimulus/ -│ └── controllers/ # Bundled Stimulus controllers -│ ├── bridge/ # Bridge Components for Native -│ │ ├── nav_button_controller.js -│ │ ├── menu_controller.js -│ │ └── form_controller.js -│ ├── toggle_controller.js -│ ├── clipboard_controller.js -│ ├── autosave_controller.js -│ └── flash_controller.js -└── tests/ -``` - -### Core Feature: Turbo Integration - -#### Layout Helper — `turboIncludes()` - -Drop this into your `` and Turbo Drive is active immediately: - -```html - - - - #turboIncludes()# - - -``` - -Options: `turboIncludes(drive=true, cacheControl="no-preview", stimulus=true)` - -#### Turbo Frame View Helper — `turboFrame()` - -Generates `` tags from Wheels conventions: - -```html - -#turboFrame(id="contact_#contact.id#")# -
- #contact.name# — #linkTo(text="Edit", action="edit", key=contact.id)# -
-#turboFrameEnd()# - - -#turboFrame(id="activity_feed", src="/dashboard/activity", loading="lazy")# -

Loading...

-#turboFrameEnd()# - - -#turboFrame(id="sidebar_item", target="_top")# - ... -#turboFrameEnd()# -``` - -#### Turbo Stream View Helper — `turboStream()` - -Generates Turbo Stream responses: - -```cfml - - - - - - - - - - - - -``` - -The `renderTurboStream()` function sets `Content-Type: text/vnd.turbo-stream.html` and renders the stream elements. - -#### Request Detection — `isHotwireRequest()` - -Turbo requests include an `Accept` header with `text/vnd.turbo-stream.html`. The plugin provides detection helpers: - -```cfml - -isHotwireRequest() -isTurboFrameRequest() -turboFrameRequestId() -hotwireNativeApp() -``` - -For frame requests, the plugin can automatically skip the layout (since Turbo only extracts the matching frame anyway, but sending a bare frame is more efficient): - -```cfml - - - - -``` - -### Core Feature: Hotwire Native Support - -#### Mobile Detection & Conditional Rendering - -The key helper for Hotwire Native is detecting the native User-Agent and conditionally hiding web-only chrome: - -```html - - - - - -
- #includeContent()# -
- - -
- -
-
-``` - -In the native app, the navigation bar is provided by the native iOS/Android shell. Your Wheels views just render the content. - -#### Server-Side Navigation Helpers - -These are the commands that tell the native app how to handle navigation after form submissions: - -```cfml - - - - - - - - - - - - - -recedeOrRedirectTo(...) -refreshOrRedirectTo(...) -resumeOrRedirectTo(...) -``` - -These work by redirecting to special path-based routes that the native app intercepts. On the web, they redirect normally. On native, the app catches the redirect URL and performs the native navigation action. - -#### Path Configuration Endpoint - -The plugin serves a JSON file that tells the native app how to handle different URL patterns: - -```cfml - - - - -``` - -Any URL ending in `/new` or `/edit` automatically opens as a native modal. Everything else pushes onto the navigation stack. And because this is served from the server, you can change this behavior *without an app store update*. - -#### Bridge Components (Stimulus Controllers for Native) - -Bridge Components are Stimulus controllers that communicate with the native iOS/Android shell. They let you trigger native UI elements from your server-rendered HTML: - -```html - -
-
- - - - Share - -``` - -The Wheels plugin would bundle the common Bridge Component Stimulus controllers so developers don't have to write Swift/Kotlin to get native buttons, menus, and form handling. - -### Configuration - -```cfml - - -``` - ---- - -## Part 4: Implementation Roadmap - -### Phase 1: Turbo Drive (Week 1) - -Minimum viable plugin — just the script inclusion and request detection helpers. Any Wheels app gets instant SPA-like performance by adding `turboIncludes()` to their layout. - -**Deliverables:** -- `turboIncludes()` helper -- `isHotwireRequest()` / `isTurboFrameRequest()` detection -- Automatic layout skipping for frame requests -- Documentation + demo app - -### Phase 2: Turbo Frames & Streams (Weeks 2-3) - -View helpers for generating frame and stream markup. Turbo Stream response rendering. - -**Deliverables:** -- `turboFrame()` / `turboFrameEnd()` helpers -- `turboStreamAppend/Prepend/Replace/Update/Remove()` helpers -- `renderTurboStream()` controller method -- Partial rendering support for stream templates - -### Phase 3: Stimulus Integration (Week 3-4) - -Bundled Stimulus controllers for common UI patterns. Import map or asset pipeline integration. - -**Deliverables:** -- `stimulusIncludes()` helper with controller auto-loading -- 5-6 bundled controllers (toggle, clipboard, flash, autosave, tabs, modal) -- CFML tag helpers that generate the correct data attributes -- Controller generator (CLI or scaffolding) - -### Phase 4: Hotwire Native (Weeks 5-8) - -The mobile layer. Path configuration serving, native detection, navigation helpers, Bridge Components. - -**Deliverables:** -- `hotwireNativeApp()` detection -- `recedeOrRedirectTo()` / `refreshOrRedirectTo()` / `resumeOrRedirectTo()` -- Path configuration endpoint and JSON builder -- Bridge Component Stimulus controllers -- Template iOS app shell project (Swift, ~100 lines) -- Template Android app shell project (Kotlin, ~100 lines) -- Documentation: "From Wheels web app to App Store in a weekend" - ---- - -## Part 5: What This Means for Wheels Strategically - -### The Story You Can Tell - -"Wheels is the only CFML framework with built-in mobile app support. Add the Hotwire plugin, write your views once, and deploy to web, iOS, and Android from one codebase. No JSON API. No React Native. No separate frontend team." - -No other CFML framework can say this. ColdBox can't. FW/1 can't. And it's not just a sales pitch — it's architecturally true. The same CFML views, the same models, the same validations, the same controller logic. Turbo Frames on web become individual screens in the native app. The path configuration controls native navigation from the server. - -### The Ecosystem Play - -Hotwire has been adopted by Symfony (PHP), Django (Python), Flask, Spring Boot, and .NET. By building a first-class Wheels integration, you position Wheels alongside these frameworks in the Hotwire ecosystem. The community at hotwire.io already lists frameworks from multiple languages. Adding Wheels/CFML to that list raises Wheels' visibility beyond the CFML world. - -### The AI-Development Multiplier - -Claude Code generating a Wheels + Hotwire app needs to understand: -- CFML (it already does) -- HTML with Turbo Frame/Stream annotations (simple markup patterns) -- Stimulus controllers (small, formulaic JS files) - -Compare this to Claude Code generating a Wheels + React Native app, which would need to understand CFML, JSON API design, React, React Native, navigation libraries, state management, TypeScript, and native build tooling. The Hotwire approach reduces the AI-development surface area by an order of magnitude. - ---- - -## Part 6: Basecoat UI Integration — The Design System Layer - -### The Real Problem Basecoat Solves - -People don't adopt React because they love `useState`. They adopt it because shadcn/ui makes everything they build look professionally designed. Basecoat gives us the exact same design tokens, color system, and component aesthetics — but as pure CSS classes on plain HTML. No React, no build step, no virtual DOM. A ` - -``` - -Available helpers: `stimulusController`, `stimulusAction`, `stimulusTarget`, `stimulusValue`. - -### 5. Hotwire Native navigation - -When a request comes from a Turbo Native iOS/Android shell, redirect through the special interception paths instead of a normal HTTP redirect. Each helper falls through to `redirectTo()` on web requests, so the same controller code works for both clients: - -```cfm -function create() { - post = model("Post").create(params.post); - - // Native: dismiss/pop. Web: redirect to index. - recedeOrRedirectTo(route="posts"); -} - -function update() { - post = model("Post").findByKey(params.key); - post.update(params.post); - - // Native: refresh current screen. Web: redirect to show. - refreshOrRedirectTo(route="post", key=post.id); -} -``` - -Available helpers: `recedeOrRedirectTo` (pops), `refreshOrRedirectTo` (reloads), `resumeOrRedirectTo` (no-op). Detect native requests directly with `hotwireNativeApp()`. - -### 6. Path configuration endpoint (Native) - -Serve a JSON document controlling navigation behavior in native apps: - -```cfm -// In a controller action routed at GET /native/config -function config() { - hotwireNativePathConfiguration( - settings = { - tabs: [ - {title: "Home", path: "/dashboard", icon: "house"}, - {title: "Orders", path: "/orders", icon: "list.clipboard"} - ] - } - // Sensible default rules are applied if `rules` is omitted — - // see hotwireNativePathConfiguration() for the defaults. - ); -} -``` - -## Request detection - -| Helper | Returns true when | -|---|---| -| `isHotwireRequest()` | `Accept` header contains `text/vnd.turbo-stream.html` | -| `isTurboFrameRequest()` | `Turbo-Frame` request header is present | -| `turboFrameRequestId()` | Returns the requesting frame's id (or `""`) | -| `hotwireNativeApp()` | `User-Agent` contains `Turbo Native` | - -## Deactivating - -```bash -rm -rf vendor/hotwire -wheels reload -``` - -## Reference - -- `packages/hotwire/CLAUDE.md` — markup reference, helper inventory, naming conventions -- `packages/hotwire/.ai/ARCHITECTURE.md` — detailed architecture notes -- [Hotwire](https://hotwired.dev) — upstream documentation for Turbo, Stimulus, and Hotwire Native - -## License - -MIT diff --git a/packages/hotwire/box.json b/packages/hotwire/box.json deleted file mode 100644 index 9ac2fda752..0000000000 --- a/packages/hotwire/box.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "wheels-hotwire", - "slug": "wheels-hotwire", - "version": "0.1.0", - "author": "Wheels Core Team", - "homepage": "https://wheels.dev", - "repository": { "type": "git", "URL": "https://github.com/wheels-dev/wheels.git" }, - "bugs": "https://github.com/wheels-dev/wheels/issues", - "keywords": ["wheels", "hotwire", "turbo", "stimulus", "turbo-frames", "turbo-streams", "hotwire-native", "mobile"], - "type": "cfwheels-plugins", - "license": [{ "type": "MIT", "URL": "https://opensource.org/licenses/MIT" }], - "description": "Hotwire infrastructure for Wheels: Turbo Drive, Turbo Frames, Turbo Streams, Stimulus helpers, and Hotwire Native mobile support." -} diff --git a/packages/hotwire/index.cfm b/packages/hotwire/index.cfm deleted file mode 100644 index 052a4330a3..0000000000 --- a/packages/hotwire/index.cfm +++ /dev/null @@ -1,59 +0,0 @@ -

wheels-hotwire

-

Hotwire infrastructure for Wheels: Turbo Drive, Turbo Frames, Turbo Streams, Stimulus, and Hotwire Native mobile support.

- -

Quick Start

-

Add ##hotwireIncludes()## to your layout's <head> to activate Turbo Drive and Stimulus.

- -

Helpers

- -

Includes

-
  • hotwireIncludes() — Turbo + Stimulus script tags
- -

Turbo Frames

-
    -
  • turboFrame(id, [src], [loading], [target]) / turboFrameEnd()
  • -
- -

Turbo Streams

-
    -
  • turboStreamAppend(target, content)
  • -
  • turboStreamPrepend(target, content)
  • -
  • turboStreamReplace(target, content)
  • -
  • turboStreamUpdate(target, content)
  • -
  • turboStreamRemove(target)
  • -
  • turboStreamBefore(target, content)
  • -
  • turboStreamAfter(target, content)
  • -
  • turboStreamRefresh()
  • -
  • renderTurboStream(streams) — controller method
  • -
- -

Request Detection

-
    -
  • isHotwireRequest() — Turbo Stream request?
  • -
  • isTurboFrameRequest() — Turbo Frame request?
  • -
  • turboFrameRequestId() — requesting frame ID
  • -
  • hotwireNativeApp() — Hotwire Native mobile?
  • -
- -

Hotwire Native Navigation

-
    -
  • recedeOrRedirectTo() — pop/dismiss on native, redirect on web
  • -
  • refreshOrRedirectTo() — refresh on native, redirect on web
  • -
  • resumeOrRedirectTo() — resume on native, redirect on web
  • -
- -

Stimulus Convenience

-
    -
  • stimulusController(controllers)
  • -
  • stimulusAction(actions)
  • -
  • stimulusTarget(controller, name)
  • -
  • stimulusValue(controller, name, value)
  • -
- -

Companion Plugin

-

Pair with wheels-basecoat for a complete UI component library (buttons, cards, dialogs, tables, forms) styled with shadcn/ui-quality design.

- -

Links

- diff --git a/packages/hotwire/package.json b/packages/hotwire/package.json deleted file mode 100644 index 2e17d1735c..0000000000 --- a/packages/hotwire/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "wheels-hotwire", - "version": "0.1.0", - "author": "Wheels Core Team", - "description": "Hotwire infrastructure for Wheels: Turbo Drive, Turbo Frames, Turbo Streams, Stimulus helpers, and Hotwire Native mobile support", - "wheelsVersion": ">=3.0", - "provides": { - "mixins": "controller", - "services": [], - "middleware": [] - }, - "dependencies": {} -} diff --git a/packages/hotwire/tests/HotwireSpec.cfc b/packages/hotwire/tests/HotwireSpec.cfc deleted file mode 100644 index 12fc239a2f..0000000000 --- a/packages/hotwire/tests/HotwireSpec.cfc +++ /dev/null @@ -1,182 +0,0 @@ -component extends="wheels.WheelsTest" { - - function beforeAll() { - hw = new plugins.hotwire.Hotwire(); - hw.init(); - } - - function run() { - - describe("hotwireIncludes()", () => { - - it("outputs Turbo script tag by default", () => { - var result = hw.hotwireIncludes(); - expect(result).toInclude("@hotwired/turbo"); - expect(result).toInclude('type="module"'); - }) - - it("outputs Stimulus script tag by default", () => { - var result = hw.hotwireIncludes(); - expect(result).toInclude("@hotwired/stimulus"); - expect(result).toInclude("Application.start()"); - }) - - it("outputs turbo-cache-control meta tag by default", () => { - var result = hw.hotwireIncludes(); - expect(result).toInclude('name="turbo-cache-control"'); - expect(result).toInclude('content="no-preview"'); - }) - - it("omits Turbo when turbo=false", () => { - var result = hw.hotwireIncludes(turbo=false); - expect(result).notToInclude("@hotwired/turbo"); - }) - - it("omits Stimulus when stimulus=false", () => { - var result = hw.hotwireIncludes(stimulus=false); - expect(result).notToInclude("@hotwired/stimulus"); - }) - - it("omits cache-control meta when turboCacheControl is empty", () => { - var result = hw.hotwireIncludes(turboCacheControl=""); - expect(result).notToInclude("turbo-cache-control"); - }) - - }) - - describe("turboFrame()", () => { - - it("generates opening tag with required id attribute", () => { - var result = hw.turboFrame(id="my-frame"); - expect(result).toInclude(' { - var result = hw.turboFrame(id="lazy-frame", src="/content/load"); - expect(result).toInclude('src="/content/load"'); - }) - - it("includes loading attribute when provided", () => { - var result = hw.turboFrame(id="lazy-frame", src="/load", loading="lazy"); - expect(result).toInclude('loading="lazy"'); - }) - - it("includes target attribute when provided", () => { - var result = hw.turboFrame(id="nav-frame", target="_top"); - expect(result).toInclude('target="_top"'); - }) - - it("omits optional attributes when not provided", () => { - var result = hw.turboFrame(id="bare-frame"); - expect(result).notToInclude("src="); - expect(result).notToInclude("loading="); - expect(result).notToInclude("target="); - }) - - }) - - describe("turboFrameEnd()", () => { - - it("returns closing turbo-frame tag", () => { - var result = hw.turboFrameEnd(); - expect(result).toBe("
"); - }) - - }) - - describe("Turbo Stream helpers", () => { - - it("turboStreamAppend() generates append action with template", () => { - var result = hw.turboStreamAppend(target="list", content="
  • item
  • "); - expect(result).toInclude('action="append"'); - expect(result).toInclude('target="list"'); - expect(result).toInclude(""); - }) - - it("turboStreamPrepend() generates prepend action with template", () => { - var result = hw.turboStreamPrepend(target="list", content="
  • first
  • "); - expect(result).toInclude('action="prepend"'); - expect(result).toInclude('target="list"'); - expect(result).toInclude("