From 13f1d5d877e7f58b6557a50849929c34f881ac34 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Mon, 14 Sep 2026 01:16:31 +0200 Subject: [PATCH] chore: seed the lode and enable the pre-PR gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository's durable memory now lives in lode/, so the next change starts from what the code does rather than from a re-read. It holds a summary and the three invariants, the vocabulary, the practices .claude/rules does not state, a workflow profile for the shared /lode: skills, and one area summary each for the components layer, the popover components and their Stimulus controllers, the MCP server, the docs site, packaging and release, and the two test suites. Every line range and count in it was computed with a script, not read off the screen. lode/review/ holds 16 rules imported from the repository's own review history: no cubic learnings exist for this repo, so all 16 come from merged PR review threads (CodeRabbit on PRs 13, 15, 16, 17, 18 and 33; cubic-dev-ai on PR 37) where the author accepted the finding or rejected it with a reason. Four rules merge several threads that said the same thing — the ensure-in-around debate across three spec files, two RuboCop offences in one rake task, three missing-code-fence-language comments — and two findings were dropped because their subject is gone: the shell-interpolation fixes to the old inline deploy-docs.yml, which is now a thin caller of the shared docs-kit workflow. Each rule was re-checked against the current code before it was written down. Thirteen doc-versus-code disagreements turned up while reading (stale trees and generators in CLAUDE.md, a docs/CLAUDE.md describing the pre-docs-kit app, two bun pins, a bin/ci that runs a minitest suite this app does not have). They are in the PR body, not in the lode: a document states only what is true of the tree it ships in. Five local commands the plugin now supersedes are retired — lfg, plan, tdd, github-review-pr, github-review-comments — and CLAUDE.md's command table points at the /lode: equivalents. github-ci-failures, review-pr, add-component, check-component, audit-components, test-all and fix-docs-tests are untouched; github-ci-failures' hand-off line now names /lode:review-pr. Claude-Session: https://claude.ai/code/session_01Hga3idYBp7fWdj7GmmfofF --- .claude/commands/github-ci-failures.md | 2 +- .claude/commands/github-review-comments.md | 77 -------- .claude/commands/github-review-pr.md | 163 ----------------- .claude/commands/lfg.md | 194 -------------------- .claude/commands/plan.md | 80 --------- .claude/commands/tdd.md | 127 ------------- .claude/settings.json | 13 ++ .gitignore | 3 + CLAUDE.md | 28 ++- lode/components/summary.md | 116 ++++++++++++ lode/docs-site/summary.md | 89 ++++++++++ lode/interactive/summary.md | 115 ++++++++++++ lode/lode-map.md | 46 +++++ lode/mcp-server/summary.md | 71 ++++++++ lode/packaging-and-release/summary.md | 89 ++++++++++ lode/plans/README.md | 13 ++ lode/practices.md | 100 +++++++++++ lode/review/README.md | 30 ++++ lode/review/components.md | 73 ++++++++ lode/review/docs-site.md | 70 ++++++++ lode/review/interactive.md | 109 ++++++++++++ lode/review/packaging-and-release.md | 76 ++++++++ lode/review/testing-and-ci.md | 45 +++++ lode/summary.md | 35 ++++ lode/terminology.md | 48 +++++ lode/testing-and-ci/summary.md | 85 +++++++++ lode/workflow.md | 197 +++++++++++++++++++++ 27 files changed, 1447 insertions(+), 647 deletions(-) delete mode 100644 .claude/commands/github-review-comments.md delete mode 100644 .claude/commands/github-review-pr.md delete mode 100644 .claude/commands/lfg.md delete mode 100644 .claude/commands/plan.md delete mode 100644 .claude/commands/tdd.md create mode 100644 .claude/settings.json create mode 100644 lode/components/summary.md create mode 100644 lode/docs-site/summary.md create mode 100644 lode/interactive/summary.md create mode 100644 lode/lode-map.md create mode 100644 lode/mcp-server/summary.md create mode 100644 lode/packaging-and-release/summary.md create mode 100644 lode/plans/README.md create mode 100644 lode/practices.md create mode 100644 lode/review/README.md create mode 100644 lode/review/components.md create mode 100644 lode/review/docs-site.md create mode 100644 lode/review/interactive.md create mode 100644 lode/review/packaging-and-release.md create mode 100644 lode/review/testing-and-ci.md create mode 100644 lode/summary.md create mode 100644 lode/terminology.md create mode 100644 lode/testing-and-ci/summary.md create mode 100644 lode/workflow.md diff --git a/.claude/commands/github-ci-failures.md b/.claude/commands/github-ci-failures.md index 33c8deb2..56cabdd5 100644 --- a/.claude/commands/github-ci-failures.md +++ b/.claude/commands/github-ci-failures.md @@ -23,7 +23,7 @@ Once you have the PR number, confirm it: gh pr view --json title,state,url,mergeable ``` -**Pre-flight: merge conflicts (detection only).** If `mergeable` is `CONFLICTING`, STOP — do not diagnose CI on a conflicted branch (the merge itself may fix or cause the failures). Report the conflict and hand off to `/github-review-pr`, whose Phase A0 owns the resolution runbook — this command's toolset deliberately does not include the merge machinery. If `mergeable` is `UNKNOWN`, note it and proceed: the orchestrator resolves the ambiguity; a standalone run shouldn't block on GitHub's recompute. +**Pre-flight: merge conflicts (detection only).** If `mergeable` is `CONFLICTING`, STOP — do not diagnose CI on a conflicted branch (the merge itself may fix or cause the failures). Report the conflict and hand off to `/lode:review-pr`, whose conflict phase owns the resolution runbook — this command's toolset deliberately does not include the merge machinery. If `mergeable` is `UNKNOWN`, note it and proceed: the orchestrator resolves the ambiguity; a standalone run shouldn't block on GitHub's recompute. ## Phase 1: Identify Failing Checks diff --git a/.claude/commands/github-review-comments.md b/.claude/commands/github-review-comments.md deleted file mode 100644 index 8f972819..00000000 --- a/.claude/commands/github-review-comments.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -description: "Use when a PR has unresolved review comments that need responses -- evaluates each comment, implements valid fixes, pushes back on incorrect suggestions, and resolves all threads." -model: opus -argument-hint: "PR number (e.g., 123 or #123)" -allowed-tools: Bash(gh pr list:*), Bash(gh pr view:*), Bash(gh pr diff:*), Bash(gh pr comment:*), Bash(gh api:*), Bash(git log:*), Bash(git blame:*), Bash(git push:*), Bash(git commit:*), Bash(git add:*), Bash(bundle exec:*), Read, Write, Edit, Glob, Grep, Agent ---- - -# Review GitHub PR Comments: $ARGUMENTS - -You are reviewing and responding to all unresolved review comments on a GitHub pull request. - -## Phase 0: Determine the PR Number - -Parse `$ARGUMENTS` flexibly: `PR123`, `123`, `#123` -> PR 123. If empty, auto-detect from current branch: - -```bash -gh pr list --author=@me --head="$(git branch --show-current)" --state=open --json number,title -``` - -## Phase 1: Fetch All Unresolved Review Comments - -```bash -gh api graphql -f query=' - query($owner: String!, $repo: String!, $pr: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $pr) { - reviewThreads(first: 100) { - nodes { - id - isResolved - path - line - comments(first: 20) { - nodes { - id - databaseId - body - author { login } - createdAt - } - } - } - } - } - } - } -' -f owner=mhenrixon -f repo=daisyui -F pr= -``` - -Filter to only **unresolved** threads. Skip bot comments and resolved threads. - -## Phase 2: Categorise Each Comment - -| Category | Action | -|----------|--------| -| Valid fix needed | Implement the fix | -| Valid test gap | Add the missing test | -| Incorrect suggestion | Push back with technical reasoning | -| Suggestion conflicts with DaisyUI 5 spec | Push back, reference MCP snippet | -| Unclear | Ask for clarification | - -**Before categorising**, always read the actual file and verify against DaisyUI 5 specs. - -## Phase 3: Implement Accepted Fixes - -1. Make changes -2. Run `bundle exec rspec ` -3. Run `bundle exec rubocop ` -4. Commit and push - -## Phase 4: Reply to Every Comment - -For accepted fixes, reply with commit SHA. For rejected suggestions, reply with technical reasoning. Resolve all threads via GraphQL. - -## Phase 5: Verify Completion - -Confirm no unresolved threads remain. Report final tally. diff --git a/.claude/commands/github-review-pr.md b/.claude/commands/github-review-pr.md deleted file mode 100644 index 7a720e6e..00000000 --- a/.claude/commands/github-review-pr.md +++ /dev/null @@ -1,163 +0,0 @@ ---- -description: "Use when a PR needs full review — resolves merge conflicts with the base first, then fixes CI failures, then addresses unresolved review comments. Conflicts first so CI diagnoses the post-merge reality; failures before comments because comment fixes trigger new CI runs that obscure the original failures." -model: opus -argument-hint: "PR number (e.g., 156 or #156)" -allowed-tools: Bash(gh pr list:*), Bash(gh pr view:*), Bash(gh pr checks:*), Bash(gh pr checkout:*), Bash(gh pr diff:*), Bash(gh pr comment:*), Bash(gh api:*), Bash(gh run view:*), Bash(git log:*), Bash(git blame:*), Bash(git diff:*), Bash(git status:*), Bash(git switch:*), Bash(git fetch:*), Bash(git merge:*), Bash(git merge-tree:*), Bash(git rev-parse:*), Bash(git push:*), Bash(git commit:*), Bash(git add:*), Bash(bundle exec:*), Bash(bundle install:*), Bash(bin/rubocop:*), Bash(bun:*), Bash(cd:*), Read, Write, Edit, Glob, Grep, Agent ---- - -# Review GitHub PR (full pass): $ARGUMENTS - -You are running a full review pass on a pull request. The pass has three phases that MUST run in this order: - -1. **Phase A0: merge conflicts** — bring the branch up to date with its base and resolve any conflicts before anything else. -2. **Phase A: CI failures** — fix anything red before touching review comments. -3. **Phase B: review comments** — only after Phase A leaves CI green (or pending green after a push). - -## Why this order matters - -**Conflicts before failures**: CI results only matter for the code that will actually merge. On a conflicted (or stale) branch you'd diagnose failures against a base that no longer exists — and the conflict resolution itself changes code, invalidating the run you just fixed. Resolving conflicts first means Phase A reads CI for the post-merge reality, and you spend exactly one extra CI cycle instead of two. - -**Failures before comments**: if you fix review comments first, every commit pushes a new CI run. By the time the review-comment fixes finish, the original failure logs are buried under new pipeline runs. Symptoms: - -- The "Gem Tests (Ruby 3.4) failed" log you needed to read is now from a stale run; the latest run is still in progress on top of your unrelated comment fixes. -- A review-comment fix accidentally repairs the CI failure as a side effect, and you lose the chance to verify the failure was real. -- A review-comment fix accidentally INTRODUCES a CI failure, and you can't tell whether the new failure was pre-existing or your fault. - -Conflicts-first, then failures-first eliminates this confusion. CI is either green or red on a known commit against the current base; the review-comment fixes layer cleanly on top. - -## Phase 0: Determine the PR Number - -The user may provide a PR number as `$ARGUMENTS`. Parse it flexibly: - -- `PR156`, `PR 156`, `pr156` → PR 156 -- `156` → PR 156 -- `#156` → PR 156 -- Empty/blank → auto-detect from current branch - -**If no PR number is provided**, detect it automatically: - -```bash -gh pr list --author=@me --head="$(git branch --show-current)" --state=open --json number,title -``` - -If exactly one open PR exists for the current branch, use it. If none or multiple, ask the user. - -Once you have the PR number, confirm it: - -```bash -gh pr view --json title,state,url -``` - ---- - -## Phase A0: Merge conflicts - -Check whether the branch merges cleanly into its base: - -```bash -gh pr view --json mergeable,mergeStateStatus,baseRefName -``` - -| `mergeable` | Action | -|-------------|--------| -| `MERGEABLE` | Skip to Phase A. | -| `UNKNOWN` | GitHub is recomputing (common right after pushes, and it can stay UNKNOWN for minutes). Don't poll it — verify **locally**, against the PR's actual head (NOT `HEAD`, which may be some other checked-out branch): `git fetch origin ` and `git fetch origin pull//head`, verify both refs resolve (`git rev-parse --verify origin/^{commit}` and `git rev-parse --verify FETCH_HEAD^{commit}` — a bad ref also exits 1 from merge-tree, so exit code alone can't be trusted), then `git merge-tree --write-tree --name-only origin/ FETCH_HEAD`. Clean exit → no conflicts, skip to Phase A. Exit 1 **with conflict output** → resolve below (the `--name-only` file list is your work list). | -| `CONFLICTING` | Resolve, below. | - -### Resolution procedure - -1. Check out the PR's branch (`gh pr checkout `) with a clean tree (`git status`). Stash nothing — if the tree is dirty, stop and ask the user. -2. `git fetch origin ` then **`git merge origin/`** — MERGE, never rebase. The branch is shared (it has a PR); a rebase would require a force-push, which `.claude/rules/git-workflow.md` forbids on shared branches. -3. Resolve every conflicted file **semantically** — read both sides and produce the version that preserves BOTH changes' intent. Never blanket `--ours`/`--theirs` a source file. Repo-specific rules: - - **Lockfiles** (`Gemfile.lock`, `docs/Gemfile.lock`, `docs/bun.lock` — all three are tracked): NEVER hand-merge a lockfile. Take the base's file, then re-resolve the branch's own dependency changes on top: `bundle install` at the root (and/or in `docs/`), `bun install` in `docs/` for `bun.lock`. Only the regenerated file gets committed. - - **`CHANGELOG.md` (Unreleased)**: union — keep BOTH sides' entries (main's landed bullets and this branch's), most recent first, without duplicating the `### Added`/`### Changed` subheads. Losing either side is a real regression reviewers rarely catch. - - **`lib/daisy_ui/version.rb`**: releases land DIRECTLY on `main` via `rake release[X.Y.Z]` (the task aborts off-main and pushes to `origin/main` itself — no PR), so an ordinary feature branch never edits this file — a conflict here means the BRANCH bumped it on purpose (a release-prep PR). Keep the branch's bump in that case; if the intent isn't obvious from the branch's own commits, stop and ask. Only take the base's version when the branch's edit was clearly accidental. - - **`lib/daisy_ui/updated_at.rb`**: machine-written by `rake release` on every release — take the base's side; it is regenerated at the next release regardless. - - **Component modifier maps** (`register_modifiers` blocks in `lib/daisy_ui/*.rb`): both sides usually added different modifiers — keep both, and preserve the responsive variant comment block (six lines per modifier, including the container-query variants: `# "sm:..."`, `# "@sm:..."`, `# "md:..."`, `# "@md:..."`, `# "lg:..."`, `# "@lg:..."`) above EVERY modifier line. Tailwind scans those comments to generate responsive classes; dropping one in a merge silently breaks responsive variants. - - **Generated assets**: nothing generated is tracked in this repo (docs CSS builds and `tailwind.sources.css` are gitignored) — so there is no artifact to regenerate-instead-of-merge; every remaining conflict is source and merges semantically. -4. Run the verification gates BEFORE pushing the merge — scoped to what the conflict touched, at minimum: - ```bash - bundle exec rubocop lib spec - bundle exec rspec - # docs/ files involved (the docs app has its own lint + test setup): - cd docs && bin/rubocop && bun run lint:js && bun run lint:css - cd docs && bun run build:css && bundle exec rspec - ``` -5. Commit the merge (keep git's standard merge-commit message; add a body line naming any non-obvious resolution choice) and `git push` — a merge commit never needs force. - -### Phase A0 exit criteria - -- The PR reports `MERGEABLE` (or the local `git merge-tree` check is clean), AND the merge commit (if one was needed) is pushed. -- If the merge produced changes, CI is now re-running — that's expected; Phase A reads the fresh run. -- If a conflict cannot be resolved with confidence (both sides rewrote the same logic and the correct combination isn't decidable from the code), **stop and ask the user** — a guessed resolution that compiles is worse than a question. - ---- - -## Phase A: Run `/github-ci-failures` - -Invoke the existing `/github-ci-failures` slash command with the same `$ARGUMENTS` value. Its purpose: fix every failing CI check, push, leave the branch in a state where CI is either green or running-pending-toward-green. - -Follow that command's full process — phases 1–6 of the failures runbook. The slash command is at `.claude/commands/github-ci-failures.md`. Its workflow: - -1. Identify failing checks via `gh pr checks `. -2. Fetch failure logs. -3. Diagnose root cause for each. -4. Fix locally — lint first (fast, deterministic), then specs, then docs tests. -5. Verify locally before commit (`bundle exec rspec `, `bundle exec rubocop`). -6. Commit + push + report which checks are now running. - -### Phase A exit criteria - -Before moving to Phase B, one of these must be true: - -- All CI checks are green on the latest pushed commit. OR -- All CI checks are pending (running) on the latest pushed commit, AND no checks failed in the most recent completed run on this commit. OR -- A persistent CI failure exists that is **not caused by changes on this branch** (e.g., a flaky Playwright docs test on `main`, or an environmental failure like a browser-install timeout). Report this explicitly and proceed to Phase B with the caveat noted. - -If failures persist on this branch's changes, **do NOT proceed to Phase B**. Report what's still failing, what's been tried, and ask the user how to proceed. - ---- - -## Phase B: Run `/github-review-comments` - -Once Phase A's exit criteria are met, invoke `/github-review-comments` with the same `$ARGUMENTS`. Its purpose: address every unresolved review thread on the PR, push fixes, reply with commit SHAs, and resolve the threads. - -The slash command is at `.claude/commands/github-review-comments.md`. Its workflow: - -1. Fetch all unresolved review threads via the GitHub GraphQL API. -2. Read and categorise each comment (valid fix / invalid suggestion / unclear), verifying against the DaisyUI 5 spec where relevant. -3. Implement accepted fixes; verify locally (specs, rubocop). -4. Commit all fixes together with a clear message; push. -5. Reply to every thread with the commit SHA (for accepted fixes) or technical reasoning (for rejections). -6. Resolve each thread via the GraphQL `resolveReviewThread` mutation. -7. Verify no unresolved threads remain. - -### Phase B exit criteria - -- All unresolved review threads have been replied to and resolved (or the user has explicitly approved leaving a specific thread open). -- The branch has been pushed with all accepted fixes. - ---- - -## Phase C: Final report - -Before reporting, re-check mergeability once more (`gh pr view --json mergeable`, or the local `git merge-tree` check if UNKNOWN) — the base can move underneath a long pass. If a NEW conflict appeared, loop back to Phase A0. - -After all phases complete, report: - -1. **Phase A0 summary**: whether the branch was conflicted, which files conflicted, how each was resolved (and the merge commit SHA) — or "clean merge, no action". -2. **Phase A summary**: which CI failures were diagnosed and fixed. Note the commit SHAs for the fixes. -3. **Phase B summary**: which review comments were accepted (with commit SHAs), which were pushed back on (with reasoning), and the final unresolved-thread count (should be 0). -4. **End state**: final mergeability + CI status on the latest commit. -5. **Outstanding work**: anything that still needs attention — e.g., CI was pending at the end of Phase B and the user should verify the latest run after the comment fixes. - ---- - -## Important Notes - -- **Do not interleave the phases.** Don't fix a CI failure, then a review comment, then another CI failure. The whole point of this command is the strict ordering. -- **A new CI failure emerging during Phase B** (e.g., a comment fix breaks a spec) means looping back to Phase A — fix the new failure before continuing comment work. Likewise, **a new conflict appearing mid-pass** (the base moved) means looping back to Phase A0. These loop-backs are the only allowed reverse directions. -- **If the PR is already merged**, there is nothing to review — report that and stop. (A stale `$ARGUMENTS` or a just-merged PR shows up as `state: MERGED` in Phase 0's confirm step.) -- **If the PR merges cleanly, has no failures AND no unresolved comments**, report "PR is clean" and stop. -- **If `$ARGUMENTS` is the same as the current open PR**, the two child slash commands will see the same PR. They share state through the git branch and the GitHub API, not through any in-process variable. -- **Don't re-implement the child slash commands' logic**. Invoke them and let them do their work. This command is the orchestrator. diff --git a/.claude/commands/lfg.md b/.claude/commands/lfg.md deleted file mode 100644 index 1498c4ec..00000000 --- a/.claude/commands/lfg.md +++ /dev/null @@ -1,194 +0,0 @@ ---- -description: "Executes full autonomous engineering workflow with verification. Use when implementing complete features, tackling GitHub issues, or running end-to-end development cycles." -model: opus -argument-hint: "GitHub issue number/URL or feature description" -allowed-tools: Bash(gh issue view:*), Bash(gh search:*), Bash(gh issue list:*), Bash(gh pr create:*), Bash(gh pr view:*), Bash(bundle exec:*), Bash(git:*), Read, Write, Edit, Glob, Grep, Agent, mcp__daisyui__daisyUI-Snippets ---- - -# LFG - Full Autonomous Workflow - -Execute a complete engineering workflow with verification at each phase. - -## Phase 0: Branch Setup - -**BEFORE any other work, prepare the git branch:** - -1. Check the current branch: `git branch --show-current` -2. If NOT on `main`, switch: `git checkout main` -3. Pull latest: `git pull origin main` -4. Create feature branch: `git checkout -b issue-{number}-{brief-description}` (or `feature/{description}` if no issue number) - ---- - -## Phase 1: Understand - -### Step 1: Gather Requirements - -If `$ARGUMENTS` is a GitHub issue number or URL: - -```bash -gh issue view --json title,body,labels,assignees,comments -``` - -If `$ARGUMENTS` is a description, use it directly. - -### Step 2: Define Acceptance Criteria - -**MANDATORY:** Write explicit acceptance criteria: - -- **GIVEN** [context/setup] -- **WHEN** [action taken] -- **THEN** [expected outcome] - -You MUST NOT proceed until you can articulate these clearly. - -### Step 3: Comprehension Gate - -Before proceeding, you must: - -1. State the problem/feature in one sentence -2. Explain WHY this is needed -3. List what will change from the user's perspective -4. Identify edge cases not explicitly mentioned -5. Explain the code path involved - -If you cannot complete ALL five items, investigate further. - -### Step 4: Create Task List - -Create a TaskCreate todo list with specific implementation steps. - ---- - -## Phase 2: Explore - -1. Find related files (Glob/Grep or Explore agent) -2. Read existing patterns in similar components -3. Use `mcp__daisyui__daisyUI-Snippets` to get official DaisyUI class names -4. Check existing test coverage -5. Review the Base class pattern in `lib/daisy_ui/base.rb` - ---- - -## Phase 3: Plan - -1. List files to modify with specific changes -2. List new files to create with purpose -3. Plan test coverage (TDD: tests FIRST) -4. Update task list with implementation steps - ---- - -## Phase 4: Implement (TDD) - -For each logical unit: - -### 4.1: Write Failing Test First - -Create a test that demonstrates the expected behavior. Run it to confirm it FAILS: - -```bash -bundle exec rspec -``` - -### 4.2: Implement Minimum Code - -Write the MINIMUM code to make the test pass. Follow project patterns: - -| Never Do | Always Do | -|----------|-----------| -| Guess DaisyUI class names | Use `mcp__daisyui__daisyUI-Snippets` to verify | -| Skip responsive comments | Include `# "sm:class" "md:class" "lg:class"` for every modifier | -| Hardcode HTML tags | Use `as:` parameter with `public_send(as, ...)` | -| Skip sub-components | Add methods for component parts (body, title, etc.) | -| Forget `component_class` | Always set `self.component_class` | - -### 4.3: Refactor - -Once green, refactor while keeping tests passing. - -### 4.4: Validate - -```bash -bundle exec rubocop -``` - -### 4.5: Repeat - -Move to next logical unit. Mark task items complete. - ---- - -## Phase 5: Verify - -**ALL of these must pass before committing:** - -```bash -bundle exec rubocop # Style -bundle exec rspec # Tests -``` - -### Solution Verification - -Re-read the original requirements and verify: -- "If I were the requester, would I consider this fully resolved?" -- "Have I addressed the ROOT CAUSE, not just the symptom?" -- "Do my tests prove the feature works?" - ---- - -## Phase 6: Commit & PR - -### Commit - -```bash -git add -git commit -m "$(cat <<'EOF' -feat(scope): brief description - -## Summary -[What changed and why] - -## Test Coverage -- spec 1: validates requirement X -- spec 2: validates edge case Y - -## Verification -- [x] bundle exec rubocop passes -- [x] bundle exec rspec passes -EOF -)" -``` - -### Push & PR - -```bash -git push -u origin $(git branch --show-current) - -gh pr create --title "feat(scope): brief description" --body "$(cat <<'EOF' -## Summary -- Key change 1 -- Key change 2 - -Closes # - -## Test plan -- [ ] Scenario 1 -- [ ] Scenario 2 -EOF -)" -``` - ---- - -## Verification Checklist - -- [ ] All acceptance criteria met -- [ ] Tests written BEFORE implementation -- [ ] `bundle exec rubocop` passes -- [ ] `bundle exec rspec` passes -- [ ] DaisyUI class names verified via MCP server -- [ ] Responsive comments present on all modifiers -- [ ] PR created with description - -Now, execute this workflow for the provided issue or feature. diff --git a/.claude/commands/plan.md b/.claude/commands/plan.md deleted file mode 100644 index 7065bace..00000000 --- a/.claude/commands/plan.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -description: "Investigates the codebase, designs a solution, and produces a durable plan artifact — a GitHub issue or a plan markdown under docs/plans/. Read-only: never edits application code. Use before /lfg for anything non-trivial." -model: fable -argument-hint: "issue | md | " -allowed-tools: Bash(gh issue create:*), Bash(gh issue list:*), Bash(gh issue view:*), Bash(gh search:*), Bash(gh label list:*), Bash(git log:*), Bash(git diff:*), Bash(git branch:*), Bash(date:*), Read, Grep, Glob, Write, Agent ---- - -# Plan — design expensive, execute cheap - -You are the planning specialist. This command runs on the most capable model deliberately: the thinking happens here, the execution happens later on cheaper models (`/lfg` on Opus, layer specialists on Sonnet). That split only works if the plan is **self-contained** — an executor with none of this session's context must be able to implement it without guessing. - -## Output mode from $ARGUMENTS - -| $ARGUMENTS starts with | Artifact | -|------------------------|----------| -| `issue` | GitHub issue (default — feeds directly into `/lfg `) | -| `md` or `file` | Markdown file at `docs/plans/YYYY-MM-DD-.md` (date from `date +%F`) | -| anything else | GitHub issue | - -## Hard constraints - -- **Read-only for source code.** Never edit application code, never commit, never create branches. The only file you may Write is a new plan markdown under `docs/plans/`. -- **Never reproduce secrets** (keys, tokens, credentials) in the plan, even redacted ones you encounter while reading config. -- **Dedupe before creating an issue**: `gh issue list --search ""` — if an existing issue covers this, extend it in your summary instead of duplicating. - -## Phase 1 — Investigate - -Protect this session's context: delegate mechanical exploration to cheaper subagents and keep Fable for judgment. - -1. Fan out Explore agents (`model: haiku`) for file discovery and naming-convention sweeps; use `model: sonnet` agents when a subsystem needs to be read and summarized. Launch independent explorations in parallel. -2. Read the load-bearing files yourself — the ones the design decision actually hinges on. Don't design from subagent summaries alone. -3. Check CLAUDE.md and any layer-specific CLAUDE.md files for past decisions and gotchas. -4. Check `git log` for recent related work; the design should extend it, not fight it. - -## Phase 2 — Design - -- Develop 2–3 candidate approaches with real tradeoffs. Pick one and say why; record why the others lost. -- The chosen design must respect project invariants: Phlex components inheriting from DaisyUI::Base, register_modifiers with responsive comments, TDD (specs named before implementation steps), MCP server for DaisyUI class verification. -- Decide the test strategy: unit specs for gem components in `spec/lib/daisy_ui/`, request/system specs for docs. - -## Phase 3 — Emit the plan artifact - -Use this structure for the issue body or markdown file. Every section is load-bearing — an executor uses Context to avoid re-discovery, Steps to act, Gates to verify, Boundaries to stop. - -```markdown -# - -## Problem / Goal -<What's wrong or missing, who it affects, what done looks like.> - -## Context (read these first) -<Bullet list: `path/to/file.rb` — why it matters to this change. Include components, specs, docs examples, base class. Self-contained: no references to "as discussed" or this session.> - -## Decision -<Chosen approach and rationale. Then: alternatives considered and why each was rejected.> - -## Implementation steps -<Ordered, small, each mapped to a specialist where useful (/add-component, /check-component, /tdd). Specs come before the code they cover. Name exact files to create or change.> - -## Verification gates -<Exact commands + expected outcome:> -- `bundle exec rspec` — all green (gem) -- `cd docs && bundle exec rspec` — all green (docs) -- `bundle exec rubocop` — no offenses -- `cd docs && bin/rubocop` — no offenses - -## Out of scope -<Explicit boundaries — the adjacent things an eager executor must NOT do.> - -## Execution -Execute with `/lfg <issue-number>` (or `/lfg docs/plans/<file>.md`). -``` - -For GitHub issues: create with `gh issue create --title "..." --body "$(cat <<'EOF' ... EOF)"` — single-quoted heredoc delimiter. Apply the `plan` label if it exists (`gh label list`); don't create labels. - -For markdown files: Write to `docs/plans/YYYY-MM-DD-<slug>.md`. Leave it uncommitted — committing is the user's call. - -## Phase 4 — Handoff - -Report back: link to the issue (or file path), the chosen approach in 2–3 sentences, and the exact execute command. Stop there — do not start implementing. diff --git a/.claude/commands/tdd.md b/.claude/commands/tdd.md deleted file mode 100644 index 600b8bb4..00000000 --- a/.claude/commands/tdd.md +++ /dev/null @@ -1,127 +0,0 @@ ---- -description: "Use when implementing any feature or fixing any bug -- enforces RED-GREEN-REFACTOR: write failing test first, implement minimum code to pass, then refactor." -model: sonnet ---- - -# TDD Command - -Enforce test-driven development methodology with RED -> GREEN -> REFACTOR cycle. - -## The TDD Cycle - -```text -RED -> GREEN -> REFACTOR -> REPEAT - -RED: Write a failing test (test MUST fail first) -GREEN: Write MINIMAL code to pass (nothing more) -REFACTOR: Improve code while keeping tests green -REPEAT: Next feature/scenario -``` - -## When to Use - -- Implementing new components -- Adding new modifiers to existing components -- Fixing bugs (write test that reproduces bug FIRST) -- Refactoring existing components -- Adding sub-component methods - -## Workflow - -### Step 1: Write Failing Tests (RED) - -```ruby -# spec/lib/daisy_ui/new_component_spec.rb -describe DaisyUI::NewComponent do - subject(:output) { render described_class.new } - - it "is expected to match the formatted HTML" do - expected_html = html <<~HTML - <div class="new-component"></div> - HTML - expect(output).to eq(expected_html) - end - - describe "modifiers" do - context "when given :primary modifier" do - subject(:output) { render described_class.new(:primary) } - - it "renders it apart from the main class" do - expected_html = html <<~HTML - <div class="new-component new-component-primary"></div> - HTML - expect(output).to eq(expected_html) - end - end - end -end -``` - -### Step 2: Run Tests - Verify FAIL - -```bash -bundle exec rspec spec/lib/daisy_ui/new_component_spec.rb - -FAIL - NameError / Expected behavior not met -``` - -**Tests MUST fail before implementing.** This confirms: -- Tests are actually running -- Tests are testing the right thing -- Implementation doesn't already exist - -### Step 3: Implement Minimal Code (GREEN) - -Write the minimum code to make the test pass. - -### Step 4: Run Tests - Verify PASS - -```bash -bundle exec rspec spec/lib/daisy_ui/new_component_spec.rb - -N examples, 0 failures -``` - -### Step 5: Refactor (IMPROVE) - -Improve code while keeping tests green: -- Extract methods -- Improve naming -- Reduce duplication - -### Step 6: Run Full Suite - -```bash -bundle exec rspec -``` - -## What Every Component Spec Should Cover - -1. Default rendering (base class only) -2. Each modifier individually -3. Multiple modifiers combined -4. Responsive modifiers (at least one viewport) -5. Custom classes via `class:` option -6. Data attributes via `data:` option -7. Custom tag via `as:` option -8. Sub-component methods (if any) - -## Coverage Requirements - -| Code Type | Minimum Coverage | -|-----------|------------------| -| All code | 80% | -| Component modifiers | 100% | -| Base class | 100% | -| Configuration | 100% | - -## Checklist - -- [ ] Tests written BEFORE implementation -- [ ] Tests fail initially (RED phase verified) -- [ ] Minimal code written to pass (GREEN) -- [ ] Code refactored with tests still passing -- [ ] Coverage meets requirements (80%+) -- [ ] All modifiers tested -- [ ] Responsive modifiers tested -- [ ] DaisyUI class names verified via MCP server diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..90b8653d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "extraKnownMarketplaces": { + "zoolutions": { + "source": { + "source": "github", + "repo": "zoolutions/claude-plugins" + } + } + }, + "enabledPlugins": { + "lode@zoolutions": true + } +} diff --git a/.gitignore b/.gitignore index 9658545c..d1fe0738 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ /docs/public/assets/ /docs/public/vite*/ /docs/bun.lockb + +# Lode scratch (never committed) +/lode/tmp/ diff --git a/CLAUDE.md b/CLAUDE.md index 2fa6a620..6cd02752 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Memory + +Durable project memory lives in `lode/` (index: `lode/lode-map.md`). Read it +before exploring the code. `lode/review/` holds accepted review findings as rules +about the system; `/lode:gate` enforces them before any push, and `/lode:learn` +adds to them. `lode/workflow.md` is the profile the shared `/lode:` workflow +skills read. + ## Repository Structure This is a monorepo containing: @@ -119,19 +127,29 @@ HEADLESS=false bundle exec rspec # Watch browser tests run | Command | Purpose | |---------|---------| -| `/plan` | Fable-powered planning → GitHub issue or `docs/plans/` markdown (read-only; execute with `/lfg`) | -| `/lfg` | Full autonomous engineering workflow with verification | +| `/lode:plan` | Read-only planning → GitHub issue or `lode/plans/` markdown (execute with `/lode:lfg`) | +| `/lode:lfg` | Full autonomous engineering workflow with verification | +| `/lode:tdd` | RED → GREEN → REFACTOR cycle | +| `/lode:review-pr` | Full PR pass: resolve merge conflicts, then fix CI failures, then resolve review comments (in that order) | +| `/lode:finish-prs` | Drive a stack of open PRs to merge-ready, one at a time | +| `/lode:debug-flaky` | Root-cause an intermittent test — evidence, repro, stress-proofed fix | +| `/lode:gate` | Pre-PR gate: fresh-context review against the rules and `lode/review/`; the push hook requires it | +| `/lode:learn` | Write accepted review findings into `lode/review/` | +| `/lode:sync` | Keep `lode/` true to the code after a change | | `/add-component` | Create a new DaisyUI component with tests and docs | | `/check-component` | Verify a single component against DaisyUI 5 spec | | `/audit-components` | Audit all components against DaisyUI 5 | -| `/tdd` | RED → GREEN → REFACTOR cycle | | `/test-all` | Run complete test suite (gem + docs) | | `/fix-docs-tests` | Fix failing docs specs | | `/review-pr` | Review a GitHub PR for quality and patterns | -| `/github-review-pr` | Full PR pass: resolve merge conflicts, then fix CI failures, then resolve review comments (in that order) | -| `/github-review-comments` | Respond to unresolved PR review comments | | `/github-ci-failures` | Diagnose and fix CI failures | +The `/lode:` commands come from the `lode@zoolutions` plugin, enabled in +`.claude/settings.json`; they read `lode/workflow.md` for this repository's +commands, shapes, CI and conflict rules. The local `lfg`, `plan`, `tdd`, +`github-review-pr` and `github-review-comments` commands were retired in favour +of them. + ### Model tier convention Commands and agents pin a model tier via frontmatter aliases: `haiku` for mechanical/config work, `sonnet` for layer specialists (the default), `opus` for orchestration and PR review, `fable` for read-only planning. Always use tier aliases, never full model IDs — aliases track the latest model in each tier. When spawning subagents for mechanical work (file finding, pattern scans), pass a cheaper model explicitly rather than letting them inherit the session model. diff --git a/lode/components/summary.md b/lode/components/summary.md new file mode 100644 index 00000000..b656ee0d --- /dev/null +++ b/lode/components/summary.md @@ -0,0 +1,116 @@ +# Components: `Base` and the 77 classes built on it + +## Loading + +`lib/daisyui.rb` is the gem's named entry point and does one thing: `require_relative "daisy_ui"`. +`lib/daisy_ui.rb` requires `phlex` and `zeitwerk`, then sets up +`Zeitwerk::Loader.for_gem` with the inflection `"daisy_ui" => "DaisyUI"` and three +ignores — `daisyui.rb` (not a constant), `daisy_ui/updated_at.rb` and +`daisy_ui/engine.rb` (the engine references `Rails::Engine`, so it is required +explicitly at the bottom, only `if defined?(Rails::Engine)`). `version.rb` and +`updated_at.rb` are `require_relative`'d before the loader runs. +`loader.load_file(".../base.rb")` forces `Base` to load eagerly, because every +component's class body calls `register_modifiers` at definition time. + +`module DaisyUI` then `extend Configurable` (the `configure`/`configuration` +class methods) and `extend Phlex::Kit` (the `Button(...)` short form for any +view that `include DaisyUI`). + +## The argument pipeline + +`Base#initialize(*modifiers, as: :div, id: nil, **options)` +(`lib/daisy_ui/base.rb:152-163`) sorts a call into three buckets: + +| Bucket | Where it comes from | Where it ends up | +|---|---|---| +| modifiers | positional symbols, plus keys pulled out by `extract_boolean_modifiers` | CSS classes | +| options | `as:`, `id:` | the tag name and the `id` attribute | +| attributes | every other keyword (`data:`, `aria:`, `class:`, `responsive:`) | `@options`, consumed by `classes` / `attributes` | + +`extract_boolean_modifiers` (`base.rb:251-260`) walks `modifier_map.keys` and, +for each key present in the options **whose value is exactly `true` or `false`**, +deletes it from the options; only the `true` ones join the modifier list. A key +that is not in `modifier_map`, or whose value is a string, stays in the options +and is rendered as an HTML attribute. + +## Building the class string + +`classes` (`base.rb:179-186`) is one `merge_classes` call whose arguments are +evaluated left to right, and two of them mutate `options`: + +```ruby +merge_classes(base_class, *modifier_classes, *responsive_classes, options.delete(:class)) +``` + +The order is load-bearing. `base_class` (`base.rb:206-211`) returns `nil` when +`options[:responsive]` contains a `true` value — the base class is then emitted +only with breakpoint prefixes — and it must therefore run **before** +`responsive_classes` (`base.rb:217-240`) deletes `:responsive`. `options.delete(:class)` +runs last, which is why `attributes` (`base.rb:195-197`) never re-emits `class`. + +`modifier_map` (`base.rb:242-249`) merges four sources, later winning: + +1. `skeleton: "skeleton"`, available on every component +2. `self.class.modifiers` — what `register_modifiers` accumulated +3. `DaisyUI.configuration.modifiers.for(component: self.class)` — app-added, scoped +4. `DaisyUI.configuration.modifiers.for(component: nil)` — app-added, global + +So a globally-configured modifier overrides a component-scoped one, which +overrides the class's own. + +`apply_prefix` (`base.rb:262-267`) returns its argument untouched when no prefix +is configured; otherwise it splits on whitespace and prefixes each token, which +is why a modifier may map to several classes (`primary: "bg-primary text-primary-content"` +in `COLOR_MODIFIERS`, `base.rb:41-119`, 11 entries: primary, secondary, accent, +neutral, base_100, base_200, base_300, info, success, warning, error). + +**`apply_prefix` calls `String#split`, so every caller coerces first.** Exactly +three places in `lib/` feed `component_class` to it, and all three call `to_s`: +`base_class` (`base.rb:210`), `responsive_classes` (`base.rb:227`) and +`ThemeController#view_template` (`theme_controller.rb:49`). A new caller that +forgets the `to_s` raises `NoMethodError` for the 58 components whose +`component_class` is a Symbol — but only when a prefix is configured, which 13 of +the 71 spec files do. (`apply_prefix` returns its argument untouched for `nil` +and whenever `DaisyUI.configuration.prefix` is `nil`, which is why the bug hid +for so long. `McpServer` also reads `component_class`, but prints it rather than +splitting it.) + +## Inheritance + +`Base.inherited` (`base.rb:138-145`) gives each subclass a `dup` of the parent's +modifier hash — so `register_modifiers` in a subclass adds without mutating the +parent — and copies `component_class` only when the parent set one explicitly +(`instance_variable_defined?(:@component_class)`), so an ordinary component +still derives its own from its name. + +## Sub-components + +A component's parts are instance methods that call `component_classes` +(`base.rb:271-274`), which prefixes the fixed part names and appends +`options.delete(:class)`: + +```ruby +def body(**options, &) + div(class: component_classes("card-body", options:), **options, &) +end +``` + +`render_as` (`base.rb:276-282`) dispatches `as:` either as a Phlex tag method +(a Symbol) or as another component class to render. + +## Configuration + +`DaisyUI::Configurable` (`lib/daisy_ui/configurable.rb`, 47 lines) is tiny and +**global mutable state**: `Configuration#prefix` and a `Modifiers` store keyed by +component class (`nil` = every component). `DaisyUI.configure` memoises one +`Configuration` for the process, so any spec that changes it must put it back — +see `../testing-and-ci/summary.md`. (`Configurable#configure` opens with +`self.configuration ||= Configuration.new` even though no `configuration=` +writer exists; the reader memoises and returns truthy, so `||=` short-circuits +and the missing writer is never called. Deleting the reader's `||=` memoisation +would turn that line into a `NoMethodError`.) + +## Related + +- `../interactive/summary.md` — the two components with JavaScript +- `../review/components.md` — accepted review findings about this layer diff --git a/lode/docs-site/summary.md b/lode/docs-site/summary.md new file mode 100644 index 00000000..1c3bc7d3 --- /dev/null +++ b/lode/docs-site/summary.md @@ -0,0 +1,89 @@ +# The docs site (`docs/`) + +A self-contained Rails 8.1 app with its own `Gemfile`, `.rubocop.yml`, `.rspec`, +`package.json` and Dockerfile. It depends on the gem through `gem "daisyui", +path: ".."`, so it renders the working tree's components, and on +[docs-kit](https://github.com/zoolutions/docs-kit) (`~> 1.0.8`) for the shell, +sidebar, search, code rendering and `/llms.txt` surfaces. The authoring contract +is `docs/AGENTS.md`, and `docs/.claude/skills/write-docs-page/SKILL.md` is the +page-writing skill it points at. + +## Registries, not a database + +Two plain-Ruby registries publish pages. Both expose docs-kit's "Registry v2" +shape (`.nav_items`, `#href`, `#view_class`) and are wired into +`c.nav_registries` in `config/initializers/docs_kit.rb`; the sidebar itself comes +from `c.nav = -> { DocsNav.groups }` because it interleaves both. + +| Registry | Entries | Slug renders | Linked only when | +|---|---|---|---| +| `Doc` (`app/models/doc.rb`, 53 lines) | 3 (`installation`, `getting-started`, `theming`) | `Views::Docs::Pages::<View>` | `view_class` resolves — today only `installation.rb` exists, so the other two 404 and are absent from the nav | +| `ComponentDoc` (`app/models/component_doc.rb`, 204 lines) | 70 components in 7 categories | the shared `Views::Components::Show` | the component's example namespace has at least one class | + +That "only if it exists" rule is deliberate: a registry row can be added before +its page is written without producing a dead link. `DocsController#show` and +`ComponentsController#show` apply the same test and `head :not_found` otherwise, +and `spec/requests/pages_spec.rb` asserts the 404 for `getting-started`. + +`ComponentDoc.example_class_for` (`component_doc.rb:144-151`) is the security +boundary for the reactive viewer: a class name arriving in a signed token is +resolved only when it is a `Class`, `< Views::Components::Example`, **and** its +name starts with `Views::Components::Examples::`. + +## Examples + +276 example classes live in 70 directories under +`app/views/components/examples/<component>/`. Each subclasses +`Views::Components::Example` (`app/views/components/example.rb`), `include`s +`DaisyUI`, and implements `#example`. That one method is used twice: rendered +live for the Preview tab, and read back as text for the Source tab via +`method_source` (`#example_source` strips the `def`/`end` lines and re-dedents). +Preview and Source therefore cannot drift. `.title` defaults to the humanized +class name and `.order` defaults to 100, so explicitly ordered examples sort +first. + +## Rendering + +`ApplicationController` includes `DocsKit::Controller`, which supplies +`render_page` (renders the Phlex view with `layout: false` — `DocsUI::Shell` is +the whole document — and serves the Markdown twin on a `.md` request). The app +defines no layout of its own. `allow_browser versions: :modern` is on, which is +why `spec/requests/pages_spec.rb` sends an explicit modern `User-Agent`. + +Routes (`config/routes.rb`): `/llms-full.txt`, `/llms.txt`, `/docs/search`, the +mounted `RailsIcons::Engine`, `/up`, `/service-worker`, `/manifest`, `root` → +`landings#show`, `/components/:component`, `/docs/:doc`. The phlex-reactive +engine mounts `POST /reactive/actions` itself — the file carries a comment +saying not to add it and to keep no catch-all that could shadow it. + +## CSS + +`bun run build:css` → `bin/build-css`. It resolves the `daisyui` and `docs-kit` +gem paths with `bundle show` and writes +`app/assets/stylesheets/tailwind.sources.css` with an +`@source "<gem path>/**/*.rb"` line for each, which +`application.tailwind.css` imports. **This is the mechanism the gem's responsive +comments depend on**; `bin/build-css` aborts with a message rather than building +a CSS file that silently omits a gem's classes. The daisyUI plugin is configured +`themes: all`, so `c.themes` in the docs-kit initializer (15 curated names) can +never name a theme the build lacks. + +## Deployment + +`.github/workflows/deploy-docs.yml` calls the shared +`zoolutions/docs-kit/.github/workflows/deploy.yml@main` with +`image: zoolutions/daisyui`, `service: daisyui`, on every published release and +on `workflow_dispatch`. The caller grants `packages: write`, because a reusable +workflow can only narrow the permissions it is given. + +`config/deploy.yml` (Kamal) must stay in step with the Dockerfile's runtime +layout: the app lives at `/gem/docs` (`WORKDIR`), SQLite data at `/data` +(`VOLUME /data`, `DATABASE_URL` set by `ENV` in the Dockerfile, not by Kamal), +volume `daisyui_data:/data`, `asset_path: /gem/docs/public/assets`. The build +context is the repo root (`context: ..`) so the `path: ".."` gem resolves; +`minimum_version: 4.0.7` keeps an older Kamal from deploying it. + +## Related + +- `../review/docs-site.md` — accepted review findings about this app +- `../testing-and-ci/summary.md` — the docs lint and Playwright jobs diff --git a/lode/interactive/summary.md b/lode/interactive/summary.md new file mode 100644 index 00000000..429c7026 --- /dev/null +++ b/lode/interactive/summary.md @@ -0,0 +1,115 @@ +# Popover components, the Rails engine, and the bundled Stimulus controllers + +Two components render into the browser's top layer via the native Popover API, +and each ships an accompanying Stimulus controller under +`app/javascript/daisy_ui/controllers/`. Everything else in the gem is markup only. + +## How the JavaScript reaches an app + +`lib/daisy_ui/engine.rb` (36 lines) is required from `lib/daisy_ui.rb` only +`if defined?(Rails::Engine)`. It has no `isolate_namespace` — it ships no +routes, models or helpers — and registers two initializers: + +| Initializer | Guard | Effect | +|---|---|---| +| `daisy_ui.assets` | `app.config.respond_to?(:assets)` | appends `<gem>/app/javascript` to `config.assets.paths` so Propshaft/Sprockets serve the files | +| `daisy_ui.importmap` (`before: "importmap"`) | `app.config.respond_to?(:importmap)`, then `respond_to?` again per collection | appends `<gem>/config/importmap.rb` to `importmap.paths` and `<gem>/app/javascript` to `importmap.cache_sweepers` | + +`config/importmap.rb` is a single `pin_all_from … under: "daisy_ui/controllers", +to: "daisy_ui/controllers"`, so the host app gets +`daisy_ui/controllers/daisy_dropdown_controller` and +`…/daisy_tooltip_controller` with no manual pin. Registering them is the host +app's job (`lazyLoadControllersFrom("daisy_ui/controllers", application)`). + +Both `app/` and `config/` are in the gemspec's file list, and the comment there +says so explicitly — dropping either prefix publishes a gem whose engine points +at files that are not in the package. + +## `Dropdown` — zero-JS by default + +`lib/daisy_ui/dropdown.rb` (258 lines). `Dropdown(:popover)` renders daisyUI's +**flat** structure: the trigger `<button>` and the popover panel are siblings, +and `dropdown` plus the placement classes ride the *panel* (that is the element +carrying `position-area`), not the wrapper. `popover_menu_options` +(`dropdown.rb:145-155`) therefore strips any `dropdown-content` class the caller +passes — that descendant rule forces `position: absolute`, which fights the top +layer. + +- `popover_id` (`dropdown.rb:95-97`) memoises `"dropdown_#{SecureRandom.hex(8)}"` + unless the caller passed `popover_id:`; the trigger's `popovertarget`, the + panel's `id`, and the CSS `anchor-name`/`position-anchor` pair all derive from it. +- `stimulus:` defaults to **`false`** — `stimulus?` (`dropdown.rb:104-106`) is + `@popover && @stimulus`, so a popover dropdown works with no JavaScript at all. + `true` uses `DEFAULT_STIMULUS_IDENTIFIER = "daisy-dropdown"`; a String or + Symbol overrides the identifier (`stimulus_identifier`, `dropdown.rb:109-113`). +- `PLACEMENT_MODIFIERS` is `%i[start center end top bottom left right]` (7). + +The controller (`daisy_dropdown_controller.js`, 170 lines) deliberately +implements *nothing* the Popover API already does — no open/close, no +light-dismiss, no Escape handling. It listens for the popover's own `toggle` +event and does two things: mirror `aria-expanded` onto the invoker, and, only +when `#supportsAnchorPositioning()` is false (Safari < 26, Firefox < 147), +lazily import `@floating-ui/dom` to position the panel. That import is **not** +bundled — the host app must pin it, and if it is missing the menu still opens +unpositioned with a console warning. Roving keyboard navigation is a second +opt-in (`data-daisy-dropdown-keyboard-value="true"`). + +## `Tooltip` — JavaScript required in popover mode + +`lib/daisy_ui/tooltip.rb` (231 lines). Outside popover mode a tooltip is one +element with `data-tip`; `tip:` defaults to `nil` and `view_template` +(`tooltip.rb:29-40`) emits `data_tip` only when `tip` is truthy, so a +content-only tooltip needs no dummy string. + +In popover mode: + +- `stimulus:` defaults to **`true`**, and `validate_stimulus!` + (`tooltip.rb:82-87`) raises `ArgumentError` unless the value is `true` or a + String/Symbol matching `/\A\S+\z/` — so anything else (`false`, `nil`, `""`, + `" "`, `:""`, a String with a space in it, a non-String/Symbol) raises rather + than emitting `data-controller=""`. The spec exercises the first five. +- `PLACEMENTS` is `%i[top bottom left right]` (4) and `placement` + (`tooltip.rb:93-95`) falls back to `:top`. `initialize` (`tooltip.rb:16-27`) + passes `modifiers + PLACEMENTS.select { options[it] == true }` into + `wire_controller`, so `Tooltip(:popover, bottom: true)` wires `bottom`, not the + `:top` default — the boolean-keyword form and the positional form agree. +- `content` (`tooltip.rb:42-62`) marks the panel `popover="manual"`, + `role="tooltip"`, applies `POPOVER_STYLE` (which starts it + `visibility:hidden`) and emits an arrow `<span>` carrying `ARROW_STYLE` and + the controller's `arrow` target. +- `view_template` adds `after:hidden` to the host element so daisyUI's stock + CSS arrow does not double up with the controller-positioned one. + +The controller (`daisy_tooltip_controller.js`, 231 lines) owns the lifecycle: +`connect` returns early without a `content` target, wires +pointerenter/pointerleave/focusin/focusout and sets `aria-describedby`; +`show()` sets `visibility:hidden`, calls `showPopover()`, then positions inside +a `requestAnimationFrame`. `#position()` (line 87) restores the caller's +`originalMaxWidth`, reads the computed `max-width`, and caps it at the viewport +width only when the viewport is narrower — a `max-w-64` tooltip stays 256px. +It then picks between the requested placement and its `OPPOSITE`, whichever +overflows less, shifts into the padded viewport, and only then sets +`visibility: "visible"`. + +`disconnect()` (line 40) removes every listener, tears down the open state and +restores `aria-describedby` **unconditionally**; only the `max-width` restore is +guarded by `hasContentTarget`. Global `resize`/`scroll`/`keydown` and +`visualViewport` listeners exist only while the tooltip is open +(`#setupOpen`/`#teardownOpen`). + +## Shared idiom: merging Stimulus data, never clobbering + +Both components merge rather than overwrite. `Dropdown#popover_root_attributes` +(`dropdown.rb:117-124`) and `Tooltip#wire_controller` (`tooltip.rb:97-103`) +space-join the new controller onto any caller-supplied `data[:controller]`; +`Dropdown#merge_stimulus_data` (`dropdown.rb:159-166`) and +`Tooltip#merge_stimulus_target` (`tooltip.rb:105-110`) do the same for +`data-<identifier>-target`, `uniq`-ing the tokens. Caller `style` is merged by +each class's `merge_style`, which chomps a trailing `;` before joining. + +## Related + +- `../components/summary.md` — the `Base` pipeline both of these build on +- `../review/interactive.md` — accepted review findings about this layer +- README sections "Optional `daisy-dropdown` Stimulus controller" and + "Collision-aware `Tooltip(:popover)`" are the user-facing versions diff --git a/lode/lode-map.md b/lode/lode-map.md new file mode 100644 index 00000000..2323ad31 --- /dev/null +++ b/lode/lode-map.md @@ -0,0 +1,46 @@ +# Lode map + +The index of this repository's durable memory. Read `summary.md` first, then the +area you are about to change and its `review/` file. + +## Root + +| File | What it holds | +|---|---| +| [`summary.md`](summary.md) | what the gem is, what it ships, and the three invariants every change has to keep | +| [`terminology.md`](terminology.md) | the repo's vocabulary — modifier, responsive comment, component class, prefix, sub-component, Kit short form, example, registry | +| [`practices.md`](practices.md) | patterns `.claude/rules/` does not state: Tailwind-readable class names, merging caller attributes, destructive option consumption, raising on invalid configuration, the two RuboCop configs | +| [`workflow.md`](workflow.md) | the profile the shared `/lode:` workflow skills read — commands, branches, layers, input shapes, wrong-here suggestions, docs, CI, flake sources, conflict rules, verification | +| [`plans/README.md`](plans/README.md) | where plans go: GitHub issues, or `lode/plans/` for a file plan | + +## Areas + +| Area | What it covers | +|---|---| +| [`components/summary.md`](components/summary.md) | `lib/daisy_ui/base.rb` and the 77 classes on it: loading through Zeitwerk, the modifier/option/attribute pipeline, class-string construction, inheritance, sub-components, global configuration | +| [`interactive/summary.md`](interactive/summary.md) | `Dropdown` and `Tooltip` in popover mode, the Rails engine, `config/importmap.rb`, and the two Stimulus controllers under `app/javascript/` | +| [`mcp-server/summary.md`](mcp-server/summary.md) | `exe/daisyui-mcp` and `lib/daisy_ui/mcp_server.rb`: the stdio JSON-RPC loop, its three tools, and how it discovers components through Zeitwerk's autoloads | +| [`docs-site/summary.md`](docs-site/summary.md) | the `docs/` Rails app: the two plain-Ruby registries, the 276 example classes, docs-kit rendering, the Tailwind source resolution, and the Kamal deploy | +| [`packaging-and-release/summary.md`](packaging-and-release/summary.md) | `daisyui.gemspec`'s file filter, `rake build`, `rake release[X.Y.Z]`, and `release.yml`'s four chained jobs | +| [`testing-and-ci/summary.md`](testing-and-ci/summary.md) | the gem's 71 RSpec files and their helpers, the docs app's request and system specs, and `main.yml`'s four jobs | + +## Review rules + +Accepted review findings, as rules about the system. `/lode:gate` enforces them; +`/lode:learn` adds to them. + +| File | What it covers | +|---|---| +| [`review/README.md`](review/README.md) | where these came from — no cubic learnings; merged PR review threads only | +| [`review/components.md`](review/components.md) | `apply_prefix` and Symbol coercion, empty-string guards, routing attributes to an inner control, orphaned docs examples | +| [`review/interactive.md`](review/interactive.md) | structured `class:` inputs, merging Stimulus data, optional `tip:`, boolean modifier forms, rejecting invalid identifiers, the two tooltip-controller rules | +| [`review/testing-and-ci.md`](review/testing-and-ci.md) | waiting for the positioned frame in a browser spec; why `around` hooks deliberately do not use `ensure` | +| [`review/packaging-and-release.md`](review/packaging-and-release.md) | the empty `git ls-files` trap, guard order in `rake release`, SHA-pinning the publishing action, idempotent republish | +| [`review/docs-site.md`](review/docs-site.md) | keeping Kamal and the Dockerfile in step, image-owned defaults, the docs app's own RuboCop, fenced-block languages | + +## Not in the lode + +- `lode/tmp/` — scratch for a run in progress. Gitignored, never committed. +- Style, commit format, branch names, the TDD loop and agent use live in + `.claude/rules/` (`coding-style.md`, `git-workflow.md`, `testing.md`, + `agents.md`). The lode links to them rather than restating them. diff --git a/lode/mcp-server/summary.md b/lode/mcp-server/summary.md new file mode 100644 index 00000000..8b32f400 --- /dev/null +++ b/lode/mcp-server/summary.md @@ -0,0 +1,71 @@ +# The bundled MCP server (`exe/daisyui-mcp`) + +The gem ships a Model Context Protocol server so an agent can ask the installed +gem what components it has, rather than guessing. It is a hand-written JSON-RPC +loop over stdio in `lib/daisy_ui/mcp_server.rb` (245 lines) — no MCP SDK, no +runtime dependency beyond `json`. + +`exe/daisyui-mcp` (7 lines) is the whole executable: `require "daisyui"`, +`require "daisy_ui/mcp_server"`, `DaisyUI::McpServer.run`. The gemspec derives +`s.executables` from the file list's `exe/` entries, so the binstub is installed +with the gem. + +## The loop + +`#run` (`mcp_server.rb:51-65`) reads a line from `$stdin`, parses it, writes the +response to `$stdout` and flushes. `JSON::ParserError` is caught per iteration +and only `warn`ed, so a malformed line does not end the session; `nil` from +`gets` (EOF) breaks the loop. Startup logging goes to stderr (`warn`) because +stdout is the protocol channel. + +`#handle_request` (`mcp_server.rb:69-90`) dispatches four methods and rescues +`StandardError` into a JSON-RPC error, so no tool bug can kill the server: + +| `method` | Handler | Response | +|---|---|---| +| `initialize` | `handle_initialize` | `PROTOCOL_VERSION` `"2024-11-05"`, `capabilities.tools`, `serverInfo` `{name: "daisyui", version: DaisyUI::VERSION}` | +| `tools/list` | `handle_tools_list` | the frozen `TOOLS` array | +| `tools/call` | `handle_tools_call` | `{content: [{type: "text", text: …}]}` | +| `notifications/initialized` | — | returns `nil`: notifications get no response | +| anything else | — | error `-32601`, "Method not found: …" | + +An unknown tool name returns `isError: true` with a text body rather than a +JSON-RPC error; an internal exception returns code `-32603`. + +## The three tools + +`TOOLS` (`mcp_server.rb:13-41`) declares exactly three: + +- `list_components` — every component as `- Name (css-class) - N modifiers`, + plus a total. +- `get_component` (requires `component`) — CSS class, the sorted modifier names, + and a canned usage block. Falls back to a case-insensitive match + (`find_component_class`, `mcp_server.rb:233-239`) before reporting "not found". +- `search_components` (requires `query`) — matches the component name first + (`next` on a hit, so a name match never also reports modifiers), then any + modifier key containing the downcased query. + +## What counts as a component + +`#component_classes` (`mcp_server.rb:222-231`) memoises a walk of +`DaisyUI.constants`, skipping `EXCLUDED_CONSTANTS` +(`%i[Base Configurable Configuration Modifiers VERSION UPDATED_AT McpServer]`, +7 entries) and keeping only constants that are a `Class` **and** `< DaisyUI::Base`. +The `< Base` test is what does the real filtering — it is what drops `Engine`, +and `Configuration`/`Modifiers` are nested under `Configurable` so they never +appear in `DaisyUI.constants` at all. + +`DaisyUI.constants` lists Zeitwerk's autoload entries too (Zeitwerk registers +them with `Module#autoload`, and Ruby counts an autoload name as a constant), +so `const_get` on each one loads the file: the walk sees all 77 components in a +process that has referenced none of them. The memoisation in `@component_classes` +means a component defined *after* the first tool call is invisible for the rest +of the session — harmless for a stdio server whose library is frozen at boot. + +There is no spec for this file: `spec/lib/daisy_ui/` has no `mcp_server_spec.rb`. + +## Related + +- The repo's own `.claude/settings.local.json` allows a *different*, external + MCP tool (`mcp__daisyui__daisyUI-Snippets`) for looking up daisyUI class + names; that server is not this file. See `../workflow.md` → Commands. diff --git a/lode/packaging-and-release/summary.md b/lode/packaging-and-release/summary.md new file mode 100644 index 00000000..d68af33b --- /dev/null +++ b/lode/packaging-and-release/summary.md @@ -0,0 +1,89 @@ +# Packaging and release + +## What ships in the gem + +`daisyui.gemspec` builds `s.files` from `git ls-files -z`, filtered to four +prefixes — `exe/`, `lib/`, `app/`, `config/` — plus `CHANGELOG.md`, +`LICENSE.txt` and `README.md`. `app/` and `config/` are not optional: they carry +the Stimulus controllers and the importmap pin file the engine points at, so a +build that omits them publishes a gem whose `daisy_ui.importmap` initializer +references a missing file. + +Two things make that robust: + +- The `git ls-files` call runs with `chdir: __dir__` and `err: IO::NULL`, and + **raises `Errno::ENOENT` itself when the result is empty**. `rescue Errno::ENOENT` + then falls back to a `Dir[]` glob over the same prefixes. Rescuing only the + "git is not installed" case would not be enough: in the docs Docker build + `git` exists but `.git/` is excluded, so `ls-files` succeeds with no output. + Both branches must list the same prefixes or the two builds differ. +- `s.executables` is derived from the file list (`s.files.grep(%r{\Aexe/})`), so + adding a binstub is a one-line change. + +Runtime dependencies are `phlex ("~> 2.0", ">= 2.0.0")` and `zeitwerk ~> 2.6`; `required_ruby_version` +is `>= 3.2`. `rubygems_mfa_required` is set. + +`rake build` (`Rakefile:22-32`) builds with `--strict`, unpacks into +`/tmp/gem-verify`, prints the file list and cleans up — the quick local check +that the filter still does what it should. + +## `rake release[X.Y.Z]` + +`Rakefile:35-176`. The task is the only supported way to cut a release and it +runs **directly on `main`** — it pushes to `origin/main` itself, so a release is +not a PR. + +Guards, in order (`Rakefile:45-49`): abort unless `git branch --show-current` is +`main`; abort unless `git status --porcelain` is empty. The branch check comes +first, because the later steps commit and `git push origin main` — from a +feature branch that would push the feature branch's HEAD onto `main`. + +Then: rewrite `lib/daisy_ui/version.rb`; rewrite `lib/daisy_ui/updated_at.rb` +with `Time.now.utc`; `bundle install` at the root and, when it exists, +`cd docs && bundle install` (so `docs/Gemfile.lock`'s `daisyui (X.Y.Z)` pin +never drifts); `gem build --strict` as a smoke test; commit +`chore: bump version to X.Y.Z`; push; `gh release create <tag> --generate-notes`. + +`rake release[pre]` keeps the current version and marks the release a +pre-release; a version string matching `/alpha|beta|rc|pre/` is also treated as +one. `rake release[X.Y.Z,force]` deletes the existing release and tag first +(`gh release delete --cleanup-tag`, `git tag -d`) — the escape hatch for a +release whose pipeline failed. + +Every step is idempotent-by-skip: the version rewrite, the commit, the push and +the release creation each check first and print a `⊘ … (skipped)` line instead +of failing. + +`CHANGELOG.md` is **not** touched by the task. Its only released heading is +`[0.1.0]`; everything since sits under `[Unreleased]` while `VERSION` is at +1.3.1, and GitHub release notes come from `--generate-notes` over the commits. + +## `.github/workflows/release.yml` + +Triggered by `release: types: [published]` — the release is public before the +pipeline runs. That window is accepted deliberately: the jobs are chained, so +nothing reaches RubyGems unless `test` and `build` pass first, and the worst +case is a public GitHub release with no matching gem for a few minutes. Note +that `rake release` itself runs `gem build --strict` but **not** the specs, so +the `test` job below is the only automated gate between a tag and a push. + +| Job | Needs | Does | +|---|---|---| +| `test` | — | `bundle exec rspec` on Ruby 3.3 and 3.4, `fail-fast: true` | +| `build` | `test` | aborts unless the tag (minus `v`) equals `DaisyUI::VERSION`; `gem build --strict`; unpacks and **fails** if the package contains any `.git*`, any `*.gemspec`, or a `spec`/`test` directory; writes `.sha256`/`.sha512`; uploads the artifact | +| `publish-rubygems` | `build` | verifies both checksums, configures trusted publishing, signs with Sigstore and `gem push --attestation` | +| `upload-release-assets` | `build`, `publish-rubygems` | `gh release upload … --clobber` | + +Two properties make a rerun safe: `gem push` is skipped when +`gem info daisyui --exact --remote` already reports that version (a +`::warning::`, not a failure), and the asset upload uses `--clobber`. + +`rubygems/configure-rubygems-credentials` is pinned to the 40-character commit +SHA `bc6dd217f8a4f919d6835fcfefd470ef821f5c44` with a `# v1.0.0` comment — it +runs in the publish path with `id-token: write`, where a mutable tag would be a +supply-chain hole. The `actions/*` steps are pinned by major tag. + +## Related + +- `../review/packaging-and-release.md` — accepted review findings +- `../docs-site/summary.md` — the separate docs deploy workflow diff --git a/lode/plans/README.md b/lode/plans/README.md new file mode 100644 index 00000000..07986882 --- /dev/null +++ b/lode/plans/README.md @@ -0,0 +1,13 @@ +# Plans + +Plans for this repository are **GitHub issues** on `zoolutions/daisyui`. That is +the default the local `/plan` command already used, and it is what `/lode:plan` +should keep using: an issue number feeds straight into the implementation +workflow, and the plan and the work that closes it stay in one place. + +`/lode:plan --file` writes here instead — `lode/plans/YYYY-MM-DD-<slug>.md` — +when a plan is too long or too speculative for an issue, or when it needs to be +reviewed alongside the code in a PR. + +There are no plan files in the tree today, and no `docs/plans/` directory: every +change so far arrived as an issue or straight as a PR. diff --git a/lode/practices.md b/lode/practices.md new file mode 100644 index 00000000..22b072a3 --- /dev/null +++ b/lode/practices.md @@ -0,0 +1,100 @@ +# Practices + +Patterns this repository follows that `.claude/rules/` does not already state. +Style, file size, commit format, agent use and the TDD loop live there: +`../.claude/rules/coding-style.md`, `../.claude/rules/testing.md`, +`../.claude/rules/git-workflow.md`, `../.claude/rules/agents.md`. + +## A class name is not a class name until Tailwind can read it + +Anything that ends up in a `class=` attribute must exist as a literal string in +a file Tailwind scans. Three consequences the code relies on: + +- `register_modifiers` values are literal (`primary: "btn-primary"`), never + built by interpolation. +- Breakpoint variants exist **only** in the comments above each entry. The + established shape is six variants per modifier — `sm:`, `@sm:`, `md:`, `@md:`, + `lg:`, `@lg:` — written one per line (427 of the 472 comment blocks). Eight + files add `xl:`/`@xl:` for a total of eight (`badge`, `drawer`, `dropdown`, + `loading`, `menu`, `modal`, `table`, `tabs`: 42 blocks), and three blocks + (`card.rb` `bordered`, `carousel.rb` `horizontal`, `skeleton.rb` `text`) pack + the same six variants two to a line. What matters is the set of variants, not + the line count. Copy the neighbouring modifier's set rather than inventing + one; a missing variant is dropped from the built CSS with no error anywhere. +- A modifier mapping to several classes (`"bg-primary text-primary-content"`) + needs each *token* spelled out in the comment, because `responsive_classes` + splits and prefixes them one at a time. + +## Merge into caller-supplied attributes; never overwrite + +Every place a component adds to something the caller may also have set, +it merges. `data-controller` and `data-<identifier>-target` are space-joined +and `uniq`'d; `style` is joined after chomping a trailing `;`; `class` goes +through `merge_classes`/`component_classes`. Before writing `options[:x] = …`, +check whether `x` is caller-visible — `review/interactive.md` records the +clobbered-target bug that was exactly this. + +The exception is `Dropdown#popover_menu_options` (`dropdown.rb:145-155`), which +rebuilds the panel's class list rather than merging: it has to strip +`dropdown-content`, whose descendant rule would force `position: absolute` onto a +top-layer popover. It still preserves the caller's other classes. + +Coerce before you split: `apply_prefix` calls `String#split`, and +`component_class` is a Symbol in 58 of the 77 components. + +## Options are destroyed as they are consumed + +`classes` deletes `:class` and `:responsive` from `@options` so `attributes` +can splat the remainder straight onto the element. Anything a component +consumes as configuration must be deleted, or it is rendered as an HTML +attribute; anything it does *not* consume is passed through on purpose. This is +also why the argument order inside `classes` is load-bearing — `base_class` +reads `:responsive` before `responsive_classes` deletes it. + +## Route caller attributes to the element they belong on + +A component that renders a wrapper plus an inner control needs a named path for +the inner one. `Otp` takes `input_attributes:` for the `<input>`; without it +`name`/`id`/`value` land on the outer `<label>` and the component cannot be +submitted in a form. When adding a component with a hidden inner input, provide +the keyword from the start. + +## Reject invalid configuration at construction + +`Tooltip#validate_stimulus!` raises `ArgumentError` rather than rendering +`data-controller=""`. Prefer a raise at initialize time over markup that looks +wired and silently does nothing — a Phlex component has no other place to +report a problem. + +## Empty string is not nil + +Guards written as `if size` treat `""` as present and emit `--size: ;`. +`RadialProgress` uses `if size && !size.to_s.empty?`, which keeps the +empty-string suppression while tolerating a non-String. Use the same shape for +any optional value interpolated into a style or attribute. + +## Specs assert the whole rendered string + +`spec/support/html_helpers.rb#html` normalises an expected HTML heredoc so it +can be compared with `eq`. A spec that only checks a substring hides attribute +regressions; match the file you are editing. + +## Restore global configuration in an `around` hook + +`DaisyUI.configuration` is process-global. Every spec that changes it restores +it after `example.run`. See `review/testing-and-ci.md` for why the suite deliberately +does not use `ensure`. + +## Docs examples are part of the component + +`docs/app/views/components/examples/<component>/` is where a modifier is +demonstrated. Removing or renaming a modifier without updating those classes +leaves an example that renders a class daisyUI no longer defines, and the docs +build will not complain. Grep the examples directory for the modifier before +deleting it. + +## Two RuboCop configurations + +The gem root and `docs/` have separate `.rubocop.yml` files that disagree (most +visibly on multiline trailing commas). Lint the tree you edited, from its own +directory: `bundle exec rubocop` at the root, `bin/rubocop` in `docs/`. diff --git a/lode/review/README.md b/lode/review/README.md new file mode 100644 index 00000000..c365a27b --- /dev/null +++ b/lode/review/README.md @@ -0,0 +1,30 @@ +# Accepted review findings + +Rules about this system that came out of code review, written in system voice so +`/lode:gate` can enforce them. One file per lode area; each entry names the rule, +why it holds, where it lives in the code, which direction is safe when in doubt, +and the review thread it came from. + +## Sources + +- **cubic learnings: none.** No learnings are recorded for this repository (it is + either not active in cubic or has none accepted). Everything here comes from + the second source. +- **Merged PR review threads** on `zoolutions/daisyui` — CodeRabbit on PRs 13, + 15, 16, 17, 18, 33 and cubic-dev-ai on PR 37, plus the author's replies. A + reply of the "fixed in `<sha>`" / "agreed" kind made the finding an entry; a + reasoned rejection made it a `### Not a bug` entry. + +An entry stays only while its subject exists in the tree. Findings whose file or +mechanism has since been replaced were dropped rather than carried forward. + +## Files + +- [`components.md`](components.md) — `Base`, the argument pipeline, and the 77 + components built on it +- [`interactive.md`](interactive.md) — `Dropdown`, `Tooltip` and the two Stimulus + controllers +- [`testing-and-ci.md`](testing-and-ci.md) — the two suites and their hooks +- [`packaging-and-release.md`](packaging-and-release.md) — gemspec, `rake release`, + `release.yml` +- [`docs-site.md`](docs-site.md) — the `docs/` Rails app, its lint and its deploy diff --git a/lode/review/components.md b/lode/review/components.md new file mode 100644 index 00000000..1e505afe --- /dev/null +++ b/lode/review/components.md @@ -0,0 +1,73 @@ +# Review rules: components and `Base` + +Accepted findings about `lib/daisy_ui/base.rb` and the 77 classes on it. +See [`../components/summary.md`](../components/summary.md) for how the layer works. + +## `apply_prefix` splits a String, so every caller coerces first + +`Base#apply_prefix` (`base.rb:262-267`) calls `String#split` to prefix each token +of a multi-class modifier. 58 of the 77 components set `self.component_class` to +a **Symbol**, so any caller that hands it the raw value raises `NoMethodError: +undefined method 'split' for an instance of Symbol` — and only when a prefix is +configured, which is why it survived: the default `apply_prefix` returns its +argument untouched before ever splitting. + +Both readers coerce today: `base_class` uses `component_class&.to_s` +(`base.rb:210`) and `responsive_classes` uses `apply_prefix(base_class_value.to_s)` +(`base.rb:227`). `ThemeController#view_template` does the same at +`theme_controller.rb:49`. + +- **Safe direction**: `.to_s` before `apply_prefix`, always. It costs nothing on + a String and is the difference between working and raising on a Symbol. +- **Not a fix**: normalising one component's `component_class` to a String. That + was the first proposal; it hides the bug for one class and leaves the other 57. +- **Test**: `spec/lib/daisy_ui/base_spec.rb:6-21`, "coerces the symbol + component_class before prefixing". +- Origin: PR 13 thread on `lib/daisy_ui/link.rb:5`; re-raised and fixed at the + root in PR 18 (`3e559b4`). + +## A guard on an optional style value tests for emptiness, not truthiness + +`RadialProgress#view_template` appends `--size:` only when +`size && !size.to_s.empty?` (`radial_progress.rb:21`), and `--thickness:` under +the same shape on the next line. A bare `if size` treats +`""` as present and emits `--size: ;`, which is an invalid declaration the +browser drops silently; a bare `if size.empty?` raises on an Integer. The +`.to_s.empty?` shape keeps both properties. + +- **Safe direction**: reject the empty string at the guard rather than let it + reach the style string. +- **Where else it applies**: any optional value a component interpolates into a + `style` or a data attribute. +- **Test**: none for the empty string. `spec/lib/daisy_ui/radial_progress_spec.rb` + covers `size: "6rem"` and the omitted case, not `size: ""`. +- Origin: PR 18 thread on `lib/daisy_ui/radial_progress.rb:22` (`3e559b4`). + +## A component with a hidden inner control needs a named path to it + +`Otp` renders a `<label>` wrapper around a real `<input>`. Every keyword a caller +passes lands on the wrapper, so `name`, `id`, `value` and `aria-*` never reached +the input and the component could not be submitted in a form. `Otp#initialize` +now takes `input_attributes: {}` and splats it into `input(...)` +(`otp.rb:7`, `otp.rb:23`). + +- **Safe direction**: when a new component wraps an interactive element, add the + keyword in the same change. Retrofitting it is an API addition callers have to + learn about. +- **Test**: `spec/lib/daisy_ui/otp_spec.rb:165`, the `input_attributes` group. +- Origin: PR 17 thread on `lib/daisy_ui/otp.rb:22` (`92343f8`). + +## Removing a modifier orphans the docs examples that use it + +Dropping a key from a `register_modifiers` block does not break anything that +fails loudly: the example under +`docs/app/views/components/examples/<component>/` keeps rendering, now emitting a +class daisyUI no longer defines, and no build or spec complains. + +- **Safe direction**: grep + `docs/app/views/components/examples/` for the modifier name before deleting it, + and delete or rewrite the example in the same PR. +- **Test**: none — this is the gap. `docs/spec/requests/pages_spec.rb` renders + component pages but asserts nothing about which classes they emit. +- Origin: PR 13 thread on `lib/daisy_ui/button.rb:35` — removing `glass` and + `no_animation` left two example files behind (`bf479cd` deleted them). diff --git a/lode/review/docs-site.md b/lode/review/docs-site.md new file mode 100644 index 00000000..de169f44 --- /dev/null +++ b/lode/review/docs-site.md @@ -0,0 +1,70 @@ +# Review rules: the docs site + +Accepted findings about the `docs/` Rails app, its lint and its deploy. See +[`../docs-site/summary.md`](../docs-site/summary.md) for how it is put together. + +## `config/deploy.yml` and the Dockerfile describe one container, twice + +Kamal's config and `docs/Dockerfile` must agree on the runtime layout or the +deploy succeeds and the site comes up wrong. The pairs that have to match: + +| Dockerfile | `config/deploy.yml` | +|---|---| +| `WORKDIR /gem/docs` | `asset_path: /gem/docs/public/assets` | +| `VOLUME /data` | `volumes: ["daisyui_data:/data"]` | +| `ENV DATABASE_URL="sqlite3:///data/production.sqlite3"` | *(absent — set by the image)* | + +An `asset_path` pointing at the old layout means the asset bridge between +versions silently does nothing, and a volume mounted at the wrong path loses the +SQLite database on every deploy. + +- **Safe direction**: a change to either file is a change to both. Re-read the + Dockerfile's `WORKDIR`, `VOLUME` and `ENV` lines before editing `deploy.yml`. +- **Test**: none — no spec covers deploy config. +- Origin: PR 15 thread on `docs/config/deploy.yml:36` (`44b5b3d`). + +## A value the image already sets is not a Kamal secret + +`DATABASE_URL` is set by `ENV` in the Dockerfile (line 90) and appears nowhere in +`config/deploy.yml` or `.kamal/secrets`. Listing it as a secret means a missing +environment variable at deploy time overrides the image's correct default with an +empty string, and `.kamal/secrets` grows an entry nobody can explain. Today that +file carries exactly one line, `KAMAL_REGISTRY_PASSWORD`, and says in a comment +why `SECRET_KEY_BASE` sits in `deploy.yml` under `env.clear` instead. + +- **Safe direction**: the image owns its own defaults; Kamal supplies only what + differs per host. +- **Test**: none. +- Origin: PR 15 thread on `.github/workflows/deploy-docs.yml:88` (`44b5b3d`). + +## The docs app's RuboCop is not the gem's + +`docs/.rubocop.yml` inherits docs-kit's config, targets Ruby 3.4, and disagrees +with the root file where the two overlap: + +- `Style/TrailingCommaInHashLiteral` / `InArrayLiteral`: `comma` in `docs/`, + `no_comma` at the root. +- `Rails/FilePath: EnforcedStyle: arguments`: `Rails.root.join("app", "assets", + "images", "og")`, not `Rails.root.join("app/assets/images/og")`. + +The root config excludes `docs/**/*` entirely, so running `bundle exec rubocop` +at the root proves nothing about `docs/`. + +- **Safe direction**: lint the tree you edited from its own directory — + `bundle exec rubocop` at the root, `bin/rubocop` inside `docs/`. +- **Test**: CI's `docs-lint` job runs `bin/rubocop` from `docs/`. +- Origin: PR 33 threads on `docs/lib/tasks/docs_kit_og.rake:31` and `:32` + (`ceb8ef3`) — two findings, one rule. + +## Every fenced code block declares a language + +Markdown in this repo — `README.md`, `docs/DEPLOYMENT.md`, the command and rule +files under `.claude/` — opens every fence with a language, `text` for diagrams +and transcripts that have no syntax. Nothing in CI enforces it; the review bot +does, on every PR that touches a Markdown file. + +- **Safe direction**: `text` when in doubt. A bare fence is a review comment. +- **Test**: none — no markdownlint config exists in the repo. +- Origin: PR 13 thread on `.claude/commands/review-pr.md:53` (`bf479cd`) and + PR 15 threads on `docs/DEPLOYMENT.md:14` and `:20` (`99dafbe`) — three + findings, one rule. diff --git a/lode/review/interactive.md b/lode/review/interactive.md new file mode 100644 index 00000000..50fb20ce --- /dev/null +++ b/lode/review/interactive.md @@ -0,0 +1,109 @@ +# Review rules: `Dropdown`, `Tooltip` and the Stimulus controllers + +Accepted findings about the two popover components and the JavaScript that ships +with them. See [`../interactive/summary.md`](../interactive/summary.md) for how +the layer works. + +## A caller's `class:` may be a String, an Array or nil + +`Dropdown#popover_menu_options` (`dropdown.rb:145-155`) rebuilds the popover +panel's class list from scratch, so it has to take the caller's value apart. It +reads it as `Array(options.delete(:class)).flat_map { |v| v.to_s.split }` and +then rejects the empties and `dropdown-content`. A bare `to_s.split` turns +`class: ["a", "b"]` into the single token `["a",` — a malformed class name the +browser keeps and no test notices. + +- **Safe direction**: `Array(...)` first, `to_s.split` per element, `reject` the + empties. Anywhere a component decomposes a caller-supplied class value. +- **Test**: `spec/lib/daisy_ui/dropdown_spec.rb:509` proves `dropdown-content` is + stripped; the Array shape itself is not covered. +- Origin: PR 16 thread on `lib/daisy_ui/dropdown.rb:143` (`bc03620`). + +## A Stimulus target is merged onto the caller's, never assigned over it + +`Dropdown#merge_stimulus_data` (`dropdown.rb:159-166`) splits the existing +`data-<identifier>-target`, appends its own token, `uniq`s and re-joins. +Assigning `data[key] = target` drops whatever the caller wired to the same +controller, and the loss is silent — the element renders, the caller's target +just never resolves. + +The same rule governs the controller name itself: +`Dropdown#popover_root_attributes` (`dropdown.rb:117-124`) and +`Tooltip#wire_controller` (`tooltip.rb:97-103`) space-join onto any existing +`data[:controller]`. + +- **Safe direction**: read, merge, `uniq`, write. Before any `options[:x] = …`, + ask whether `x` is caller-visible. +- **Test**: `spec/lib/daisy_ui/dropdown_spec.rb:607`, "space-joins a + caller-supplied controller instead of clobbering it". +- Origin: PR 16 thread on `lib/daisy_ui/dropdown.rb:164` (`bc03620`). + +## `tip:` is optional, and an omitted tip emits no `data-tip` + +`Tooltip#initialize` defaults `tip:` to `nil` and `view_template` +(`tooltip.rb:29-40`) sets `data_tip` only when `tip` is truthy. Requiring the +keyword forced a content-only tooltip to pass `tip: ""`, which renders +`data-tip=""` — an empty daisyUI bubble on hover. + +- **Safe direction**: a keyword that only exists to feed one attribute defaults + to `nil`, and the attribute is omitted when it is. +- **Test**: `spec/lib/daisy_ui/tooltip_spec.rb:8`, "renders without data-tip when + tip is omitted". +- Origin: PR 17 thread on `lib/daisy_ui/tooltip.rb:20` (`92343f8`). + +## The boolean-keyword form of a modifier must reach the same code as the symbol + +`Tooltip(:bottom)` and `Tooltip(bottom: true)` are the same request, but only the +first arrives in `modifiers` — `Base#extract_boolean_modifiers` runs later, in +`super`. `Tooltip#initialize` (`tooltip.rb:16-27`) therefore computes +`PLACEMENTS.select { |candidate| options[candidate] == true }` itself and passes +`modifiers + boolean_placements` into `wire_controller`, so the controller's +`placement-value` agrees with the rendered class either way. + +- **Safe direction**: any component that reads its own modifiers *before* + `super` has to look in `options` too. +- **Test**: `spec/lib/daisy_ui/tooltip_spec.rb:218`, "derives placement from + boolean directional modifiers". +- Origin: PR 37 thread on `lib/daisy_ui/tooltip.rb:20` (`04e60e8`). + +## An invalid controller identifier raises instead of rendering + +`Tooltip#validate_stimulus!` (`tooltip.rb:82-87`) accepts `true` or a +String/Symbol matching `/\A\S+\z/` and raises `ArgumentError` for everything +else. `false`, `nil`, `""`, `" "` and `:""` would otherwise render +`data-controller=""` or `data-controller=" "`: markup that looks wired and does +nothing, with no error anywhere in the stack. + +- **Safe direction**: a Phlex component has no channel to report a problem other + than raising at construction. Reject at `initialize`. +- **Test**: `spec/lib/daisy_ui/tooltip_spec.rb:253`, which exercises all five + invalid values. +- Origin: PR 37 thread on `lib/daisy_ui/tooltip.rb:78` (`04e60e8`). + +## The tooltip controller restores the caller's `max-width` before measuring + +`#position()` (`daisy_tooltip_controller.js:87`) writes +`this.contentTarget.style.maxWidth = this.originalMaxWidth` (captured in +`connect`, line 23) **before** reading the computed value, then caps it at the +padded viewport only when the viewport is narrower. Measuring without restoring +first reads back the previous frame's clamp, so a `max-w-64` tooltip shrinks a +little more on every reposition. + +- **Safe direction**: restore, measure, clamp — in that order, every frame. +- **Test**: `docs/spec/system/tooltip_popover_spec.rb` uses `max-w-64` and + asserts the computed 256px constraint survives. +- Origin: PR 37 thread on `daisy_tooltip_controller.js:89` (`04e60e8`). + +## `disconnect()` tears everything down unconditionally + +`disconnect()` (`daisy_tooltip_controller.js:40-48`) removes the four element +listeners, calls `#teardownOpen()` and `#restoreDescription()` with no guard; +only the `max-width` restore is behind `if (this.hasContentTarget)`. An early +`return` on `!this.hasContentTarget` leaks the pointer/focus listeners, the +`window`/`visualViewport` listeners and the pending animation frame whenever the +content target goes away while the controller element stays connected. + +- **Safe direction**: guard the one line that needs the element, never the whole + cleanup. Cleanup is not allowed to be conditional on state that may have moved. +- **Test**: none — no JS unit suite exists in this repo. +- Origin: PR 37 thread on `daisy_tooltip_controller.js:41` (`292ff6b`). diff --git a/lode/review/packaging-and-release.md b/lode/review/packaging-and-release.md new file mode 100644 index 00000000..c98d3df5 --- /dev/null +++ b/lode/review/packaging-and-release.md @@ -0,0 +1,76 @@ +# Review rules: packaging and release + +Accepted findings about `daisyui.gemspec`, the `release` Rake task and +`.github/workflows/release.yml`. See +[`../packaging-and-release/summary.md`](../packaging-and-release/summary.md) for +how the path works. + +## An empty `git ls-files` is a failure, not an empty gem + +`daisyui.gemspec` reads its file list from `git ls-files -z` and falls back to a +`Dir[]` glob. `rescue Errno::ENOENT` alone covers only "git is not installed" — +but the case that actually happens is the docs Docker build, where `git` exists +and `.git/` is excluded from the context: `ls-files` exits 0 with no output and +`s.files` silently becomes `[]`. The gemspec therefore ends the `begin` block +with `files.empty? ? raise(Errno::ENOENT) : files`, so the empty result takes the +same branch as the missing binary. + +- **Safe direction**: an empty result from a subprocess that should always return + something is an error. Raise into the fallback rather than shipping the empty + value. +- **Both branches list the same prefixes** — `exe/`, `lib/`, `app/`, `config/` + plus `CHANGELOG.md`, `LICENSE.txt`, `README.md`. `app/` and `config/` carry the + Stimulus controllers and the importmap pin file the engine points at; dropping + either prefix from one branch makes the two builds differ. +- **Test**: none directly. `rake build` unpacks the gem and prints the file list, + and `release.yml`'s `build` job fails the release if the package contains a + `.git*`, a `*.gemspec` or a `spec`/`test` directory. +- Origin: PR 15 thread on `daisyui.gemspec:21` (`44b5b3d`). + +## The branch check comes before the dirty check + +`rake release[X.Y.Z]` commits, then `git push origin main`. Run from a feature +branch it would push that branch's HEAD onto `main`. The task aborts unless +`git branch --show-current` is `main` (`Rakefile:45-46`) *before* it checks +`git status --porcelain` (`Rakefile:48-49`) — a clean tree on the wrong branch is +the dangerous case, so the cheaper, more specific guard runs first. + +- **Safe direction**: any new step added to the task goes after both guards. +- **Test**: none — the task is not covered by a spec. +- Origin: PR 13 thread on `Rakefile:142` (`bf479cd`). + +## An action in the publish path is pinned to a commit SHA + +`rubygems/configure-rubygems-credentials` runs in `publish-rubygems`, the job +that holds `id-token: write` and pushes to RubyGems. It is pinned to the +40-character SHA `bc6dd217f8a4f919d6835fcfefd470ef821f5c44` with a `# v1.0.0` +comment; a mutable tag there would let a retagged release publish as this gem. +The `actions/*` steps elsewhere in the file are pinned by major tag. + +- **Safe direction**: any third-party action that runs with `id-token: write` or + `contents: write` gets a SHA pin and a version comment. +- **Test**: none — this is a config invariant. +- Origin: PR 13 thread on `.github/workflows/release.yml:132` (`bf479cd`). + +## Republishing the same version is a warning, and asset upload clobbers + +Re-running a release must not fail on work already done. `publish-rubygems` +checks `gem info daisyui --exact --remote` for the version and emits a +`::warning::` instead of pushing when it is already there; `upload-release-assets` +passes `--clobber` to `gh release upload`. + +- **Safe direction**: every step in the release pipeline is safe to run twice. + The Rake task follows the same rule with its `⊘ … (skipped)` branches. +- **Test**: none. +- Origin: PR 13 thread on `.github/workflows/release.yml:141` (`bf479cd`). + +## Not a bug: the GitHub release is published before CI runs + +`release.yml` triggers on `release: types: [published]`, so the release is public +for the minutes the pipeline takes. Reviewed and kept: the jobs are chained +(`build` needs `test`, `publish-rubygems` needs `build`), so nothing reaches +RubyGems unless the specs pass and the package verifies. The worst case is a +GitHub release with no matching gem for a few minutes — the release is the +trigger, not the artifact. + +- Origin: PR 13 thread on `.github/workflows/release.yml:5`. diff --git a/lode/review/testing-and-ci.md b/lode/review/testing-and-ci.md new file mode 100644 index 00000000..fc8b581e --- /dev/null +++ b/lode/review/testing-and-ci.md @@ -0,0 +1,45 @@ +# Review rules: the two suites + +Accepted findings about `spec/` and `docs/spec/`. See +[`../testing-and-ci/summary.md`](../testing-and-ci/summary.md) for how they are +wired. + +## A browser spec waits for the positioned frame before reading geometry + +`daisy_tooltip_controller.js` opens the popover and then positions it inside a +`requestAnimationFrame` (`#schedulePosition`, line 82), setting `left`, `top` and +`visibility: "visible"` together at the end of `#position()`. `:popover-open` +matches the moment `showPopover()` returns — one frame too early — so any +`getBoundingClientRect()` read at that point can see the unpositioned, +`visibility: hidden` element and the bounds assertion is racy. + +`docs/spec/system/tooltip_popover_spec.rb` therefore waits on +`have_css("#…:popover-open", visible: true)` and asserts the computed +`visibility == "visible"` before trusting any coordinate. + +- **Safe direction**: the assertion that the work finished is the thing to wait + on — `visibility`, not "the element exists". A retry loop around a coordinate + hides the race instead of proving it is gone. +- **Test**: the spec is itself the test. +- Origin: PR 37 thread on `docs/spec/system/tooltip_popover_spec.rb:29` + (`04e60e8`). + +## Not a bug: `around` hooks restore after a bare `example.run`, not in `ensure` + +Reviewers repeatedly proposed wrapping the restore in `begin … ensure … end` in +the three spec files a PR happened to touch. Rejected, three times, with the same +reason: the pattern is suite-wide, not local. 13 of the 71 spec files set +`DaisyUI.configuration.prefix` in an `around` hook and every one of them restores +after a bare `example.run`; a `grep` for `ensure` in `spec/` returns nothing. + +The hazard is real and documented — an example that *raises* (rather than failing +an expectation) skips the restore and leaks a prefix into every later example — +but converting three of thirteen files creates two conventions where there was +one. Adopting `ensure` is a single suite-wide refactor with its own PR, not a +drive-by in a feature branch. + +- **Safe direction**: when adding a spec that mutates global configuration, copy + the neighbouring file's `around` shape. Do not introduce `ensure` alone. +- Origin: PR 17 threads on `spec/lib/daisy_ui/aura_spec.rb:96`, + `spec/lib/daisy_ui/megamenu_spec.rb:92` and `spec/lib/daisy_ui/otp_spec.rb:127` + — three findings, one rule. diff --git a/lode/summary.md b/lode/summary.md new file mode 100644 index 00000000..ea0d665b --- /dev/null +++ b/lode/summary.md @@ -0,0 +1,35 @@ +# daisyui (the Ruby gem) + +`daisyui` is a Phlex component library: 77 Ruby classes under `DaisyUI::` +(`lib/daisy_ui/*.rb`, every one a `< Base`) that render daisyUI 5 markup, so a +view writes `Button(:primary) { "Save" }` instead of `button class="btn +btn-primary"`. The gem depends on `phlex` and `zeitwerk` only; Rails is optional +and reached through a guarded `require` (`lib/daisy_ui.rb` loads +`daisy_ui/engine` only `if defined?(Rails::Engine)`), so the library stays plain +Phlex outside Rails. It ships a stdio MCP server (`exe/daisyui-mcp`) that +answers questions about its own components, two bundled Stimulus controllers +for the popover forms of `Dropdown` and `Tooltip`, and a docs site under `docs/` +— a separate Rails 8.1 app with its own bundle, lint and specs, deployed by +Kamal on every GitHub Release. + +Three invariants govern changes: + +1. **A modifier's CSS class must appear as a literal string in a Ruby file + Tailwind scans.** `register_modifiers` maps a symbol to a class name, and the + responsive variants (`sm:`, `@sm:`, `md:` …) exist only because they are + written out in comments above each entry; `docs/bin/build-css` resolves the + gem's install path with `bundle show` and writes `@source "<path>/**/*.rb"` + so Tailwind reads those comments. Drop a comment and the responsive class + silently stops being generated. +2. **Components are pure render — no state, no I/O.** `Base#initialize` sorts + arguments into modifiers, options and attributes and `view_template` emits + tags; nothing reads the filesystem, the network or a database. The engine + only appends asset paths. +3. **The library works without Rails, and JavaScript is opt-in everywhere but + one place.** The engine and the importmap pin file are additive; every + component renders server-side. `Dropdown` defaults to `stimulus: false`, so + `Dropdown(:popover)` is zero-JS (native Popover API + CSS anchor + positioning). The one exception is `Tooltip(:popover)`, which defaults to + `stimulus: true` and *raises* `ArgumentError` on `stimulus: false` + (`Tooltip#validate_stimulus!`, `lib/daisy_ui/tooltip.rb:82-87`) — its + collision flipping has no CSS-only form. diff --git a/lode/terminology.md b/lode/terminology.md new file mode 100644 index 00000000..798486d8 --- /dev/null +++ b/lode/terminology.md @@ -0,0 +1,48 @@ +# Terminology + +The words this repository uses, and what each one means in the code. + +- **component** — a `DaisyUI::` class inheriting `DaisyUI::Base`, one per file + under `lib/daisy_ui/`. 77 of the 83 files there declare `class X < Base`; the + other six are `base.rb`, `configurable.rb`, `engine.rb`, `mcp_server.rb`, + `updated_at.rb` and `version.rb`. +- **modifier** — a symbol a caller passes positionally (`Button(:primary)`) or as + a `true` keyword (`Button(primary: true)`) that `modifier_map` turns into one + or more CSS classes. Registered with `register_modifiers`. +- **boolean modifier** — the keyword form. `Base#extract_boolean_modifiers` + moves `key: true` out of the options hash into the modifier list; `key: false` + is also removed but adds nothing. Only keys already present in `modifier_map` + and only the values `true`/`false` are treated this way — `primary: "x"` + stays in the options and lands on the element as an HTML attribute. +- **responsive comment** — the commented-out class strings above each + `register_modifiers` entry (`# "sm:btn-primary"`, `# "@sm:btn-primary"`, …). + They are the only place the breakpoint-prefixed class names exist as literal + text, and Tailwind's scanner reads them out of the gem's `.rb` files. +- **responsive option** — `responsive: { sm: :lg, md: [:primary, true] }`. A + `true` value applies the *base* class at that breakpoint; a symbol applies + that modifier's classes, each token prefixed separately. +- **component class** — the base CSS class for a component. Of the 77 + `< Base` classes, 58 set `self.component_class` to a Symbol, 13 to a String, + 4 to `nil` (`collapsible_sub_menu.rb`, `label.rb`, `menu_item.rb`, + `table_row.rb` — they carry no class of their own), and 2 set nothing at all + (`sub_menu.rb`, `tab.rb`), so `Base.component_class` derives it from the class + name (`MockupBrowser` → `mockup-browser`). +- **prefix** — `DaisyUI.configuration.prefix`, prepended to every emitted class + by `Base#apply_prefix` for apps that build daisyUI with a CSS prefix. +- **sub-component** — an instance method on a component that renders one of its + parts (`Card#body`, `Stat#title`, `Drawer#side`), building its classes through + `Base#component_classes` so the prefix and the caller's `class:` both apply. +- **Kit short form** — `DaisyUI extend Phlex::Kit`, so a view that + `include DaisyUI` calls `Button(...)` instead of `render DaisyUI::Button.new(...)`. +- **popover mode** — the `:popover` modifier on `Dropdown` and `Tooltip`: + the panel is a real `popover` element in the browser top layer, escaping + `overflow` clipping. +- **example** — a `Views::Components::Examples::<Component>::<Name>` class in + the docs app (276 of them across 70 directories). Its `#example` method both + renders the live component and supplies the Source tab's text via + `method_source`. +- **registry** — a plain-Ruby array of hashes that publishes docs pages: + `Doc::REGISTRY` (3 guides) and `ComponentDoc::REGISTRY` (70 components, in 7 + categories). No database. +- **Markdown twin** — the `.md` rendering of a docs page that docs-kit serves + from the same Phlex class, and links from `/llms.txt`. diff --git a/lode/testing-and-ci/summary.md b/lode/testing-and-ci/summary.md new file mode 100644 index 00000000..7cc90c0e --- /dev/null +++ b/lode/testing-and-ci/summary.md @@ -0,0 +1,85 @@ +# Testing and CI + +## The gem's suite + +RSpec, 71 spec files, all under `spec/lib/daisy_ui/` — one per component plus +`base_spec.rb`. There is no spec for `mcp_server.rb`, `engine.rb` or +`configurable.rb`; `Configurable` is exercised indirectly by the specs that +configure a prefix or add modifiers. + +`spec/spec_helper.rb` requires `daisyui` and `super_diff/rspec`, loads +`spec/support/**`, and defines `ComponentHelpers#render(component, &)` as +`component.call(&)` — Phlex renders straight to a string, no Rails, no view +context. + +Two support files shape every example: + +- `spec/support/html_helpers.rb` — `html(string)` normalises an expected HTML + heredoc (collapses inter-tag whitespace, folds attribute values, one space + between attributes, strips). Expectations are written as readable multi-line + HTML and compared with `eq`, so a spec asserts the **whole** rendered string, + attribute order included — not a substring. +- `spec/support/phlex_helpers.rb` — `phlex_context(&)` renders inside a plain + `<div>` wrapper for sub-components that need a parent, and `config.include DaisyUI` + makes the Kit short form (`Button(...)`) available in examples. + +`.rspec` is `--format documentation --color --require spec_helper`. + +### The `around` convention, and its hazard + +13 of the 71 spec files set `DaisyUI.configuration.prefix`, and 4 of those 13 +(`aura`, `card`, `megamenu`, `table`) also call `config.modifiers.add`. +`DaisyUI.configure` memoises one `Configuration` per process, so each of those +13 wraps the change in an `around` hook that restores the previous value +**after** a bare `example.run`. No file in `spec/` uses `ensure` — a +`grep` for it in `spec/` returns nothing. The consequence is real: an example +that raises (not merely fails an expectation) skips the restore and leaks a +prefix into every later example. The convention is uniform on purpose; changing +it is a suite-wide refactor, not a per-PR edit (see `../review/testing-and-ci.md`). + +## The docs app's suite + +`docs/spec/` holds one request spec (`spec/requests/pages_spec.rb`, 8 examples +covering the landing page, an authored guide, three 404 paths, a component page, +a `POST /reactive/actions` round-trip and `/up`) and one system spec +(`spec/system/tooltip_popover_spec.rb`) driven by Capybara + the Playwright +driver against headless Chromium. + +`spec/system/support/precompile_assets.rb` runs `bun run build:css` in a +`before(:suite)` hook — but only outside CI (`ENV["CI"]`/`ENV["GITHUB"]`), only +when a system/controller/request example is selected, and only when no +`tailwindcss` process is already running in the directory. It clobbers assets +again `after(:suite)`. Locally that means a spec run mutates +`app/assets/builds/`. + +The system spec reads geometry out of the page with `page.evaluate_script`. The +controller positions inside a `requestAnimationFrame`, so `:popover-open` alone +is not enough to read from — the spec waits on +`have_css("#left_edge_tooltip:popover-open", visible: true)` and asserts +`visibility == "visible"` before trusting any coordinate. + +## CI (`.github/workflows/main.yml`) + +Four jobs, all in parallel, on `push` to `main` and on every `pull_request`: + +| Job | Name in checks | Runs | +|---|---|---| +| `lint` | Lint | `bundle exec rubocop lib spec` (Ruby 4.0) | +| `gem-test` | Gem Tests (Ruby 3.2 / 3.3 / 3.4 / 4.0) | `bundle exec rspec`, `fail-fast: false` | +| `docs-lint` | Docs Lint | in `docs/`: `bin/rubocop`, `bun run lint:js` (Biome), `bun run lint:css` (Stylelint) | +| `docs-test` | Docs Tests | in `docs/`: Playwright chromium + libvips, `bun run build:css`, `db:create db:migrate`, `assets:precompile`, then `bundle exec rspec` with `HEADLESS=true`; 15-minute timeout; uploads `docs/tmp/capybara/screenshots/*.png` on failure | + +Ruby is pinned to `4.0` for the three non-matrix jobs and bun to `1.3.2`. +Nothing filters by path: a gem-only PR still runs both docs jobs. + +The CI lint command is `rubocop lib spec` — narrower than the local +`bundle exec rubocop`, which also covers the `Rakefile` and the gemspec, and +whose `AllCops.Exclude` drops `docs/**/*` entirely. The docs app has a separate +`.rubocop.yml` that inherits docs-kit's config, targets Ruby 3.4, and disagrees +with the root file on trailing commas: the gem wants +`EnforcedStyleForMultiline: no_comma`, the docs app wants `comma`. + +## Related + +- `../review/testing-and-ci.md` — accepted review findings about the suites +- `../packaging-and-release/summary.md` — the release and deploy workflows diff --git a/lode/workflow.md b/lode/workflow.md new file mode 100644 index 00000000..f42ee0de --- /dev/null +++ b/lode/workflow.md @@ -0,0 +1,197 @@ +# Workflow profile + +Everything the shared workflow skills (`/lode:lfg`, `/lode:review-pr`, +`/lode:finish-prs`, `/lode:debug-flaky`, `/lode:tdd`, `/lode:plan`) need to know +about this repository that is not already in `CLAUDE.md`, `.claude/rules/` or the +rest of `lode/`. + +## Commands + +Two bundles: the gem at the repo root, the docs app in `docs/`. A `docs/` command +runs from `docs/`. + +| Purpose | Command | Notes | +|---|---|---| +| fast loop (one file) | `bundle exec rspec spec/lib/daisy_ui/<name>_spec.rb` | pure Phlex rendering; no Rails, no DB, no network | +| full suite (gem) | `bundle exec rspec` | 71 files, no services, no network. Safe in two worktrees at once. | +| full suite (docs) | `cd docs && bundle exec rspec` | needs a Playwright chromium and a SQLite DB; **not** safe in two worktrees — `spec/system/support/precompile_assets.rb` rebuilds and then clobbers `docs/app/assets/builds/` around the run | +| lint (gem) | `bundle exec rubocop` | `AllCops.Exclude` drops `docs/**/*`; CI runs the narrower `rubocop lib spec` | +| lint (docs) | `cd docs && bin/rubocop && bun run lint:js && bun run lint:css` | Biome for JS, Stylelint for CSS; docs-kit's RuboCop config, Ruby 3.4 target | +| one CI cell locally | `rbenv/asdf` to the matrix Ruby (3.2, 3.3, 3.4, 4.0), then `bundle install && bundle exec rspec` | the matrix has one dimension, the Ruby version; `.tool-versions` pins 4.0.0 at the root | +| docs build / check | `cd docs && bun run build:css` | `bin/build-css --minify`; resolves the `daisyui` and `docs-kit` gem paths with `bundle show` and aborts if either is missing | +| run the app | `cd docs && bin/dev` | `bin/rails server` only. For live CSS run `bun run watch:css` beside it, or use `Procfile.dev`. | +| gem console | `bin/console` | `DaisyUI::Button.new(:primary).call` renders to a String | +| release | `bundle exec rake release[X.Y.Z]` | runs on `main` and pushes to `origin/main` itself — never from a feature branch | + +The `mcp__daisyui__daisyUI-Snippets` tool in `.claude/settings.local.json` is an +**external** daisyUI class-name lookup. It is not `exe/daisyui-mcp`, this gem's +own server. + +## Branches and PRs + +- Default branch: `main`. +- Work branches: `.claude/rules/git-workflow.md` names `feature/*`, `fix/*`, + `refactor/*`, `ci/*`, `chore/*`, rooted off fresh `origin/main`. The history + also carries `feat(...)`-titled PRs; the branch prefix is the rule, the commit + subject follows conventional commits. +- Commits: conventional, scope in parentheses (`feat(dropdown):`, + `chore(docs):`), body says why. +- PR body sections, in order: Summary, Test plan, Deviations & judgment calls, + Gate. +- Merge policy: squash on `main` when green and approved. Never force-push a + branch that has a PR — merge `main` forward into it instead. +- Attribution: no `Co-Authored-By` and no "Generated with" line. End a commit + body and a PR body with the session line the harness supplies. + +## Layers + +| Layer | Files | Edit rule | +|---|---|---| +| `Base` and the argument pipeline | `lib/daisy_ui/base.rb` | owned here; every component depends on it, so a change needs `base_spec.rb` coverage for Symbol, String and nil `component_class` | +| Components | `lib/daisy_ui/*.rb` (77 `< Base`) | owned here; one class per file, one spec per file | +| Configuration | `lib/daisy_ui/configurable.rb` | owned here; process-global, so anything that touches it needs a restoring `around` hook in specs | +| Rails engine + pins | `lib/daisy_ui/engine.rb`, `config/importmap.rb` | owned here; both paths must stay inside the gemspec's `app/`+`config/` file filter | +| Stimulus controllers | `app/javascript/daisy_ui/controllers/*.js` | owned here; no build step, no JS test suite — the only coverage is `docs/spec/system/` | +| MCP server | `lib/daisy_ui/mcp_server.rb`, `exe/daisyui-mcp` | owned here; no spec exists | +| Docs app | `docs/**` | owned here, separate bundle and lint; run its commands from `docs/` | +| Docs chrome | the `docs-kit` gem (`~> 1.0.8`) | vendored upstream — `DocsUI::Shell`, `DocsKit::Controller`, `/llms.txt`; change it in `zoolutions/docs-kit`, not here | +| `docs/app/assets/builds/`, `docs/app/assets/stylesheets/tailwind.sources.css` | generated | gitignored; regenerate with `bun run build:css`, never edit | +| `Gemfile.lock`, `docs/Gemfile.lock`, `docs/bun.lock` | generated, tracked | regenerate, never hand-edit | + +## Shapes + +Every component change is checked against all of these; a reviewer will name the +one that was skipped. + +- **Modifier, positional**: `Button(:primary)`. +- **Modifier, boolean keyword**: `Button(primary: true)` and `primary: false` — + `Base#extract_boolean_modifiers` handles both, and `false` adds nothing. +- **A registered key with a non-boolean value**: `Button(primary: "x")` stays in + the options and renders as an HTML attribute. That is deliberate. +- **`responsive:` with `true`**: the base class is emitted *only* prefixed + (`base_class` returns `nil`). With a symbol or an array of symbols each token is + prefixed separately. +- **A configured prefix**: `DaisyUI.configuration.prefix` changes every emitted + class, and `apply_prefix` calls `String#split` — so a Symbol `component_class` + (58 of 77) is the shape that breaks. +- **`component_class` shapes**: Symbol (58), String (13), `nil` (4), unset (2, + derived from the class name). +- **Caller-supplied attributes**: `class:` as a String, an Array or absent; + `data:`, `style:` and `aria:` that must be merged onto, never assigned over. +- **`as:`**: a Symbol tag, or another component class (`render_as`). +- **Sub-components rendered inside a parent**: `phlex_context` in + `spec/support/phlex_helpers.rb`. +- **Popover mode**: `Dropdown(:popover)` (zero JS by default) and + `Tooltip(:popover)` (JS required), and for both `stimulus:` as `true`, a String, + a Symbol, and the invalid values `Tooltip` rejects. +- **A browser without CSS anchor positioning**: the dropdown controller's + `@floating-ui/dom` fallback, which the gem does not bundle. +- **Ruby 3.2**: the gemspec floor and the root RuboCop target. CI runs 3.2 → 4.0. +- **Without Rails**: `lib/daisy_ui/engine.rb` loads only + `if defined?(Rails::Engine)`; nothing else may assume Rails. + +## Constraints + +Reviewer suggestions that are wrong in this repository. + +| Suggestion | Why it is wrong here | +|---|---| +| "Wrap the `around` hook's restore in `ensure`." | The bare-`example.run` shape is uniform across all 13 spec files that mutate global config. Converting the files one PR touches creates two conventions. It is a suite-wide refactor with its own PR. See `review/testing-and-ci.md`. | +| "Delete the commented-out class strings above `register_modifiers`." | Those comments are the **only** place the breakpoint-prefixed class names exist as literal text. Tailwind scans the gem's `.rb` files (via `bin/build-css`'s `@source` glob) and generates the responsive variants from them. Deleting one removes that class from the built CSS with no error. | +| "Normalise this component's `component_class` to a String." | It fixes one class and leaves the other 57 Symbols. The fix belongs at the callers of `apply_prefix`, which coerce with `.to_s`. | +| "Publish the GitHub release only after CI passes." | Deliberate. `release.yml` triggers on `release: published` and the jobs are chained, so nothing reaches RubyGems unless `test` and `build` pass. | +| "Run RuboCop over the whole tree from the root." | The root config excludes `docs/**/*` and disagrees with `docs/.rubocop.yml` on trailing commas. Lint each tree from its own directory. | +| "Add a catch-all route to the docs app." | The phlex-reactive engine mounts `POST /reactive/actions` itself; a catch-all shadows it. `config/routes.rb` says so in a comment. | + +## Docs + +- User-facing docs live in two places: `README.md` (installation, compatibility + notes, the Stimulus controllers, the MCP server) and the docs site under + `docs/app/views/`. A component page is published by a row in + `ComponentDoc::REGISTRY` plus at least one example class under + `docs/app/views/components/examples/<slug>/`; a guide by a row in + `Doc::REGISTRY` plus `Views::Docs::Pages::<View>`. +- Changelog: `CHANGELOG.md`. Entries go under `## [Unreleased]`, in the + `### Added` / `### Changed` / `### Fixed` subsections. `rake release` does not + touch this file — release notes come from `gh release create --generate-notes`. +- A change to a component's modifiers always updates that component's examples + under `docs/app/views/components/examples/<slug>/` in the same PR — nothing + fails if you forget. +- Files that pin a version and drift after a release: `docs/Gemfile.lock` pins + `daisyui (X.Y.Z)` through `path: ".."`. `rake release` runs + `cd docs && bundle install` for exactly this reason; if it is ever skipped, the + frozen install in the `docs-lint` / `docs-test` jobs fails on the next PR. + +## CI + +- `.github/workflows/main.yml` — on `push` to `main` and on every + `pull_request`. Four jobs in parallel, **no path filters**: a gem-only PR still + runs both docs jobs. +- `.github/workflows/release.yml` — on `release: published`. `test` (Ruby 3.3, + 3.4, `fail-fast: true`) → `build` (tag-vs-`VERSION` check, package contents + check) → `publish-rubygems` (trusted publishing + Sigstore) → + `upload-release-assets`. +- `.github/workflows/deploy-docs.yml` — on `release: published` and + `workflow_dispatch`. Calls `zoolutions/docs-kit/.github/workflows/deploy.yml@main`. +- Matrix: Ruby `3.2 / 3.3 / 3.4 / 4.0` for `gem-test` only (`fail-fast: false`). + The other three jobs pin Ruby `4.0` and bun `1.3.2`. +- Cells that differ from local: CI's lint is `bundle exec rubocop lib spec`, + narrower than the local `bundle exec rubocop` (which also covers `Rakefile` and + the gemspec). `docs-test` sets `ENV["CI"]`, which turns off the local + `precompile_assets.rb` build/clobber hook. +- Fetch a failure: `gh pr checks <PR>` for the list, then + `gh run view --job <id> --log-failed`. +- "Green" means all four checks: `Lint`, `Gem Tests (Ruby 3.2/3.3/3.4/4.0)`, + `Docs Lint`, `Docs Tests`. +- Known not-this-branch failures: `docs-test`'s Playwright browser install is + cached on `docs/bun.lock` and capped at 5 minutes; a cache miss plus a slow + download fails the job for reasons unrelated to the diff. The whole job has a + 15-minute timeout. +- Shared or rate-limited services the checks hit: none. Nothing in CI calls a + third-party API, so PRs do not have to run one at a time. + +## Flake sources + +- **`requestAnimationFrame` in `daisy_tooltip_controller.js`.** `show()` opens the + popover, then positions it a frame later. `:popover-open` matches before that, + so anything in `docs/spec/system/tooltip_popover_spec.rb` that reads geometry + without first asserting computed `visibility == "visible"` is racy. This is the + one flake this repo has actually had. +- **Playwright and the browser cache** in the `docs-test` job (above). +- **`DaisyUI.configuration` is process-global** and restored after a bare + `example.run`. An example that *raises* skips its restore and leaks a prefix + into every later example in that process — so a burst of failures after one + error is one bug, and the first failure is the real one. +- **`precompile_assets.rb`, locally only.** It shells out to `pgrep`/`lsof` to + guess whether Tailwind is running, then rebuilds and later clobbers + `docs/app/assets/builds/`. Two docs suites in the same directory, or a suite + beside a running `watch:css`, interfere. + +## Conflicts + +| File | Rule | +|---|---| +| `Gemfile.lock`, `docs/Gemfile.lock` | never hand-merge: take the base's, then `bundle install` (in `docs/` for the docs one) | +| `docs/bun.lock` | take the base's, then `cd docs && bun install` | +| `CHANGELOG.md` | union under `## [Unreleased]`, most recent first, without duplicating the `### Added` / `### Changed` subheads | +| `lib/daisy_ui/version.rb` | releases land directly on `main` via `rake release`, so a feature branch never edits it. A conflict means the branch bumped on purpose — keep the branch's bump; if the intent is not obvious from its commits, ask | +| `lib/daisy_ui/updated_at.rb` | machine-written by `rake release` — take the base's; it is regenerated at the next release | +| `register_modifiers` blocks | keep both sides' modifiers **and** the full set of breakpoint variants commented above each one (six variants, or eight in `badge`, `drawer`, `dropdown`, `loading`, `menu`, `modal`, `table`, `tabs`) | +| `Doc::REGISTRY`, `ComponentDoc::REGISTRY` | append-only, base order first | +| `docs/app/views/components/examples/<slug>/` | add a second example file rather than merge two example bodies into one | +| generated assets | nothing generated is tracked, so there is no artifact to regenerate instead of merging — every remaining conflict is source and merges semantically | + +## Verification + +- The manual check a user of this change would do: `bin/console`, then + `puts DaisyUI::<Component>.new(...).call` and read the class attribute; for + anything with JavaScript or a docs page, `cd docs && bun run build:css && + bin/dev` and open `/components/<slug>`, toggling Preview and Source. +- A change to a modifier map is not verified until the class appears in the built + CSS: `bun run build:css` then grep `docs/app/assets/builds/application.css` for + it, including the `sm:`/`@sm:` variants. +- Stress iterations for a flake proof: 50 runs of the affected spec + (`for i in (seq 50); ...; end`), and for the docs system spec that is 50 runs of + the file, not of the whole suite. +- Where evidence goes: `lode/tmp/` (gitignored), unless the PR needs an auditable + trail.