diff --git a/.claude/commands/github-ci-failures.md b/.claude/commands/github-ci-failures.md index 33c8deb..56cabdd 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 8f97281..0000000 --- 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 7a720e6..0000000 --- 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 1498c4e..0000000 --- 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 7065bac..0000000 --- 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 600b8bb..0000000 --- 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 0000000..90b8653 --- /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 9658545..d1fe073 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 2fa6a62..6cd0275 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 0000000..b656ee0 --- /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 0000000..1c3bc7d --- /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 0000000..429c702 --- /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 0000000..2323ad3 --- /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 0000000..8b32f40 --- /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 0000000..d68af33 --- /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 0000000..0798688 --- /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 0000000..22b072a --- /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 0000000..c365a27 --- /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 0000000..1e505af --- /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 0000000..de169f4 --- /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 0000000..50fb20c --- /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 0000000..c98d3df --- /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 0000000..fc8b581 --- /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 0000000..ea0d665 --- /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 0000000..798486d --- /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 0000000..7cc90c0 --- /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 0000000..f42ee0d --- /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.