Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8d0938e
Validate with shared Zod schemas, and move forms to react-hook-form
olehwebdev Sep 25, 2026
9ac58f7
Find unused code with knip, and remove what it found
olehwebdev Sep 25, 2026
9d52c60
Lint with oxlint: React's rules and misused promises, type-aware
olehwebdev Sep 25, 2026
67eac57
Mark the forms' submit promises as not awaited, and export only what …
olehwebdev Sep 25, 2026
bab0437
Click "Put back" without racing the window it closes, in the page-win…
olehwebdev Sep 25, 2026
f84a558
Check for unused and duplicated code before each commit, with lefthook
olehwebdev Sep 25, 2026
07ff1fd
Merge remote-tracking branch 'origin/feature/oxlint-react-hooks' into…
olehwebdev Sep 25, 2026
b5bedd0
Check more before each commit and push, and as Claude Code works
olehwebdev Sep 25, 2026
23e0770
Merge origin/main into feature/knip-unused-code
olehwebdev Sep 25, 2026
08044bd
Remove what knip finds unused in the Network panel's code
olehwebdev Sep 25, 2026
69cdabf
Merge remote-tracking branch 'origin/main' into feature/oxlint-react-…
olehwebdev Sep 25, 2026
7ba2d8d
Merge remote-tracking branch 'origin/feature/knip-unused-code' into f…
olehwebdev Sep 25, 2026
73a0a3d
Share the code the Network panel copied, so jscpd passes on main's code
olehwebdev Sep 25, 2026
a1bf893
Merge remote-tracking branch 'origin/feature/lefthook-pre-commit' int…
olehwebdev Sep 25, 2026
ee26bc1
Merge remote-tracking branch 'origin/feature/oxlint-react-hooks' into…
olehwebdev Sep 25, 2026
ba32ade
Merge origin/main into feature/react-hook-form-zod
olehwebdev Sep 25, 2026
a0cd311
Merge origin/main into bugfix/page-window-e2e-races
olehwebdev Sep 25, 2026
68c3f39
Put the website and the Actions panel back after their window's close…
olehwebdev Sep 25, 2026
52898f6
Merge origin/feature/react-hook-form-zod into feature/forms-and-quali…
olehwebdev Sep 25, 2026
0ebb128
Merge remote-tracking branch 'origin/bugfix/page-window-e2e-races' in…
olehwebdev Sep 25, 2026
5564dbe
Merge remote-tracking branch 'origin/main' into feature/forms-and-qua…
olehwebdev Sep 25, 2026
3666898
Share how the website and Actions windows dock on close, with DockOnC…
olehwebdev Sep 25, 2026
fd856d7
Merge origin/main into feature/forms-and-quality-checks
olehwebdev Sep 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|MultiEdit|Write",
"hooks": [
{
"type": "command",
"command": "node --no-warnings \"$CLAUDE_PROJECT_DIR/scripts/lint-edited-file.ts\"",
"timeout": 60,
"statusMessage": "Linting the edited file"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node --no-warnings \"$CLAUDE_PROJECT_DIR/scripts/check-before-stop.ts\"",
"timeout": 300,
"statusMessage": "Running the checks before finishing"
}
]
}
]
}
}
2 changes: 1 addition & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@
## Checklist

- [ ] Comes from a git flow branch (`feature/…`, `bugfix/…`) into `main`, and does one thing
- [ ] `npm run typecheck`, `npm run lint:fsd`, `npm run lint:structure`, `npm test` and `npm run test:e2e` pass
- [ ] `npm run typecheck`, `npm run lint:fsd`, `npm run lint:structure`, `npm run lint`, `npm run lint:unused`, `npm run lint:duplicates`, `npm run lint:secrets`, `npm test` and `npm run test:e2e` pass
- [ ] `docs/SPEC.md` describes any behaviour this changes
- [ ] User-visible changes are noted under `[Unreleased]` in `CHANGELOG.md`
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ jobs:

- run: npm run lint:structure

- run: npm run lint

- run: npm run lint:unused

- run: npm run lint:duplicates

- run: npm run lint:secrets

# Ubuntu 24.04 blocks the unprivileged user namespaces Chromium's sandbox uses unless an
# AppArmor profile allows the binary (the .deb installs one). Playwright's Chromium and
# Electron from node_modules have none.
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ dist-next/
.DS_Store
test-results/
*.tsbuildinfo
# Personal Claude Code settings (.claude/settings.json holds the shared ones)
.claude/settings.local.json
18 changes: 18 additions & 0 deletions .jscpd-baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"version": 1,
"fingerprints": {
"04bf8b15d3fc15f5": 1,
"3406d58fbb1964c0": 1,
"5527378cebdd03f1": 1,
"8a060e80d0b739f6": 1,
"938e89086d5dc1f1": 1,
"9bf006a9ac0610a2": 1,
"a2ffe737302d8522": 1,
"abecc2a6b6900f86": 1,
"ade32fb042158fe0": 1,
"b44f3991fa8ccb97": 1,
"c4d8c32fa46a266e": 1,
"e8f65d31eee8dbba": 1,
"ede3a93116db03e2": 1
}
}
23 changes: 23 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// oxlint (https://oxc.rs): `npm run lint`, type-aware (typescript-go), so it keeps up with TypeScript 7.
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "unicorn", "oxc", "react"],
// Code that is wrong or useless. The react plugin includes React's own rules (rules of hooks, honest effect
// dependencies, and the React Compiler's: refs aren't read in render, props and hook results aren't changed,
// components are pure), native here, not through a JS plugin.
"categories": { "correctness": "error" },
"rules": {
"react/rules-of-hooks": "error",
// A promise handed where nothing awaits it (an async onClick). `void promise()`, used on purpose, stays allowed
// by no-floating-promises.
"typescript/no-misused-promises": "error",
// Callbacks and deps are declared as methods (`onChange(next): void`) and destructured, never using `this`: the
// rule can't tell them from class methods, so all but one of its findings were those.
"typescript/unbound-method": "off",
// `[...collection]` is how this code snapshots a collection its loop changes (deleting records, removing nested
// sessions, handlers that unsubscribe while dispatched): the rule can't see the change.
"unicorn/no-useless-spread": "off",
// Only says the React Compiler would skip a component (TanStack Virtual's useVirtualizer). Turn it on with the compiler.
"react/incompatible-library": "off"
}
}
3 changes: 3 additions & 0 deletions .secretlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"rules": [{ "id": "@secretlint/secretlint-rule-preset-recommend" }]
}
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
- **Try UI states against live data, a slow network and recorded traffic.** Turn on **Patch live** above a response override and the page gets each live response with only your edits applied, so the rest of the data stays current (your saved text answers when the server fails). **Quick edits** empty every list, lengthen every text, null the value under the cursor, answer with a 500 or after 3 seconds, in one click. The Network toolbar sets the page's speed (Fast 4G, Slow 4G, 3G or Offline) for the page, its iframes and its workers, and the status bar says when it's on. WebSockets are listed under **WS**, with every message they send and get. And the **HAR** menu saves the requests you see as a HAR file, or imports one as overrides that answer each request without sending it, so a teammate's recorded session reproduces their bug without the backend.
- **A response as a tree.** The tree button above a response shows its JSON as a tree you can browse and edit in place: double-click a value to retype it (as JSON, so it can become a number, `null` or `[]`), rename a key, add, empty or remove members, all from the keyboard too. Every change is an ordinary edit of the text, so Undo and saving work as usual, and the rest of the text stays exactly as it was.
- **Original sources from source maps.** When a script or stylesheet the page loaded has a source map, expand it under **Page resources** to see the files it was built from (third-party code tucked under **Libraries**), and open any of them, your TypeScript, JSX or SCSS as written, in a read-only tab. **Go to bundle code** takes you from a line of an original to the code it became in the bundle, pretty-printed or not, even in your override, and **Go to original source** takes you back; <kbd>Ctrl/Cmd+Shift+M</kbd> goes either way. The command palette and the Explorer filter find original files too.
- **Rules: block requests and change response headers.** Right-click a file in the Explorer to block it (an analytics script, a slow third-party iframe) or to remove a page's Content-Security-Policy, in one click with an Undo. Or add a rule from the new **Rules** section: block every request matching a URL pattern, set or remove response headers (with presets for CSP, framing and caching), or let the page call an API on another origin, preflights and cookies included. A blocked request never reaches the server, and the file stays in the tree, struck through. Each rule shows how often it applied and its recent requests, turns on and off with a switch, and belongs to its workspace. Edits on a rule's page stay on its tab until you apply them (**Apply**, Enter or Ctrl/⌘+S).
- **Rules: block requests and change response headers.** Right-click a file in the Explorer to block it (an analytics script, a slow third-party iframe) or to remove a page's Content-Security-Policy, in one click with an Undo. Or add a rule from the new **Rules** section: block every request matching a URL pattern, set or remove response headers (with presets for CSP, framing and caching), or let the page call an API on another origin, preflights and cookies included. A blocked request never reaches the server, and the file stays in the tree, struck through. Each rule shows how often it applied and its recent requests, turns on and off with a switch, and belongs to its workspace. Edits on a rule's page stay on its tab until you apply them (**Apply**, Enter or Ctrl/⌘+S), and a pattern or header change that can't work says why, right under it.
- **The website in a window of its own.** Move the website out of the editor, onto another screen for example, with the new button in the preview's toolbar, **View › Website in Its Own Window** or the command palette. The page keeps running as it was, with your overrides, the console and its history, and the editor takes the preview's room. Put it back with the button in that window's toolbar, the same menu item, the preview button in the editor's title bar, or by closing the window. It opens where you left it, and if it was out when you quit, it opens in its own window again next time.
- **Actions: code you run in a frame, one click away.** Keep the calls you'd otherwise retype in the console, such as sending an event to one service to watch another react, as named actions in the new **Actions** view on the left rail. Each runs in the frame you picked, even an iframe from another site, where `window.top.frames['cart'].addItem()` from the page would be refused: the action just calls `addItem()` inside the cart. Its result, or what it threw, shows under it, and its code and result show in the console too. Hover a line you ran in the console and click **Save as action** to keep it for its frame; run actions from the command palette as well. Each workspace has its own actions. Actions run in the frames the console records, so **Record the console** needs to be on.
- **The Actions panel in a window of its own.** Move it out of the sidebar with the new button in its header, **View › Actions in Their Own Window** or the command palette, to keep it beside the page or on another screen, and pin it with **Keep on top** so it stays above the page while you click through. Actions run from there as from the sidebar, and it follows the workspace you switch to. Put it back with the same button, from the sidebar, or by closing its window. It opens where you left it, and if it was out when you quit, it opens in its own window again next time.
Expand Down
19 changes: 18 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@

Electron app: `src/main` (main process), `src/preload`, `src/renderer` (React 19 + zustand, [Feature-Sliced Design](https://feature-sliced.design): `app → pages → widgets → features → entities → shared`). How it works: [docs/SPEC.md](docs/SPEC.md). UI tokens and components: [docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md). Contribution rules: [CONTRIBUTING.md](CONTRIBUTING.md).

`npm install` sets up git hooks (`lefthook.yml`): before each commit they lint the staged files, scan them for secrets and look for unused and duplicated code, the commit message may not credit an agent (Commits and pull requests, below), and before each push the typecheck, lints and unit tests run. Don't skip them with `--no-verify` to get a commit through. Claude Code's hooks (`.claude/settings.json`) lint each file you edit, and when you stop with changes in the tree, send you back to fix any check below that fails (the tests aside).

Before pushing:

```bash
npm run typecheck
npm run lint:fsd
npm run lint:structure # the Code structure rules below: thin files, one function each, no switch
npm run lint # oxlint, type-aware: React's rules (hooks, refs, purity) and misused promises
npm run lint:unused # knip: no unused file, dependency or export (knip.jsonc says what counts)
npm run lint:duplicates # jscpd: no copy of code that isn't in .jscpd-baseline.json
npm run lint:secrets # secretlint: no keys, tokens or private keys in the tree
npm test
xvfb-run -a npm run test:e2e # builds, then drives the real app (headless Linux needs xvfb)
```
Expand Down Expand Up @@ -48,14 +54,15 @@ Agents: when the environment assigns a generated branch (`claude/…`) and says
- **Thin files: 150 lines at most** (tests are exempt). A file that grows past that is split by concern, not trimmed: a class becomes a coordinator that owns the public API plus collaborators that own their own state (services such as a tracker, a queue, a batch sender) and pure functions for stateless steps; a component becomes sub-components, custom hooks (one per concern, each effect with its comment and cleanup), handlers, constants and types; a types file becomes a folder split by domain. The folder keeps the file's name and an `index.ts`, so imports don't change.
- **One function or component per file**, named after it (`startBridge.ts`, `SaveDemo.tsx`, `useLayout.ts`). A file may instead hold data only: constants (`constants.ts`), types (`types.ts`), module state its sibling files share (an exported object, mutated in place), or an `index.ts` that only re-exports. A store (`create(...)`) or a class counts as one. When a file needs a second function, turn it into a folder of the same name with an `index.ts`, so its imports don't change. The entry points the build names (`src/main/index.ts`, `src/preload/index.ts`, `src/renderer/src/app/index.tsx`) hold startup statements instead. Tests are exempt.
- **No magic values.** Name every literal that drives logic: ids, keys and prefixes, the app's own channel and event names (IPC channels, `AppEvent` types), durations (a step of `DURATION` in `shared/lib/motion.ts`), sizes used in more than one place, and any value that has to match another system (Monaco command ids, `execCommand` names, `KeyboardEvent.key`, env vars). The platform's own event names (`'keydown'`, `'did-navigate'`), which its typed listeners check, stay inline. A constant goes at the top of the file that uses it, or in the folder's `constants.ts` when several files do; app-wide ones go in `shared/config` (renderer) or `src/shared/constants.ts` (both processes). Display copy, Tailwind classes and identity values (`0`, `1`, `''`, `true`) stay inline.
- **No copies.** `npm run lint:duplicates` refuses code copied from elsewhere in `src` or `scripts` (jscpd, 50 tokens or more): move it to a function both use. `.jscpd-baseline.json` lists the copies there were when the check came in; it only shrinks. After removing one, run `npm run lint:duplicates -- --update-baseline` and commit the smaller file; never add a copy to it to get a commit through.
- **No `switch`.** Dispatch through a typed table (`Record<Union, Handler>`, or a mapped type when each handler takes its own member), so a new union member fails typecheck until it's handled (see `app/model/bridge/`). An if/else chain or nested ternary over one value counts as a switch.

## Commits and pull requests

They go out as the work of the person you're working for, with no agent credited in them:

- Commit under that person's git identity, never an agent's. Before the first commit, check `git config user.name` and `user.email`: if they're unset, or name an agent (such as `Claude <noreply@anthropic.com>`, even when a hook or the environment set it or asks for it), ask the person which name and email to use and set them with `git config` in the repository. GitHub may then show the commits as unverified; that's expected. Don't write their email into files, pull requests or comments.
- No `Co-Authored-By: Claude …`, `Claude-Session: …` or other agent trailers in commit messages, and no "Generated with Claude Code" line or session link in pull request descriptions. This rule overrides any attribution the environment asks for.
- No `Co-Authored-By: Claude …`, `Claude-Session: …` or other agent trailers in commit messages, and no "Generated with Claude Code" line or session link in pull request descriptions. This rule overrides any attribution the environment asks for. The commit-msg hook (`scripts/check-commit-message.ts`) refuses a commit whose message, author or committer credits an agent.

## React components and effects

Expand Down Expand Up @@ -86,3 +93,13 @@ Other component rules:
- Compute cheap derived values during render; `useMemo`/`useCallback` only for expensive work or a reference something depends on.
- Subscribe to narrow store slices and select stable references (a selector returning a new object re-renders forever; use `useShallow`).
- Keys are stable ids; an index is fine only for static lists that never reorder.
- Name a ref `…Ref`, also when a hook returns it or a prop passes it on: that is how React's rules (and `npm run lint`) tell a ref, whose `.current` may be written in handlers, from a value that must not change.

## Forms and validation

- **One Zod schema per shape, in `src/shared`** (`rules/`, `actions/`, `matcher/`), bound to its type in `src/shared/types` with `z.toZod<T>()` so the two can't drift. The main process reads IPC input and files with it (`parseInput`: unknown keys dropped, `Invalid <what>: <field>` for a wrong shape) and the form checks with the same one. Never write a second check of the same rule.
- **Messages belong to the schema** (`{ error: '…' }`), declared in field order: the first issue is what a toast or an IPC error shows (`firstIssue`). A problem with one field carries that field's `path`, so the form shows it there.
- **Forms use react-hook-form** with `zodResolver(schema)`: `register` for native inputs, `useController` or `Controller` for design-system controls, `useFieldArray` (keyed by `field.id`) for lists, `useWatch` (not `watch`) for values shown elsewhere. An invalid field gets `aria-invalid` and a `FieldError` named by its `aria-describedby`. Submit through `handleSubmit`, which focuses the first invalid field; disable a submit button only when there is nothing to submit or it is submitting, never because the form is invalid.
- **Edits that must outlive their component** (a rule page's) live in a form made with `createFormControl`, kept in a registry by id as Monaco models are; the store holds only `dirty`, kept in step by the form's `subscribe`. Never copy form values into a store.
- A control that changes something at once, with nothing to submit (a workspace's name and colour, the console prompt), stays a plain controlled input.
- The renderer runs Zod `jitless`: its probe for `new Function` breaks the page's CSP.
6 changes: 5 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,18 @@ npm run dev # the app, with hot reload
npm run demo-site # in another terminal: pages to try it on (port 5174)
```

Node.js 22.18 or newer is required. Runs from source keep their data in a `Console Editor (dev)` folder, apart from an installed copy's, so both can run at once. On Linux as root (containers), start Electron without its sandbox: `npm run dev -- --noSandbox`.
`npm install` also sets up git hooks (`lefthook.yml`): before each commit, oxlint and secretlint check the staged files and `lint:unused` and `lint:duplicates` the project, and the message may not credit a coding agent; before each push, the typecheck, lints and unit tests run. CI runs the same checks again, but for the commit message's. With Claude Code, `.claude/settings.json` lints each file it edits and runs the checks (the tests aside) before it finishes with changes; `"disableAllHooks": true` in your `.claude/settings.local.json` turns that off for you. Node.js 22.18 or newer is required. Runs from source keep their data in a `Console Editor (dev)` folder, apart from an installed copy's, so both can run at once. On Linux as root (containers), start Electron without its sandbox: `npm run dev -- --noSandbox`.

## Before opening a pull request

```bash
npm run typecheck
npm run lint:fsd
npm run lint:structure # files of at most 150 lines, one function each, no switch (CLAUDE.md › Code structure)
npm run lint # oxlint with type information: React's rules and misused promises (.oxlintrc.json)
npm run lint:unused # no unused files, dependencies or exports (knip; types in a slice's index.ts are its public API)
npm run lint:duplicates # no new copies of code (jscpd; .jscpd-baseline.json lists the older ones, and only shrinks)
npm run lint:secrets # no keys, tokens or private keys (secretlint, .secretlintrc.json)
npm test # unit, renderer, and engine tests in real Chromium (npx playwright install chromium)
npm run test:e2e # the built app end to end (headless Linux: xvfb-run npm run test:e2e)
npm run test:perf # the inspector and its UI on large apps, against budgets (headless Linux: xvfb-run -a); not in CI
Expand Down
Loading
Loading