diff --git a/AGENTS.md b/AGENTS.md index 77ada26..1de6b42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,20 +79,54 @@ drift off those the longer a session runs — on 2026-08-28 one session wrote eighty single-line `if`s against a rule stated two paragraphs above. So when a judgment rule starts being violated, give it a check rather than restating it. -| Rule | Proven by | -| -------------------------------------------------------------------- | ------------------ | -| `const name = () => {}`, never `function name() {}` | `pnpm check-style` | -| `Array`, never `Item[]` — left to right is more explicit | `pnpm lint` | -| Braces and multiple lines on every `if`, never a single-line one | `pnpm lint` | -| Import React members individually (`ReactNode`), never `React.X` | `pnpm lint` | -| `components/*`, `client/*`, `routes/*`, `shared/*` aliases in `app/` | `pnpm lint` | -| Only `@toolpath/part-server` uses the Toolpath SDK at runtime | `pnpm lint` | -| Nothing in `packages/` imports an application | `pnpm lint` | -| Only `stickout.ts` turns a clamping length into a stickout | `pnpm lint` | -| A relative import inside a package carries its `.js` extension | `pnpm lint` | -| The layering under Project Map | `pnpm lint` | -| Tailwind classes for styling; `style={{}}` only for a computed value | judgment | -| `@toolpath/ui` components over hand-authored HTML, while it is used | judgment | +| Rule | Proven by | +| ----------------------------------------------------------------------- | ------------------ | +| `const name = () => {}`, never `function name() {}` | `pnpm check-style` | +| `Array`, never `Item[]` — left to right is more explicit | `pnpm lint` | +| Braces and multiple lines on every `if`, never a single-line one | `pnpm lint` | +| Import React members individually (`ReactNode`), never `React.X` | `pnpm lint` | +| `components/*`, `client/*`, `routes/*`, `shared/*` aliases in `app/` | `pnpm lint` | +| Only `@toolpath/part-server` uses the Toolpath SDK at runtime | `pnpm lint` | +| Nothing in `packages/` imports an application | `pnpm lint` | +| Only `stickout.ts` turns a clamping length into a stickout | `pnpm lint` | +| A relative import inside a package carries its `.js` extension | `pnpm lint` | +| The layering under Project Map | `pnpm lint` | +| Tailwind classes for styling; `style={{}}` only for a computed value | judgment | +| `@toolpath/ui` components over anything hand-authored, while it is used | judgment | +| A box drawn over the page is `Menu`, not a portal of its own | `kit-usage.test` | + +### Reach for `@toolpath/ui` first + +**Before writing any UI, look for the kit component that already does it.** Not +only instead of raw HTML — instead of _anything hand-authored_, including a +control assembled out of other kit components, a popover, a placement rule, a +drag affordance, or a piece of keyboard handling. The kit is `Menu`, `Toggle`, +`Table`, `Button`, `IconButton`, `Checkbox`, `Input`, `Combobox`, `Dialog`, +`Tabs`, `Tooltip`, `Slider`, `Switch`, `Badge`, `Callout`, `Card`, `Notification` +and more; read `node_modules/@toolpath/ui/src/` and look before you build. + +This is the rule agents in this repository break most often, and it is expensive +every time. On 2026-09-11 one session hand-wrote a portal, a placing rule, a +flip rule, an Escape handler and a press-outside handler for two menus — every +one of them something `Menu.Popover` already is — and both menus then shipped +clipped by the card they were drawn inside, which is the defect the kit exists +to have solved once. The same session drew the millimetres/inches switch as two +`Chip`s side by side rather than the `Toggle` the kit exports for exactly that. +Neither was noticed until somebody looked at the screen and asked why. + +So: + +- **Search the kit before writing a component**, and name the kit component you + rejected if you write your own — "the kit has no X" is a claim to check, not + an assumption to make. +- **A kit component beats a correct hand-written one.** Being clipped, being + unreachable by keyboard, and reading as one more control on a crowded panel + are the costs a shared component has already paid. +- **Style with the kit's props and `cn`**, not with a parallel set of classes. +- **When the kit's shape fights you, use its escape hatch rather than leaving + it.** `Menu.Trigger` renders its own element, so pass the kit `Button` + through `render` instead of nesting one inside it — nesting is two controls + with one name, which a screen reader and `getByRole` both see. What the checks cannot carry: @@ -264,64 +298,68 @@ application unless that application says otherwise. route, and the pure half is where its rules live. A change to how it behaves is almost always a change to one of these rather than to `routes/part.tsx`: -| Question | Module | -| ---------------------------------------------------- | ---------------------------------------------------- | -| what the list holds, its names, ids, storage | `app/shared/feature-list.ts` | -| which key a row's lines reach the bill under | `sheetKeysOf`, same file | -| which of a row's lines one stack of it wrote | `lineId`, `app/shared/setup-sheet.ts` | -| what is on the order list, for both pages | `app/shared/order-list.ts` | -| whether a row has anything ordered, and what to buy | `isIncomplete` / `componentTotals`, same file | -| which of four things the page is being asked | `asked()`, same file | -| the three presses over the part that add a row | `app/components/add-bar.tsx` | -| whether the presses and the rows are drawn at all | `app/shared/part-chrome.ts` | -| where the part is framed, beside the questions | `app/shared/frame-inset.ts` | -| the heading face and the small-capitals label | `app/shared/type.ts` | -| how tall the tool list opens | `TABLE_OPENS_AT`, `components/part-tool-table.tsx` | -| the columns a list opens with, and their order | `TOOL_COLUMNS`, `components/part-tool-table.tsx` | -| the two columns the list turns on for itself | `app/shared/auto-columns.ts` | -| a row's answer, and what it opens to | `app/shared/recommendations.ts` | -| what a press on a row of the order list opens | `pressRow`, `app/routes/part.tsx` | -| whose stacks the tree beside an open box shows | `editedItem` / `treeKey`, same file | -| what the panel offers for the tool it shows | `app/shared/tool-actions.ts` | -| what fills the tool table, and the cache | `app/shared/catalog-matcher.ts` | -| a stored guid turned back into a record | `getTool`/`getHolder`/`getCollet`, `catalog.ts` | -| what overruling the rules offers, per column | `overridableTools`, `app/shared/tool-fit.ts` | -| the note and press a changed filter raises | `OverrideNotice`, `app/components/column-filter.tsx` | -| whether a value is inside a filter's bound | `withinRange`, `app/shared/filter.ts` | -| what a number box takes besides a number | `app/shared/range-entry.ts` | -| which bounds are somebody's own, not the geometry's | `ownBounds`, `app/shared/filter.ts` | -| which columns' rules an emptied box releases | `releasedBounds`, `app/shared/filter.ts` | -| what a narrowed axis says an unticked value brings | `facetCounts`, `app/shared/catalog-matcher.ts` | -| the same work, off the UI thread | `app/client/catalog-matcher.worker.ts` | -| what a click on the part means | `app/shared/part-interaction.ts` | -| which layer one press of Escape or Enter reaches | `app/shared/use-escape.ts` | -| which list the arrows move through, and the focus | `app/shared/arrow-target.ts` | -| how tall a menu or the column picker is, which way | `menuRoom`, `app/components/column-filter.tsx` | -| whether an open filter survives the list under it | `FilterMenu`, `app/components/column-filter.tsx` | -| a feature's assemblies, its slots, its storage | `app/shared/assembly-tree.ts` | -| what narrows what when any part is chosen first | `app/shared/assembly-narrowing.ts` | -| why an offered chuck cannot be built out of the crib | `colletGap`, same file | -| whether the rack shows them at all, and how many | `holdersToOffer`, same file | -| the press that shows them, hidden to begin with | `app/components/no-collet-toggle.tsx` | -| a group's worst case, and whose it is | `app/shared/group-geometry.ts` | -| the material a whole question has to clear | `askedCurve`, same file | -| how far below the holder a stack has to stand | `belowHolder`, `app/shared/drawn-assembly.ts` | -| which slots were filled against the rules | `overrides`, `app/shared/assembly-tree.ts` | -| what a stack offers, and its button's words | `app/shared/assembly-actions.ts` | -| what a shop calls an assembly, and its field | `renameItem` / `renameAssembly`, `name-field.tsx` | -| reading and filtering a holder or a collet | `app/shared/component-columns.ts` | -| which column header asks which filter | `app/shared/column-filters.ts` | -| a tool in one phrase, with its shank or neck | `app/shared/tool-type.ts` | -| which ticks a filter the page set puts on Type | `typesAsking`, `app/shared/tool-type.ts` | -| which of its columns a tap list narrows on | `askOfTapColumn`, `app/shared/column-filters.ts` | -| what a tick on the tap list's Type column asks | `formsAskingTaps`, `app/shared/hole-mode.ts` | -| what is narrowing a list, named for its button | `narrowingNames`, `app/shared/column-filters.ts` | -| the numbers a thread and a depth put on a tap | `tapBounds`, `app/shared/hole-mode.ts` | -| the form behind a Type phrase, and what it asks | `app/shared/tool-type.ts` | -| the list, its answers and its right-click | `app/components/feature-list-panel.tsx` | -| building a group | `app/components/group-editor.tsx` | -| the reading, its numbers and its thread | `app/components/selection-panel.tsx` | -| the tool table and its marks | `app/components/part-tool-table.tsx` | +| Question | Module | +| ----------------------------------------------------- | ---------------------------------------------------- | +| what the list holds, its names, ids, storage | `app/shared/feature-list.ts` | +| which key a row's lines reach the bill under | `sheetKeysOf`, same file | +| which of a row's lines one stack of it wrote | `lineId`, `app/shared/setup-sheet.ts` | +| what is on the order list, for both pages | `app/shared/order-list.ts` | +| whether a row has anything ordered, and what to buy | `isIncomplete` / `componentTotals`, same file | +| which of four things the page is being asked | `asked()`, same file | +| the three presses over the part that add a row | `app/components/add-bar.tsx` | +| whether the presses and the rows are drawn at all | `app/shared/part-chrome.ts` | +| where the part is framed, beside the questions | `app/shared/frame-inset.ts` | +| the heading face and the small-capitals label | `app/shared/type.ts` | +| how tall the tool list opens | `TABLE_OPENS_AT`, `components/part-tool-table.tsx` | +| the columns a list opens with, and their order | `TOOL_COLUMNS`, `components/part-tool-table.tsx` | +| the two columns the list turns on for itself | `app/shared/auto-columns.ts` | +| how wide a column is, as a share of the panel | `app/shared/column-width.ts` | +| refitting the tracks a drag froze onto the table | `app/shared/use-fitted-columns.ts` | +| which columns a list shows, in what order, remembered | `app/shared/column-layout.ts` | +| a row's answer, and what it opens to | `app/shared/recommendations.ts` | +| what a press on a row of the order list opens | `pressRow`, `app/routes/part.tsx` | +| whose stacks the tree beside an open box shows | `editedItem` / `treeKey`, same file | +| what the panel offers for the tool it shows | `app/shared/tool-actions.ts` | +| what fills the tool table, and the cache | `app/shared/catalog-matcher.ts` | +| a stored guid turned back into a record | `getTool`/`getHolder`/`getCollet`, `catalog.ts` | +| what overruling the rules offers, per column | `overridableTools`, `app/shared/tool-fit.ts` | +| the note and press a changed filter raises | `OverrideNotice`, `app/components/column-filter.tsx` | +| whether a value is inside a filter's bound | `withinRange`, `app/shared/filter.ts` | +| what a number box takes besides a number | `app/shared/range-entry.ts` | +| which bounds are somebody's own, not the geometry's | `ownBounds`, `app/shared/filter.ts` | +| which columns' rules an emptied box releases | `releasedBounds`, `app/shared/filter.ts` | +| what a narrowed axis says an unticked value brings | `facetCounts`, `app/shared/catalog-matcher.ts` | +| the same work, off the UI thread | `app/client/catalog-matcher.worker.ts` | +| what a click on the part means | `app/shared/part-interaction.ts` | +| which layer one press of Escape or Enter reaches | `app/shared/use-escape.ts` | +| which list the arrows move through, and the focus | `app/shared/arrow-target.ts` | +| where a menu opened off a button stands, and how tall | `app/shared/menu-place.ts` | +| keeping an open menu against its button | `Menu.Popover`, `@toolpath/ui` | +| whether an open filter survives the list under it | `FilterMenu`, `app/components/column-filter.tsx` | +| a feature's assemblies, its slots, its storage | `app/shared/assembly-tree.ts` | +| what narrows what when any part is chosen first | `app/shared/assembly-narrowing.ts` | +| why an offered chuck cannot be built out of the crib | `colletGap`, same file | +| whether the rack shows them at all, and how many | `holdersToOffer`, same file | +| the press that shows them, hidden to begin with | `app/components/no-collet-toggle.tsx` | +| a group's worst case, and whose it is | `app/shared/group-geometry.ts` | +| the material a whole question has to clear | `askedCurve`, same file | +| how far below the holder a stack has to stand | `belowHolder`, `app/shared/drawn-assembly.ts` | +| which slots were filled against the rules | `overrides`, `app/shared/assembly-tree.ts` | +| what a stack offers, and its button's words | `app/shared/assembly-actions.ts` | +| what a shop calls an assembly, and its field | `renameItem` / `renameAssembly`, `name-field.tsx` | +| reading and filtering a holder or a collet | `app/shared/component-columns.ts` | +| which column header asks which filter | `app/shared/column-filters.ts` | +| a tool in one phrase, with its shank or neck | `app/shared/tool-type.ts` | +| which ticks a filter the page set puts on Type | `typesAsking`, `app/shared/tool-type.ts` | +| which of its columns a tap list narrows on | `askOfTapColumn`, `app/shared/column-filters.ts` | +| what a tick on the tap list's Type column asks | `formsAskingTaps`, `app/shared/hole-mode.ts` | +| what is narrowing a list, named for its button | `narrowingNames`, `app/shared/column-filters.ts` | +| the numbers a thread and a depth put on a tap | `tapBounds`, `app/shared/hole-mode.ts` | +| the form behind a Type phrase, and what it asks | `app/shared/tool-type.ts` | +| the list, its answers and its right-click | `app/components/feature-list-panel.tsx` | +| building a group | `app/components/group-editor.tsx` | +| the reading, its numbers and its thread | `app/components/selection-panel.tsx` | +| the tool table and its marks | `app/components/part-tool-table.tsx` | - `docs/` holds planning documents that outlive a single change. `docs/CATALOG-SPEC.md` is the tool catalog specified end to end — how a shop @@ -547,7 +585,10 @@ original. For the catalog: closest meaningful test in the same session. - After meaningful changes, run the relevant checks and report what passed, failed, or was skipped. -- When building UI, if the user is still using `@toolpath/ui`, be sure to always prefer the toolpath UI components and css conventions over raw HTML or other hand authored components if possible. +- **When building UI, reach for `@toolpath/ui` first** — see _Reach for + `@toolpath/ui` first_ under Code Styling. Prefer a kit component over anything + hand-authored, including a popover, a placement rule or a piece of keyboard + handling you could write yourself, and follow its CSS conventions. Before editing: diff --git a/apps/catalog/app/components/app-header.test.tsx b/apps/catalog/app/components/app-header.test.tsx index fefc44c..9d27fb1 100644 --- a/apps/catalog/app/components/app-header.test.tsx +++ b/apps/catalog/app/components/app-header.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { PublicInspectionReport } from '@toolpath/part-contracts' import { forgetPart, rememberPart } from 'shared/part-session' @@ -82,3 +82,34 @@ describe('the tabs', () => { expect(screen.getByRole('link', { name: 'Parts' })).toHaveAttribute('href', '/parts') }) }) + +/** + * **One setting with two states, so the kit's `Toggle`** (Paul, 2026-09-11: + * "the mm/in toggle should use the toggle component from @toolpath/ui"). It was + * two `Chip`s side by side, which is two buttons that happen to be drawn next + * to each other — the kit's control slides an indicator between the two and + * takes the keyboard with it. + * + * The group around it is load-bearing: the kit makes a two-item toggle a + * `role="switch"` and takes no name of its own, so without it the header offers + * a switch that says only "mm". + */ +describe('the unit control', () => { + it('is the kit toggle, named, with the current unit selected', () => { + render() + + const units = screen.getByRole('group', { name: 'Units' }) + expect(within(units).getByRole('switch')).toBeVisible() + expect(within(units).getByRole('button', { name: 'mm' })).toHaveAttribute('data-selected') + expect(within(units).getByRole('button', { name: 'in' })).toHaveAttribute('data-unselected') + }) + + it('asks for the other unit when the other one is pressed', () => { + const onUnit = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'in' })) + + expect(onUnit).toHaveBeenCalledWith('inches') + }) +}) diff --git a/apps/catalog/app/components/app-header.tsx b/apps/catalog/app/components/app-header.tsx index b66b579..f32d17d 100644 --- a/apps/catalog/app/components/app-header.tsx +++ b/apps/catalog/app/components/app-header.tsx @@ -1,6 +1,6 @@ import { NavLink, useNavigate, useParams, useSearchParams } from 'react-router' -import { Badge, IconButton, cn } from '@toolpath/ui' -import { Chip, ChipGroup } from 'components/chip' +import { Badge, IconButton, Toggle, cn } from '@toolpath/ui' +import { Chip } from 'components/chip' import { ToolpathLogo } from 'components/toolpath-logo' import { UNIT_ABBREVIATION, UNIT_SYSTEMS, type UnitSystem } from '@toolpath/tool-support' import { MoonIcon, SunIcon, UploadSimpleIcon } from '@phosphor-icons/react' @@ -78,13 +78,31 @@ export const AppHeader = ({ unit, onUnit, toolCount, onUploadPart }: AppHeaderPr > {theme === 'dark' ?