diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 00000000..8e62901a --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -0,0 +1,122 @@ +--- +name: code-reviewer +description: Thorough review of a change against this repo's rules — intent/requirements, correctness, design, security (incl. dependency audit), and test coverage. Reviews flexibly by scope: uncommitted work, the whole branch against its base (default master), a specific commit, or a named branch — always as one combined diff. Use after implementing a change, or to review a branch before merge. Complements the generic /code-review skill, which does not know these project rules. +tools: Read, Grep, Glob, Bash +--- + +# Code Reviewer + +Review a change as one coherent diff and report findings only — do not edit +files. Read enough of the surrounding code to judge it, read the cited rule and +doc files before ruling, and cite `file:line` for every finding. + +## Scope — what to review + +Orient first: `git status --short` and `git rev-parse --abbrev-ref HEAD`. Then +pick the scope and state which you chose. Always review the **net result as a +single diff, never commit-by-commit** — later commits (fixups, reverts) may +correct earlier ones, and only the final state matters. + +* **Explicitly requested** — honour what the invocation asks for: a base branch + (`git diff ...HEAD`), a single commit (`git show `), or a commit + range (`git diff ..`). +* **Uncommitted work present** (`git status --short` non-empty) → review the + working tree against `HEAD` (`git diff HEAD`) plus any untracked files (list + with `git status`, then read them). +* **Clean tree** → review the whole branch against its base (default `master`): + `git diff master...HEAD` (three dots = only what this branch introduced). + +Work top-down: first establish what the change is supposed to do, then judge +whether it does so correctly, cleanly, safely, and with tests. The repo-specific +rules in section 6 are the easiest to miss — do not skip them. + +## 1. Intent & requirements + +* Establish the intent from the task / PR description and any linked issue (a + GitHub issue number appears in parentheses in the commit/PR name, e.g. + `(#261)`). Check the diff against it. +* Is all the planned functionality present, or is something stubbed, `TODO`, or + silently dropped? +* Flag scope creep — unrelated changes riding along + ([code.md](../rules/code.md)). + +## 2. Correctness & robustness + +* **Error handling.** Failures are handled at the right level, not swallowed; + promises are awaited; rejections are handled. +* **Edge cases.** Empty / `null` / `undefined`, zero / one / many, boundary + values, async ordering, and failure paths are handled. Component edge cases: + missing `children`, controlled vs uncontrolled, ref forwarding. +* **Resource hygiene.** `useEffect` subscriptions / listeners / timers are + cleaned up; no retained references or unbounded state growth. + +## 3. Design & maintainability + +* Clean separation of concerns; the change integrates with the existing patterns + rather than introducing a parallel style — `React.forwardRef` + + `withGlobalProps`, context-aware variants via `useContext`, `classNames()` for + CSS Module classes, `transferProps()` for HTML attribute pass-through, CSS + Modules for styles ([frontend.md](../rules/frontend.md), + [styling.md](../rules/styling.md)). +* Props follow the [API Guidelines](../../src/docs/contribute/api.md) and nesting + follows [Composition](../../src/docs/contribute/composition.md). +* DRY without premature abstraction; sound, reasonably performant code — no heavy + work in render, no needless re-renders. + +## 4. Security + +* Validate / sanitise external data; no unsafe HTML + (`dangerouslySetInnerHTML`) with untrusted content; no secrets committed. +* **Dependencies.** If `package.json` / `package-lock.json` changed: new + dependencies need explicit approval + ([safety-guards.md](../rules/safety-guards.md)); run `npm audit` (in the + devcontainer) and report advisories; sanity-check the lockfile diff for + unexpected or transitive version bumps. + +## 5. Tests + +* New or changed code is covered — co-located Jest tests in `__tests__/` and/or + Playwright component tests (`.spec.tsx` + `.story.tsx`); obsolete tests for + removed code are deleted. Never leave a component or helper untested + ([testing.md](../rules/testing.md)). +* A bug-fix test must fail before the fix and pass after. +* Tests assert behaviour, not implementation details. + +## 6. This repo's rules + +Easy-to-miss invariants beyond the generic checks above: + +* **Lint gate** ([CLAUDE.md](../../CLAUDE.md#commands)): `npm run lint` = + eslint + markdownlint + stylelint. It is not auto-run — remind the author to + run `npm run lint`, `npm run test:jest`, and `npm run test:playwright-ct:all`. +* **Component layout** ([frontend.md](../rules/frontend.md)): every component + folder has the `.jsx` + `index.js` barrel + `*.module.scss` + `_settings`/ + `_theme`/`_tools` SCSS partials + `README.md` + `__tests__/`. PropTypes, not + TypeScript, in source. +* **CSS Modules class naming** ([styling.md](../rules/styling.md)): `root`, + `isRootXxx`, `hasRootXxx`, `isRootInXxx`, `isRootLayoutXxx`. +* **Docs** ([docs.md](../rules/docs.md)): component docs live in the component's + `README.md`; new doc pages are wired into `mkdocs.yml`. +* **Git hygiene** ([git.md](../rules/git.md)): no push or remote change without + approval; commit/PR subjects imperative English with backticked symbols and a + trailing `(#issue)` when one exists; **no `Co-Authored-By`**. PR names land in + the changelog. + +## Output format + +Group findings by severity. For each: +`severity | file:line | rule/doc cited | what is wrong | concrete fix`. + +```text +## Blocking +- [requirements] src/components/Foo/Foo.jsx:42 — acceptance criterion not implemented. +- [tests] src/helpers/bar/bar.js:10 (testing.md) — new helper `bar` has no test. + +## Non-blocking / nits +- [design] src/components/Foo/Foo.jsx:7 (frontend.md) — ref not forwarded to root element. + +## Reminders +- Run `npm run lint`, `npm run test:jest`, and `npm run test:playwright-ct:all` before committing. +``` + +End with a one-line verdict: APPROVE / APPROVE WITH NITS / REQUEST CHANGES. diff --git a/.claude/rules/code.md b/.claude/rules/code.md new file mode 100644 index 00000000..e7a3cdfd --- /dev/null +++ b/.claude/rules/code.md @@ -0,0 +1,22 @@ +# Code + +* Modify files only within defined scope, do not make changes that affect + unrelated parts of the codebase, e.g. do not change unrelated comments, + imports, code or documentation. +* Keep changes minimal and focused. +* Follow the project formatting and style sources: + [.editorconfig](../../.editorconfig) (general), + [.markdownlint.jsonc](../../.markdownlint.jsonc) (Markdown), + [.eslintrc](../../.eslintrc) / [.eslintrc-ts](../../.eslintrc-ts) + (JavaScript/TypeScript), [stylelint.config.js](../../stylelint.config.js) + (SCSS). +* Only fix linting/formatting issues in files you created or modified for the + current task. Do not fix pre-existing issues outside that scope. +* Keep comments simple and use terminology and language matching repository + standards. +* Revert unrelated changes. If they are worth keeping, ask the user whether to + track them separately — either as a GitHub issue (propose a title and + description first, and confirm before creating it) or, for small changes, on a + separate branch without an issue. +* Do not use one character long variable names or shortened names unless it is a + common abbreviation. diff --git a/.claude/rules/docs.md b/.claude/rules/docs.md new file mode 100644 index 00000000..9503b1d1 --- /dev/null +++ b/.claude/rules/docs.md @@ -0,0 +1,35 @@ +--- +paths: + - "src/docs/**" + - "src/components/**/README.md" + - "src/helpers/**/README.md" + - "README.md" +--- + +# Documentation + +## Commands + +* Run `npm run markdownlint` after changes (add `-- --fix` to autofix). +* Run `mkdocs build` and `mkdocs serve` to build and serve the documentation +* To verify a rendered component or the docs previews in a real browser + (navigate, click, screenshot, inspect the running docs site), drive host + Chrome through the `chrome-host` MCP. See + [AI Integration](../../src/docs/contribute/ai-integration.md). + +See [Commands](../../CLAUDE.md#commands). + +## Conventions + +* Documentation is built with [Material for MkDocs][mkdocs-material] and + [Docoff][docoff] (live, runnable component previews). Component docs live in + each component's `README.md`; guides and foundations live under `src/docs/`. +* New pages must be wired into the navigation in [mkdocs.yml](../../mkdocs.yml). +* Use relative links between docs. + +## Reference + +* [General Guidelines › Documenting](../../src/docs/contribute/general-guidelines.md#documenting) + +[mkdocs-material]: https://squidfunk.github.io/mkdocs-material/ +[docoff]: https://github.com/react-ui-org/docoff diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md new file mode 100644 index 00000000..b484423b --- /dev/null +++ b/.claude/rules/frontend.md @@ -0,0 +1,72 @@ +--- +paths: + - "src/**/*.js" + - "src/**/*.jsx" +--- + +# Frontend (React component library) + +## Commands + +* If dependencies change, run `npm ci`. +* Run `npm run eslint` after changes (add `-- --fix` to autofix). +* To verify a rendered component or the docs previews in a real browser + (navigate, click, screenshot, inspect the running docs site), drive host + Chrome through the `chrome-host` MCP. See + [AI Integration](../../src/docs/contribute/ai-integration.md). + +## Stack + +React UI is a themeable React component library, distributed as a UMD bundle +(with separate CSS) and as ESM (consumers run their own SASS pipeline). +Components are plain JavaScript / JSX (Babel, no TypeScript in the source) for +React 18, validated with `prop-types`. TypeScript appears only in tests and type +checks. + +## Component structure + +Each component lives in `src/components//` and follows this +layout: + +```text +Button/ + Button.jsx # Component implementation (React.forwardRef + withGlobalProps) + Button.module.scss # CSS Modules styles + index.js # Re-exports default (withGlobalProps-wrapped) as named export + _settings.scss # Component-level SCSS variables + _theme.scss # CSS custom properties (design tokens) + _tools.scss # SCSS mixins + README.md # Docoff/MkDocs documentation with live previews + helpers/ # Component-specific helper functions + __tests__/ + Button.spec.tsx # Playwright visual + functional tests + Button.story.tsx # Story components used as test fixtures + _propTests/ # Reusable prop test generators (arrays of test cases) +``` + +## Implementation pattern + +Components are `.jsx` files (not `.tsx`) using PropTypes. They: + +1. Use `React.forwardRef` to forward refs to the root HTML element. +2. Are wrapped with `withGlobalProps(Component, 'ComponentName')` for global prop + injection; the wrapped version is the default export. +3. Use `useContext` to detect layout/group contexts (`FormLayoutContext`, + `ButtonGroupContext`, `InputGroupContext`) and apply CSS class variants + accordingly. +4. Use the `classNames()` helper to conditionally combine CSS Module class names. +5. Use `transferProps()` to pass through non-React HTML attributes to the root + element. + +Honour the [API Guidelines](../../src/docs/contribute/api.md) and +[Composition](../../src/docs/contribute/composition.md) when shaping props and +nesting. + +## Styling + +Component styles use SCSS with CSS Modules — see the [styling rule](styling.md). + +## Tests + +Components are covered by Jest unit tests and table-driven Playwright component +tests — see the [testing rule](testing.md). diff --git a/.claude/rules/git.md b/.claude/rules/git.md new file mode 100644 index 00000000..f44dfdd7 --- /dev/null +++ b/.claude/rules/git.md @@ -0,0 +1,13 @@ +# Git + +## Skills + +Use `/commit` to commit changes. + +## Conventions + +Full rules are in [General Guidelines › Git Workflow](../../src/docs/contribute/general-guidelines.md#git-workflow). + +## Reference + +* [General Guidelines › Git Workflow](../../src/docs/contribute/general-guidelines.md#git-workflow) diff --git a/.claude/rules/safety-guards.md b/.claude/rules/safety-guards.md new file mode 100644 index 00000000..0152207d --- /dev/null +++ b/.claude/rules/safety-guards.md @@ -0,0 +1,9 @@ +# Safety guards + +* Never modify files listed in [.gitignore](../../.gitignore) unless approved + in a skill +* Never modify any files outside the project root +* Never run shell commands that can alter system state or access sensitive data + when run on the host system +* Never modify the git remote (e.g. `git push`) without explicit human approval +* Never introduce new dependencies without explicit human approval diff --git a/.claude/rules/styling.md b/.claude/rules/styling.md new file mode 100644 index 00000000..70e7ef31 --- /dev/null +++ b/.claude/rules/styling.md @@ -0,0 +1,32 @@ +--- +paths: + - "src/**/*.scss" + - "src/**/*.css" +--- + +# Styling + +## Commands + +Run `npm run stylelint` after changes (add `-- --fix` to autofix). + +## Styling + +Styles use SCSS with CSS Modules (`.module.scss`) with camelCase class names. A +component's styles live next to it as `Foo.module.scss` and are imported as a +module. + +Class naming convention: + +* `root` for the root element. +* Modifiers follow `isRootXxx` (state), `hasRootXxx` (has feature), + `isRootInXxx` (context), `isRootLayoutXxx` (layout variant). + +`src/styles/` contains the global theming system: settings (variables), tools +(mixins), and a large set of CSS custom properties for theming. Component SCSS +files `@use` their own `_settings`, `_theme`, `_tools` partials plus the shared +styles from `src/styles/`. Theme tokens are exposed as CSS custom properties. + +## Reference + +* [CSS Guidelines](../../src/docs/contribute/css.md) diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 00000000..85d72cf9 --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,59 @@ +--- +paths: + - "**/*.spec.tsx" + - "**/*.story.tsx" + - "**/__tests__/**" + - "tests/**" +--- + +# Testing + +## Commands + +* Run all Jest unit tests with `npm run test:jest` (TS + JS). For a single file: + `npm run test:jest:ts -- ` or `npm run test:jest:js -- `. +* Run all Playwright component tests with `npm run test:playwright-ct:all`; for + one component, `npm run test:playwright-ct:all -- -- src/components/Button`. +* Update Playwright snapshots with `npm run test:playwright-ct:all-with-update`. +* Serve the report with `npm run test:playwright-ct:show-report`. + +## Testing + +Create/update tests for added or changed components and helpers, and remove +obsolete tests when functionality is removed. Never leave a component or helper +without tests. When fixing a bug, add a test that fails before the fix and +passes after it. + +### Organization + +Jest unit/component tests are co-located in a component's `__tests__/` folder. + +### Playwright component tests + +`.spec.tsx` specs use a table-driven pattern: + +* Import arrays of test cases from `_propTests/` directories and from shared + `tests/playwright/propTests/`. +* Each test case is `{ name, props, onBeforeTest?, onBeforeSnapshot? }`; custom + field tests add `customFieldLayoutProps`, `customFieldProps`, etc. +* `mixPropTests([...arrays])` generates the cartesian product of multiple prop + arrays. +* `propTests` from `tests/playwright/` provides standard reusable test sets + (e.g. `layoutPropTest`, `sizePropTest`, `disabledPropTest`). +* Snapshot images are stored alongside the spec file in + `.spec.tsx-snapshots/`. + +**Story components** (`.story.tsx`) wrap the real component in a minimal fixture +(sometimes inside a context provider) and are imported only by `.spec.tsx` +files. Naming convention: `ForTest`, `ForRefTest`, +`ForFormLayoutTests` — the FormLayout story component is always +last. + +**Test describe structure:** `test.describe('ComponentName')` → +`test.describe('base')` (if present) → `visual` / `non-visual` / +`functionality`; the `formLayout` describe is always the last block at the same +level as `base`. + +## Reference + +* [Testing Guidelines](../../src/docs/contribute/testing-guidelines.md) diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..606fa230 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "allow": [ + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git rev-parse:*)", + "Bash(git show:*)", + "Bash(git status:*)", + "Bash(npm audit)", + "Bash(npm audit signatures)", + "Bash(npm ci)", + "Bash(npm run build:*)", + "Bash(npm run lint)", + "Bash(npm run eslint:*)", + "Bash(npm run stylelint:*)", + "Bash(npm run markdownlint:*)", + "Bash(npm start)", + "Bash(npm run start:*)", + "Bash(npm test)", + "Bash(npm run test:jest:*)", + "Bash(npm run test:playwright-ct:*)" + ] + }, + "enabledMcpjsonServers": [ + "chrome-host" + ], + "enabledPlugins": { + "github@claude-plugins-official": true, + "context7@claude-plugins-official": true + } +} diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md new file mode 100644 index 00000000..7f569dd8 --- /dev/null +++ b/.claude/skills/commit/SKILL.md @@ -0,0 +1,54 @@ +--- +description: Draft a commit message and commit staged changes, following the project git conventions +allowed-tools: Bash(git status), Bash(git diff:*), Bash(git log:*), Bash(git blame:*), Bash(git merge-base:*), Bash(git rev-parse:*), Bash(git add:*), Bash(git commit:*) +--- + +# Commit + +Draft a commit message and commit the staged changes, following the project's +git conventions. + +## When to use + +* You invoke this to commit staged work. User-only — never auto-invoked. + +## Context + +Status: + +!`git status` + +Staged diff: + +!`git diff --staged` + +Branch: + +!`git rev-parse --abbrev-ref HEAD` + +Branch commits since `master`: + +!`git log master..HEAD --oneline 2>/dev/null | head -20` + +## Steps + +Each commit must be atomic and never leave the app broken. + +If nothing is staged, stop and tell the user. + +Before committing, check the following (see [Commands](../../../CLAUDE.md#commands)): + +* `npm run build` passes +* `mkdocs build` passes +* `npm run lint` passes +* `npm run test:jest` passes +* `npm run test:playwright-ct:all` passes + +Write a commit message for the staged changes following the project's git +conventions ([General Guidelines › Git Workflow](../../../src/docs/contribute/general-guidelines.md#git-workflow)): + +Do not append `Co-Authored-By`. + +## Reference + +* [General Guidelines › Git Workflow](../../../src/docs/contribute/general-guidelines.md#git-workflow) diff --git a/.env.dist b/.env.dist index 683f6be5..28c69eec 100644 --- a/.env.dist +++ b/.env.dist @@ -49,3 +49,13 @@ PW_WORKERS=1 # Port used by Playwright Component Testing to serve the test files PW_CT_PORT=3100 + +#################################### +# Chrome-host bridge configuration # +#################################### + +# Host Chrome debug port +# CHROME_DEBUG_PORT=9333 + +# Host Chrome binary path (skip for auto-detection) +# CHROME_BIN= diff --git a/.gitignore b/.gitignore index e2ab471d..b5096964 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,9 @@ .env statistics.html !.gitkeep + +/.claude/settings.local.json +/CLAUDE.local.md + +__pycache__/ +*.pyc diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..afd9d01c --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "chrome-host": { + "command": "sh", + "args": ["scripts/mcps/chrome-host/mcp-entry.sh"] + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 65b606a1..e7bf2120 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,89 +1,82 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository -## Commands - -All commands below are meant to be run directly inside the Docker container `devcontainer`. -If you open the project in a Dev Container, you can run these commands without manually -starting Docker Compose on the host. +## Environment +This project is developed inside a Docker container called `devcontainer`. +The commands below assume you are running inside the `devcontainer`. -```bash -npm run lint # All linters (ESLint + Stylelint + Markdownlint) -npm run eslint # JS + TS linting -npm run stylelint # SCSS linting -npm run test:jest # All Jest unit tests -npm run test:jest:ts -- # Single TypeScript test file -npm run test:jest:js -- # Single JavaScript test file -npm run test:playwright-ct:all # All component tests -npm run test:playwright-ct:all-with-update # Update snapshots -npm run test:playwright-ct:all -- -- src/components/Button # Tests for one component -npm run test:playwright-ct:show-report # Serve test report at localhost:9323 -``` +From the host, run them inside it with +`docker compose exec -T devcontainer ` (start it first with +`docker compose up -d`). The other service containers (`node`, `playwright`, +`docs`) are implementation details — do not call them directly. -Playwright snapshots must always be generated inside the Docker container — snapshots differ between operating systems. +If file `/.dockerenv` is present, you are in a Docker container. -## Architecture +For details, see the +[Contributing Guide](src/docs/contribute/general-guidelines.md#development-environment). -React UI is a themeable React component library. It is distributed in two ways: - -- **UMD bundle** with separate CSS — ready to use out of the box -- **ESM** — users are responsible for their own SASS pipeline to compile the styles +## Commands -### Component structure +Run these inside the `devcontainer` (from the host, prefix with +`docker compose exec -T devcontainer`). For details, see the +[Contributing Guide](src/docs/contribute/general-guidelines.md). -Each component lives in `src/components//` and follows this layout: +| Task | Command | +|-----------------------------------|---------------------------------------------------------------------| +| Install JS | `npm ci` | +| Build library | `npm run build` | +| Run static checks | `npm run lint` | +| Run unit tests | `npm run test:jest` | +| Run a single unit test file | `npm run test:jest:ts -- ` / `npm run test:jest:js -- ` | +| Run component tests | `npm run test:playwright-ct:all` | +| Component tests for one component | `npm run test:playwright-ct:all -- -- src/components/Button` | +| Update component snapshots | `npm run test:playwright-ct:all-with-update` | +| Start build watcher | `npm start` | +| Build documentation | `mkdocs build` | +| Serve documentation | `mkdocs serve` | -``` -Button/ - Button.jsx # Component implementation (React.forwardRef + withGlobalProps) - Button.module.scss # CSS Modules styles - index.js # Re-exports default (withGlobalProps-wrapped) as named export - _settings.scss # Component-level SCSS variables - _theme.scss # CSS custom properties (design tokens) - _tools.scss # SCSS mixins - README.md # Docoff/MkDocs documentation with live previews - helpers/ # Component-specific helper functions - __tests__/ - Button.spec.tsx # Playwright visual + functional tests - Button.story.tsx # Story components used as test fixtures - _propTests/ # Reusable prop test generators (arrays of test cases) -``` +Notes: -### Component implementation pattern +* Commands like `npm`, `playwright` run in specific Docker containers. + Normally, when you run those commands, they are executed within dedicated + containers. If you need to communicate directly with specific Docker container, + use `sudo docker compose ...`. It can be useful when stopping server that is + running within `node` container even though it is started from `devcontainer`. +* See the `scripts` section in `package.json` for the full list of commands. -Components are `.jsx` files (not `.tsx`) using PropTypes. They: -1. Use `React.forwardRef` to forward refs to the root HTML element -2. Are wrapped with `withGlobalProps(Component, 'ComponentName')` for global prop injection; the wrapped version is the default export -3. Use `useContext` to detect layout/group contexts (`FormLayoutContext`, `ButtonGroupContext`, `InputGroupContext`) and apply CSS class variants accordingly -4. Use `classNames()` helper to conditionally combine CSS Module class names -5. Use `transferProps()` to pass through non-React HTML attributes to the root element +## Topics (Claude Rules) -### Styling +Project rules live in [.claude/rules/](.claude/rules/). There are two kinds. -- CSS Modules (`.module.scss`) with camelCase class names -- Class naming convention: `root` for the root element; modifiers follow `isRootXxx` (state), `hasRootXxx` (has feature), `isRootInXxx` (context), `isRootLayoutXxx` (layout variant) -- `src/styles/` contains the global theming system: settings (variables), tools (mixins), and a large set of CSS custom properties for theming -- Component SCSS files `@use` their own `settings`, `theme`, `tools` partials plus shared styles from `src/styles/` +**Always-on** (no frontmatter) — apply to every change: -### Testing patterns +* [code.md](.claude/rules/code.md) — scope discipline, minimal changes. +* [git.md](.claude/rules/git.md) — branches, commits, PRs. +* [safety-guards.md](.claude/rules/safety-guards.md) — hard guard rails. -**Playwright component tests** (`.spec.tsx`) use a table-driven pattern: -- Import arrays of test cases from `_propTests/` directories and from shared `tests/playwright/propTests/` -- Each test case is `{ name, props, onBeforeTest?, onBeforeSnapshot? }`; custom field tests add `customFieldLayoutProps`, `customFieldProps`, etc. -- `mixPropTests([...arrays])` generates the cartesian product of multiple prop arrays -- `propTests` from `tests/playwright/` provides standard reusable test sets (e.g. `layoutPropTest`, `sizePropTest`, `disabledPropTest`) -- Snapshot images are stored alongside the spec file in `.spec.tsx-snapshots/` +**Path-scoped** — each carries a `paths:` glob declaring the files it governs; +consult it when touching those files: -**Story components** (`.story.tsx`) wrap the real component in a minimal fixture (sometimes inside a context provider) and are imported only by `.spec.tsx` files. Naming convention: `ForTest`, `ForRefTest`, `ForFormLayoutTests` — FormLayout story component is always last. +* [frontend.md](.claude/rules/frontend.md) — `src/**/*.js`, `src/**/*.jsx`: + stack, component structure, implementation pattern. +* [styling.md](.claude/rules/styling.md) — `src/**/*.scss`, `src/**/*.css`: + CSS Modules, class naming, theming. +* [testing.md](.claude/rules/testing.md) — `*.spec.tsx`, `*.story.tsx`, + `__tests__/**`, `tests/**`: Jest and table-driven Playwright tests. +* [docs.md](.claude/rules/docs.md) — `src/docs/**`, component `README.md`. -**Test describe structure:** `test.describe('ComponentName')` → `test.describe('base')` (if present) → `visual` / `non-visual` / `functionality`; `formLayout` describe is always the last block at the same level as `base`. +## Agents (Claude Agents) -### Git workflow +Specialized agents live in [.claude/agents/](.claude/agents/). -Branch naming: `bc/*`, `feature/*`, `bugfix/*`, `refactoring/*`, `docs/*`, `maintenance/*` +* `code-reviewer` — review a change against this repo's rules (uncommitted work, + or a whole branch vs its base). -Commit messages: imperative English, component names in backticks (e.g. `` Add `FormLayout` context awareness to `Button` ``). No `Co-Authored-By` lines. +## AI Integration -PR names follow the same rules as commit messages and are used directly in the changelog. Only PRs into `master` appear in the changelog. +MCP-capable assistants in the `devcontainer` can drive host Chrome to verify the +docs site and rendered components. See +[AI Integration](src/docs/contribute/ai-integration.md) and +[scripts/mcps/chrome-host/README.md](scripts/mcps/chrome-host/README.md). diff --git a/mkdocs.yml b/mkdocs.yml index 0904be69..fa640c9e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -148,6 +148,7 @@ nav: - Contribute: - General Guidelines: 'docs/contribute/general-guidelines.md' - Testing Guidelines: 'docs/contribute/testing-guidelines.md' + - AI Integration: 'docs/contribute/ai-integration.md' - API Guidelines: 'docs/contribute/api.md' - Composition: 'docs/contribute/composition.md' - CSS Guidelines: 'docs/contribute/css.md' diff --git a/opencode.json b/opencode.json new file mode 100644 index 00000000..e9feb99b --- /dev/null +++ b/opencode.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "chrome-host": { + "type": "local", + "command": ["sh", "scripts/mcps/chrome-host/mcp-entry.sh"], + "enabled": true + } + } +} diff --git a/package.json b/package.json index 8ff2eb70..636c65ee 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "precopy": "rm -rf dist && mkdir dist", "prepublishOnly": "npm run build", "start": "webpack --watch --mode=development", + "start:chrome": "sh scripts/mcps/chrome-host/start-host-chrome.sh", "stylelint": "stylelint \"src/**/*.{css,scss}\" \"!src/docs/_assets/generated/**\" --config stylelint.config.js", "test": "npm run test:jest", "test:jest": "npm run test:jest:ts && npm run test:jest:js", diff --git a/scripts/mcps/chrome-host/README.md b/scripts/mcps/chrome-host/README.md new file mode 100644 index 00000000..56129520 --- /dev/null +++ b/scripts/mcps/chrome-host/README.md @@ -0,0 +1,118 @@ +# Chrome-host bridge + +Lets an MCP-capable AI assistant in the `devcontainer` drive **Chrome on the +host** via [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) +over the Chrome DevTools Protocol (CDP). + +## Principles + +- The container reaches the host only via `host.docker.internal`. +- Chrome's DevTools endpoint rejects any `Host` header that is not an IP literal + (or the name `localhost`) — so the container cannot connect to it directly. +- Fix: a loopback proxy **inside the container** (`127.0.0.1:9334`); the `Host` + Chrome finally sees is `127.0.0.1`, an IP, which it accepts. +- Chrome runs in a dedicated, persistent profile (separate from your everyday + one) and is started **by hand** on the host — never from a Claude action, + which can reap it with its process group. + +## Architecture + +```text +Claude (devcontainer) + │ .mcp.json → mcp-entry.sh → docker compose exec node mcp-launch.sh + ▼ +node container + ├─ cdp_proxy.py 127.0.0.1:9334 ──► host.docker.internal:9333 + └─ npx chrome-devtools-mcp --browser-url http://127.0.0.1:9334 + │ + ▼ +host: Chrome --remote-debugging-port=9333 (throwaway profile) +``` + +| Script | Runs on | Purpose | +| --- | --- | --- | +| `start-host-chrome.sh` | host | Launch the debug Chrome on `:9333`. Idempotent; auto-detects Chrome/Chromium/Edge. On native Docker, also runs an `ncat` forwarder to expose `:9333` on the bridge gateway. | +| `cdp_proxy.py` | node container | Loopback CDP proxy `:9334` → host `:9333`. Binds-or-exits. | +| `mcp-launch.sh` | node container | Ensure the proxy is up, then `exec` `chrome-devtools-mcp`. | +| `mcp-entry.sh` | host / devcontainer | The `.mcp.json` command; routes into the node container (adds `sudo` inside the container). | +| `mcp-setup.sh` | host (setup) | Add the `host.docker.internal:host-gateway` mapping on native Docker Engine. Idempotent; no-op on Docker Desktop. | + +## Registration + +Register `mcp-entry.sh` as an MCP server named `chrome-host`. Any MCP client +works — point its config at the script. + +**Claude / GitHub Copilot CLI** — `.mcp.json` (repo root): + +```json +{ + "mcpServers": { + "chrome-host": { + "command": "sh", + "args": ["scripts/mcps/chrome-host/mcp-entry.sh"] + } + } +} +``` + +**OpenCode** — `opencode.json` (repo root): + +```json +{ + "mcp": { + "chrome-host": { + "type": "local", + "command": ["sh", "scripts/mcps/chrome-host/mcp-entry.sh"], + "enabled": true + } + } +} +``` + +Requirements: a `docker-compose.yml` at the repo root, the repo mounted at +`/workspace` in the container, and a Compose service named `node` that runs +`node`/`npx`. + +## Usage + +1. Start host Chrome (from a host terminal, **not** from inside an AI action): + + ```sh + ./scripts/mcps/chrome-host/start-host-chrome.sh + ``` + +2. On first run, approve the `chrome-host` server in your MCP client. + +The proxy and MCP server are bootstrapped by `mcp-launch.sh`; you only start +Chrome by hand. + +## Platform support + +Decided by **Docker Desktop vs native Docker Engine**, not the OS. + +| Host setup | `host.docker.internal` reaches host loopback? | Extra steps | +| --- | --- | --- | +| Docker Desktop (macOS/Linux) | Yes (auto) | None. | +| Native Docker Engine (Linux) | No (bridge gateway only) | `setup.sh` (via `mcp-setup.sh`) adds the host-gateway mapping; the launcher runs a host-side `ncat` forwarder (install `nmap-ncat`/`nmap`) to expose Chrome's loopback CDP port on the bridge gateway. **Firewall the port**: this exposes DevTools beyond loopback. | + +## Configuration + +- Ports: host CDP `9333` (configurable), in-container proxy `9334` (fixed). +- Overrides (host launcher), set in the repo `.env`: `CHROME_DEBUG_PORT` and + `CHROME_BIN`. `CHROME_DEBUG_BIND` is an advanced escape hatch — the bind is + auto-detected, so it is not listed in `.env.dist`; set it only when detection + fails (e.g. a non-default daemon `host-gateway-ip`). **Security:** a + non-loopback bind exposes the unauthenticated CDP port; firewall it. + The proxy's listen port and target host are fixed constants; only its upstream + port is configurable, and it follows the same `CHROME_DEBUG_PORT`. +- Set these in the repo `.env`; Compose passes `CHROME_DEBUG_PORT` into the + container, so a single knob drives both sides. Recreate the node container + after editing. See `.env.dist`. +- Profile: `~/.chrome-debug-profile-` (per project, fixed). + +## Troubleshooting + +- Status: `claude mcp get chrome-host` (`✔ Connected` = healthy chain). +- Chrome died? Re-run `start-host-chrome.sh` (it persists across sessions). +- `claude mcp list` shows *Pending approval* until you approve project servers. +- Disable: remove the `chrome-host` entry from `.mcp.json`. diff --git a/scripts/mcps/chrome-host/cdp_proxy.py b/scripts/mcps/chrome-host/cdp_proxy.py new file mode 100755 index 00000000..5eeefd61 --- /dev/null +++ b/scripts/mcps/chrome-host/cdp_proxy.py @@ -0,0 +1,90 @@ +import os +import socket +import threading + +# Loopback CDP proxy. Runs INSIDE the node container. +# +# A container reaches host Chrome only via `host.docker.internal` (forwarded to +# the host loopback on Docker Desktop; the bridge gateway on native Linux Docker, +# where a host-side ncat forwarder exposes Chrome's loopback CDP port). But +# Chrome's DevTools endpoint rejects any Host header that isn't an IP literal (or +# the name "localhost") -- anti DNS-rebinding -- so connecting by that *name* +# fails, and the IPv6 it may also resolve to is often not forwarded. +# +# So this proxy listens on 127.0.0.1:LISTEN_PORT and forwards raw bytes to host +# Chrome. Clients reach it as "127.0.0.1:LISTEN_PORT", so the Host header Chrome +# sees is an IP (accepted), and the webSocketDebuggerUrl it echoes back stays on +# 127.0.0.1 too, so the follow-up WebSocket also works. +# +# The proxy's own endpoint is fixed: it always listens on the container loopback +# and forwards to host Chrome over host.docker.internal. Only the upstream Chrome +# port is configurable, via CHROME_DEBUG_PORT (set in the repo's .env, which +# Compose passes into the container), so a single knob keeps both sides in sync. + +LISTEN_HOST = "127.0.0.1" +LISTEN_PORT = 9334 # fixed; must match PROXY_PORT in mcp-launch.sh +TARGET_HOST = "host.docker.internal" # fixed; how the container reaches the host +TARGET_PORT = int(os.environ.get("CHROME_DEBUG_PORT") or "9333") # host Chrome CDP port + + +def resolve_target(): + # Force IPv4: host.docker.internal may also resolve to an IPv6 address that + # Docker does not forward to the host. + try: + infos = socket.getaddrinfo(TARGET_HOST, TARGET_PORT, socket.AF_INET, socket.SOCK_STREAM) + return infos[0][4] + except Exception: + # Docker Desktop's well-known host-gateway IPv4 as a last resort. + return ("192.168.65.254", TARGET_PORT) + + +TARGET = resolve_target() + + +def pipe(source, destination): + try: + while True: + data = source.recv(65536) + if not data: + break + destination.sendall(data) + except Exception: + pass + finally: + for sock in (source, destination): + try: + sock.close() + except Exception: + pass + + +def handle(client): + try: + upstream = socket.create_connection(TARGET, timeout=10) + except Exception: + client.close() + return + threading.Thread(target=pipe, args=(client, upstream), daemon=True).start() + threading.Thread(target=pipe, args=(upstream, client), daemon=True).start() + + +def main(): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + srv.bind((LISTEN_HOST, LISTEN_PORT)) + except OSError: + # Another instance already owns the port; let it serve. + raise SystemExit(0) + srv.listen(128) + print("chrome-host: proxy listening %s:%d -> %s:%d" % (LISTEN_HOST, LISTEN_PORT, TARGET[0], TARGET[1]), flush=True) + while True: + try: + conn, _ = srv.accept() + except Exception: + continue + handle(conn) + + +if __name__ == "__main__": + main() diff --git a/scripts/mcps/chrome-host/mcp-entry.sh b/scripts/mcps/chrome-host/mcp-entry.sh new file mode 100755 index 00000000..030ee7b7 --- /dev/null +++ b/scripts/mcps/chrome-host/mcp-entry.sh @@ -0,0 +1,33 @@ +#!/bin/sh + +# Referenced by .mcp.json / opencode.json as the `chrome-host` MCP server command. +# +# Spawns chrome-devtools-mcp inside the node container (via mcp-launch.sh), +# whether the assistant runs in the devcontainer or on the host. Docker needs +# `sudo` inside the container (like the node/npx wrappers) but not on the host; +# we tell them apart by /.dockerenv, which exists only in a container. + +set -eu + +# Compose service that runs the proxy + chrome-devtools-mcp. +DOCKER_SERVICE_NAME=node + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" + +# Walk up to the repo root (the directory holding docker-compose.yml) so +# `docker compose` finds the compose files + .env regardless of the caller's cwd. +root="$script_dir" +while [ "$root" != "/" ] && [ ! -f "$root/docker-compose.yml" ]; do + root="$(dirname "$root")" +done +cd "$root" + +# Path to mcp-launch.sh as seen inside the node container (repo mounted at /workspace). +rel="${script_dir#"$root"/}" +launch="/workspace/$rel/mcp-launch.sh" + +if [ -f /.dockerenv ]; then + exec sudo docker compose exec -T "$DOCKER_SERVICE_NAME" "$launch" +else + exec docker compose exec -T "$DOCKER_SERVICE_NAME" "$launch" +fi diff --git a/scripts/mcps/chrome-host/mcp-launch.sh b/scripts/mcps/chrome-host/mcp-launch.sh new file mode 100755 index 00000000..c47119ed --- /dev/null +++ b/scripts/mcps/chrome-host/mcp-launch.sh @@ -0,0 +1,36 @@ +#!/bin/sh + +# Runs INSIDE the node container, invoked by mcp-entry.sh. +# +# Ensures the loopback CDP proxy (cdp_proxy.py) is running, then hands stdio over +# to chrome-devtools-mcp pointed at the proxy. Resolves cdp_proxy.py next to +# itself, so it works from any location under the repo. + +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +PROXY_PORT=9334 # fixed; must match LISTEN_PORT in cdp_proxy.py +PROXY_SCRIPT="$SCRIPT_DIR/cdp_proxy.py" + +is_up() { + python3 -c 'import socket,sys; s=socket.socket(); sys.exit(0 if s.connect_ex(("127.0.0.1",'"$PROXY_PORT"'))==0 else 1)' 2>/dev/null +} + +if ! is_up; then + # cdp_proxy.py binds-or-exits, so concurrent launches are safe. + python3 "$PROXY_SCRIPT" >/tmp/cdp_proxy.log 2>&1 & + i=0 + while [ "$i" -lt 25 ]; do + if is_up; then + break + fi + i=$((i + 1)) + sleep 0.2 + done +fi + +# chrome-devtools-mcp is fetched on demand via npx rather than being added to +# package.json: it is host/dev MCP tooling for this bridge, not an app build or +# runtime dependency, so it deliberately stays out of the lockfile. `-y` confirms +# the one-off npx install; `@latest` tracks upstream fixes for the dev tool. +exec npx -y chrome-devtools-mcp@latest --browser-url "http://127.0.0.1:${PROXY_PORT}" "$@" diff --git a/scripts/mcps/chrome-host/mcp-setup.sh b/scripts/mcps/chrome-host/mcp-setup.sh new file mode 100755 index 00000000..54cfeb0e --- /dev/null +++ b/scripts/mcps/chrome-host/mcp-setup.sh @@ -0,0 +1,70 @@ +#!/bin/sh + +# Run on the HOST during project setup (called from setup.sh). +# +# Native Docker Engine on Linux doesn't inject `host.docker.internal`, so the +# proxy container can't reach host Chrome. This adds the host-gateway mapping to +# the node service -- but only there. On Docker Desktop/macOS it's a no-op: +# the name already works, and adding the mapping would repoint it to the bridge +# gateway and break Desktop. +# +# Idempotent; after patching a running setup, recreate the service: +# `docker compose up -d node`. + +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd)" +COMPOSE_FILE="$ROOT/docker-compose.yml" +DOCKER_SERVICE_NAME=node +MAPPING="host.docker.internal:host-gateway" + +# Only native Docker Engine on Linux needs the mapping. macOS is always Desktop. +if [ "$(uname -s)" = "Darwin" ]; then + exit 0 +fi + +DOCKER_OS="$(docker info --format '{{.OperatingSystem}}' 2>/dev/null || true)" +if [ -z "$DOCKER_OS" ]; then + echo "chrome-host: warning: could not query Docker (is it running?); skipping host-gateway setup." >&2 + exit 0 +fi +case "$DOCKER_OS" in + *"Docker Desktop"*) exit 0 ;; +esac + +# Native Docker Engine from here on. + +# The host launcher forwards Chrome's loopback CDP port to the bridge gateway +# with ncat (headed Chrome only binds loopback). Warn now rather than failing at +# first browser launch; don't auto-install -- that needs sudo plus distro +# detection, and adding host packages requires explicit approval. +if ! command -v ncat >/dev/null 2>&1; then + echo "chrome-host: warning: 'ncat' not found; install it (package 'nmap-ncat' or 'nmap'," >&2 + echo "chrome-host: depending on your distro) so start-host-chrome.sh can expose Chrome's" >&2 + echo "chrome-host: CDP port on the bridge gateway for the container." >&2 +fi + +[ -f "$COMPOSE_FILE" ] || exit 0 +if grep -q "$MAPPING" "$COMPOSE_FILE"; then + exit 0 +fi + +# Insert extra_hosts under the `:` block (sibling of `extends:`). +TMP_FILE="$COMPOSE_FILE.tmp" +awk -v service="$DOCKER_SERVICE_NAME" ' + { print } + $0 ~ ("^ " service ":[[:space:]]*$") && !done { + print " extra_hosts:" + print " - \"host.docker.internal:host-gateway\"" + done = 1 + } +' "$COMPOSE_FILE" > "$TMP_FILE" + +if grep -q "$MAPPING" "$TMP_FILE"; then + mv "$TMP_FILE" "$COMPOSE_FILE" + echo "chrome-host: native Docker Engine detected -- added '$MAPPING' to the '$DOCKER_SERVICE_NAME' service in $COMPOSE_FILE." + echo "chrome-host: if the '$DOCKER_SERVICE_NAME' container is already running, recreate it: docker compose up -d $DOCKER_SERVICE_NAME" +else + rm -f "$TMP_FILE" + echo "chrome-host: warning: could not locate the '$DOCKER_SERVICE_NAME' service in $COMPOSE_FILE; add '$MAPPING' to its extra_hosts manually." >&2 +fi diff --git a/scripts/mcps/chrome-host/start-host-chrome.sh b/scripts/mcps/chrome-host/start-host-chrome.sh new file mode 100755 index 00000000..03f4f1fe --- /dev/null +++ b/scripts/mcps/chrome-host/start-host-chrome.sh @@ -0,0 +1,267 @@ +#!/bin/sh + +# Run on the HOST (macOS or Linux). Launches a dedicated, isolated Chrome with +# the DevTools (CDP) endpoint enabled so a containerized assistant can drive it. +# +# It uses its own dedicated --user-data-dir, so your everyday Chrome and logins +# are untouched. A separate profile is also required: Chrome only opens a debug +# port for a fresh instance, not for an already-running default profile. +# +# That profile is persistent (reused across runs, kept under $HOME) -- not +# ephemeral or sandboxed. Anything you sign in to here stays on disk and is +# reachable over the unauthenticated CDP port, so avoid logging in to sensitive +# accounts in this browser. +# +# Overrides (shell wins over the repo's .env); see README -> Platform support +# for the bind/platform details: +# CHROME_DEBUG_PORT CDP port (default 9333) +# CHROME_BIN explicit browser binary (else auto-detected) +# CHROME_DEBUG_BIND bind address (else auto-detected: loopback on Docker +# Desktop, docker bridge gateway on native Engine). +# SECURITY: a non-loopback bind exposes the port -- firewall it. +# +# Profile is always ~/.chrome-debug-profile- (not configurable). + +set -eu + +# Opens a real browser window, so it must run on the HOST, not in a container +# (/.dockerenv exists only inside one). +if [ -f /.dockerenv ]; then + echo "chrome-host: error: run start-host-chrome.sh on your HOST, not in a container." >&2 + echo "chrome-host: it launches a Chrome window on your desktop for the containerized" >&2 + echo "chrome-host: assistant to drive. Open a host terminal and run it there." >&2 + exit 1 +fi + +# Fall back to the repo's .env (the file Docker Compose reads) so this launcher +# and the in-container proxy share one source of truth. Precedence: shell > .env +# > the defaults below. +env_root="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +while [ "$env_root" != "/" ] && [ ! -f "$env_root/docker-compose.yml" ]; do + env_root="$(dirname "$env_root")" +done +ENV_FILE="$env_root/.env" +# For each host knob, keep a value already set in the shell; otherwise eval the +# matching .env line. Letting the shell run the assignment strips quotes and +# inline comments for free. Values containing spaces must be quoted in .env. +if [ -f "$ENV_FILE" ]; then + for env_key in CHROME_DEBUG_PORT CHROME_BIN CHROME_DEBUG_BIND COMPOSE_PROJECT_NAME COMPOSE_DOCS_SERVER_PORT; do + eval "[ -n \"\${$env_key+x}\" ]" && continue + eval "$(grep -E "^[[:space:]]*$env_key=" "$ENV_FILE" | tail -n 1 || true)" + done +fi + +PORT="${CHROME_DEBUG_PORT:-9333}" + +# Preflight: curl drives every CDP readiness probe below, on every platform. +if ! command -v curl >/dev/null 2>&1; then + echo "chrome-host: error: 'curl' is required to probe Chrome's CDP endpoint but is not installed." >&2 + exit 1 +fi + +# Bind address. Explicit CHROME_DEBUG_BIND wins; otherwise auto-detect: loopback +# on Docker Desktop (incl. macOS), else the docker bridge gateway for native +# Engine. Binding to the gateway (not 0.0.0.0) keeps the port off your wider +# network. See README -> Platform support. +detect_bind() { + if [ "$(uname -s)" = "Darwin" ]; then + printf '127.0.0.1\n' + return 0 + fi + case "$(docker info --format '{{.OperatingSystem}}' 2>/dev/null || true)" in + *"Docker Desktop"*) + printf '127.0.0.1\n' + return 0 + ;; + esac + # println each gateway on its own line, then take the first IPv4 -- an + # IPv6-enabled bridge would otherwise concatenate the v4 and v6 gateways into + # one malformed token that ncat cannot bind. + gateway_ip="$(docker network inspect bridge --format '{{range .IPAM.Config}}{{println .Gateway}}{{end}}' 2>/dev/null | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)" + if [ -n "$gateway_ip" ]; then + printf '%s\n' "$gateway_ip" + return 0 + fi + return 1 +} +# Preflight: auto-detecting the bind address shells out to the docker CLI. Skip +# the check when the bind is supplied explicitly, or on macOS (always loopback). +if [ -z "${CHROME_DEBUG_BIND:-}" ] && [ "$(uname -s)" != "Darwin" ] && ! command -v docker >/dev/null 2>&1; then + echo "chrome-host: error: auto-detecting the CDP bind address needs the 'docker' CLI, but it is" >&2 + echo "chrome-host: not installed. Install Docker, or set CHROME_DEBUG_BIND (e.g. 127.0.0.1 for" >&2 + echo "chrome-host: Docker Desktop) to skip detection." >&2 + exit 1 +fi +if [ -n "${CHROME_DEBUG_BIND:-}" ]; then + BIND="$CHROME_DEBUG_BIND" +else + BIND="$(detect_bind)" || true + if [ -z "$BIND" ]; then + echo "chrome-host: error: could not determine the Docker bridge gateway for the CDP forwarder" >&2 + echo "chrome-host: (is the Docker daemon running?). Set CHROME_DEBUG_BIND to your docker bridge" >&2 + echo "chrome-host: gateway IP to override, and retry." >&2 + exit 1 + fi +fi +case "$BIND" in + 127.0.0.1 | ::1) ;; + *) + echo "chrome-host: warning: native Docker detected — exposing the CDP port on $BIND so the" >&2 + echo "chrome-host: container can reach it. This exposes browser control beyond" >&2 + echo "chrome-host: loopback; firewall the port. Native Docker also needs" >&2 + echo "chrome-host: 'host.docker.internal:host-gateway' in the node service's" >&2 + echo "chrome-host: extra_hosts. Set CHROME_DEBUG_BIND to override." >&2 + ;; +esac + +# Preflight: a concrete non-loopback bind needs an ncat forwarder (see +# ensure_forwarder). Check ncat now, before launching Chrome, so a missing tool +# fails fast instead of orphaning a browser window. Skip it if a forwarder from +# a previous run is already serving the bind. +case "$BIND" in + 127.0.0.1 | ::1 | 0.0.0.0 | ::) ;; + *) + if ! curl -s -m 2 "http://${BIND}:${PORT}/json/version" >/dev/null 2>&1 \ + && ! command -v ncat >/dev/null 2>&1; then + echo "chrome-host: error: this run must expose Chrome's CDP port ${PORT} on ${BIND} via an" >&2 + echo "chrome-host: ncat forwarder, but 'ncat' is not installed. Install ncat (the 'nmap-ncat'" >&2 + echo "chrome-host: or 'nmap' package, depending on your distro)." >&2 + exit 1 + fi + ;; +esac + +# Profile is always per-project and not configurable. COMPOSE_PROJECT_NAME comes +# from .env (or your shell); fall back to the repo directory name, like Compose. +PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$env_root")}" +PROFILE="$HOME/.chrome-debug-profile-$PROJECT" + +find_chrome() { + if [ -n "${CHROME_BIN:-}" ]; then + printf '%s\n' "$CHROME_BIN" + return 0 + fi + case "$(uname -s)" in + Darwin) + for candidate in \ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta" \ + "/Applications/Chromium.app/Contents/MacOS/Chromium" \ + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"; do + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + ;; + *) + for candidate in google-chrome google-chrome-stable chromium chromium-browser microsoft-edge brave-browser; do + resolved="$(command -v "$candidate" 2>/dev/null || true)" + if [ -n "$resolved" ]; then + printf '%s\n' "$resolved" + return 0 + fi + done + ;; + esac + return 1 +} + +# Headed Chromium pins DevTools to 127.0.0.1 and silently ignores +# --remote-debugging-address (Fedora's build, among others), so the launcher no +# longer passes that flag: Chrome always binds loopback, and ensure_forwarder +# spawns an ncat hop on $BIND:$PORT to expose it for the container. Loopback and +# wildcard binds need no hop -- loopback is already reachable, and a wildcard +# would collide with Chrome's own loopback port. +ensure_forwarder() { + bind=$1 + port=$2 + pidfile=$3 + + case "$bind" in 127.0.0.1 | ::1 | 0.0.0.0 | ::) return 0 ;; esac + + mkdir -p "$(dirname "$pidfile")" + + # A reachable bind:port means a forwarder from a previous run still serves it + # (Chrome itself only binds loopback), so reuse it. + if curl -s -m 2 "http://${bind}:${port}/json/version" >/dev/null 2>&1; then + echo "chrome-host: forwarder already serving ${bind}:${port}" + return 0 + fi + + if ! command -v ncat >/dev/null 2>&1; then + echo "chrome-host: error: Chrome bound CDP to loopback only and 'ncat' is not installed." >&2 + echo "chrome-host: install ncat (the 'nmap-ncat' or 'nmap' package, depending on your distro)" >&2 + echo "chrome-host: so a host-side forwarder can expose ${port} on ${bind} for the container." >&2 + return 1 + fi + + if [ -f "$pidfile" ]; then + kill "$(cat "$pidfile")" 2>/dev/null || true + rm -f "$pidfile" + fi + + log=${pidfile%.pid}.log + nohup ncat -k -l "$bind" "$port" --sh-exec "ncat 127.0.0.1 $port" >"$log" 2>&1 & + echo $! >"$pidfile" + + j=0 + while [ "$j" -lt 20 ]; do + if curl -s -m 2 "http://${bind}:${port}/json/version" >/dev/null 2>&1; then + echo "chrome-host: forwarder ${bind}:${port} -> 127.0.0.1:${port} (pid $(cat "$pidfile"))" + return 0 + fi + j=$((j + 1)) + sleep 0.5 + done + + echo "chrome-host: error: forwarder failed to bind ${bind}:${port}; see $log" >&2 + return 1 +} + +if curl -s -m 2 "http://127.0.0.1:${PORT}/json/version" >/dev/null 2>&1; then + echo "chrome-host: debug Chrome already running on 127.0.0.1:${PORT}" + ensure_forwarder "$BIND" "$PORT" "$PROFILE/forwarder.pid" || exit 1 + exit 0 +fi + +CHROME="$(find_chrome || true)" +if [ -z "${CHROME:-}" ]; then + echo "chrome-host: error: no Chrome/Chromium found. Set CHROME_BIN=/path/to/browser and retry." >&2 + exit 1 +fi + +mkdir -p "$PROFILE" + +# Build the argument list (quote the glob so the shell doesn't expand '*'). +# --ignore-certificate-errors skips the "Your connection is not private" +# interstitial for local HTTPS dev servers with a self-signed/absent cert. +# Acceptable here: this is a dedicated, isolated debug profile, not your +# everyday browser. +set -- \ + --remote-debugging-port="$PORT" \ + '--remote-allow-origins=*' \ + --user-data-dir="$PROFILE" \ + --no-first-run \ + --no-default-browser-check \ + --ignore-certificate-errors +set -- "$@" --new-window "http://localhost:${COMPOSE_DOCS_SERVER_PORT:-8000}" + +nohup "$CHROME" "$@" >"$PROFILE/chrome-debug.log" 2>&1 & + +i=0 +while [ "$i" -lt 30 ]; do + if curl -s -m 2 "http://127.0.0.1:${PORT}/json/version" >/dev/null 2>&1; then + echo "chrome-host: debug Chrome ready on 127.0.0.1:${PORT}" + echo "chrome-host: binary: $CHROME" + echo "chrome-host: profile: $PROFILE" + echo "chrome-host: log: $PROFILE/chrome-debug.log" + ensure_forwarder "$BIND" "$PORT" "$PROFILE/forwarder.pid" || exit 1 + exit 0 + fi + i=$((i + 1)) + sleep 0.5 +done + +echo "chrome-host: error: Chrome did not expose CDP on 127.0.0.1:${PORT}; see $PROFILE/chrome-debug.log" >&2 +exit 1 diff --git a/setup.sh b/setup.sh index 9422d798..5469d779 100755 --- a/setup.sh +++ b/setup.sh @@ -98,6 +98,9 @@ else echo "docker-compose.yml file already exists, skipping creation." fi +# Configure the Chrome-host bridge networking (native Docker Engine only) +sh ./scripts/mcps/chrome-host/mcp-setup.sh + # Build Docker images sh ./docker/build-docker-images.sh diff --git a/src/docs/contribute/ai-integration.md b/src/docs/contribute/ai-integration.md new file mode 100644 index 00000000..edea31a0 --- /dev/null +++ b/src/docs/contribute/ai-integration.md @@ -0,0 +1,89 @@ +# AI Integration + +The `devcontainer` ships several AI coding assistants (see +[General Guidelines](./general-guidelines.md#what-the-devcontainer-contains)): + +* **Claude Code** +* **GitHub Copilot CLI** +* **OpenCode** + +Through [Model Context Protocol (MCP)][mcp] they can be extended with extra +capabilities. + +## Host Chrome via MCP + +Any MCP-capable assistant in the `devcontainer` can drive a Chrome browser +running on your **host** machine, using [`chrome-devtools-mcp`][chrome-devtools-mcp]. +This is useful for verifying UI changes, taking screenshots, and interactively +debugging the running application in a real browser. + +The wiring lives in the `scripts/mcps/chrome-host/` directory and works on +macOS and Linux. + +### Setup + +The `chrome-host` server is registered automatically and, on **Docker Desktop**, +the container reaches host Chrome out of the box. Extra steps apply only to +**native Docker Engine on Linux**, where `host.docker.internal` does not resolve +by default and the `node` service needs a host-gateway mapping. + +#### Automatic setup + +The host-gateway mapping is added automatically on native Docker Engine as part +of the project's [automatic setup](./general-guidelines.md#automatic-setup) +(`setup.sh` runs `scripts/mcps/chrome-host/mcp-setup.sh`). + +#### Manual setup + +To configure it by hand instead, add the host-gateway mapping to the `node` +service: + +```yaml +services: + node: + extra_hosts: + - "host.docker.internal:host-gateway" +``` + +#### Environment + +Chrome binary, port, and bind address can be overridden via the repo's `.env` +(the host launcher reads it directly; the in-container proxy receives the port +through Docker Compose). See `.env.dist` for the available options. + +### Usage + +1. **Start the debug browser on the host.** Run this from a normal host terminal + (not from inside the `devcontainer`): + + ```bash + npm run start:chrome + ``` + + or if you do not have `npm` installed on the host: + + ```bash + ./scripts/mcps/chrome-host/start-host-chrome.sh + ``` + + A dedicated browser window opens using a throwaway profile, so your everyday + browser and its logins are untouched. The command is safe to re-run. + +2. **Trust the project's MCP servers.** All three assistants are pre-configured + in the repository. Each asks you once, on first run, to trust the project's + MCP servers — approve it. + + * **Claude** and **GitHub Copilot CLI** read the project's `.mcp.json`. + * **OpenCode** reads the project's `opencode.json`. + +3. **Use it.** The assistant can now navigate, click, screenshot, and inspect + the host browser window. + +### Notes + +* For how it works, platform specifics (Docker Desktop vs. native Docker on + Linux), and configuration options, see + `scripts/mcps/chrome-host/README.md`. + +[mcp]: https://modelcontextprotocol.io/ +[chrome-devtools-mcp]: https://github.com/ChromeDevTools/chrome-devtools-mcp diff --git a/src/docs/contribute/general-guidelines.md b/src/docs/contribute/general-guidelines.md index c30d615a..2df36912 100644 --- a/src/docs/contribute/general-guidelines.md +++ b/src/docs/contribute/general-guidelines.md @@ -264,6 +264,10 @@ Actions), please follow these guidelines: (#261)_. 3. Optionally use Markdown code blocks to emphasize, e.g. _Create `ScrollView` component (#53)_. + 4. When a commit fixes a commit already on the current branch (review + feedback, a bug or typo in earlier work, a follow-up), commit it as a + _fixup_ so history stays atomic after the pre-merge squash, instead of a + standalone "fix" commit. 5. **Write clear, helpful and descriptive PR names.**