diff --git a/AGENTS.md b/AGENTS.md
index 77ada26..1511797 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,73 @@ 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` |
+| how wide that panel is, and the handle on its edge | `app/shared/panel-width.ts` |
+| the heading face, the label, a dialog's whole voice | `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` |
+| 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` |
+| 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/shared/menu-place.ts` |
+| 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` |
+| the room a stack leaves, measured once for everyone | `app/shared/assembly-gaps.ts` |
+| which of the three clearance numbers a shop stated | `app/shared/clearance-entry.ts` |
+| the three boxes that state it, under the drawing | `app/components/clearance-entry.tsx` |
+| 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 +590,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/client/catalog-matcher.test.ts b/apps/catalog/app/client/catalog-matcher.test.ts
index 0677689..45d978f 100644
--- a/apps/catalog/app/client/catalog-matcher.test.ts
+++ b/apps/catalog/app/client/catalog-matcher.test.ts
@@ -33,7 +33,7 @@ const context: MatchContext = {
margins: { radial: 0, axial: 0 },
thresholds: thresholdsFrom(),
overrides: [],
- ownRanges: {},
+ suggestedRanges: {},
}
afterEach(() => {
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..71b2490 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'
@@ -40,7 +40,7 @@ export const AppHeader = ({ unit, onUnit, toolCount, onUploadPart }: AppHeaderPr
-
Toolpath Tool Catalog
+
Toolpath Catalog
{toolCount} tools
{/* A new part is always one press away. When another part is already
loaded, return to its viewer and open the uploader there rather than
@@ -78,13 +78,31 @@ export const AppHeader = ({ unit, onUnit, toolCount, onUploadPart }: AppHeaderPr
>
{theme === 'dark' ? : }
-
- {UNIT_SYSTEMS.map((each) => (
- onUnit(each)}>
- {UNIT_ABBREVIATION[each]}
-
- ))}
-
+ {/*
+ **The kit's `Toggle`, not two chips** (Paul, 2026-09-11). Millimetres
+ or inches is one setting with two states, which is the control the kit
+ exports for exactly this — it slides an indicator between them and
+ handles the keyboard, where a pair of chips is two buttons that happen
+ to be drawn next to each other.
+
+ Labelled by the group around it, because the kit makes a two-item
+ toggle a `role="switch"` and takes no name of its own: without it the
+ header offers a switch that says only "mm".
+ */}
+
>
diff --git a/apps/catalog/app/components/assembly-tree-panel.tsx b/apps/catalog/app/components/assembly-tree-panel.tsx
index 65acfc6..532a8ff 100644
--- a/apps/catalog/app/components/assembly-tree-panel.tsx
+++ b/apps/catalog/app/components/assembly-tree-panel.tsx
@@ -19,7 +19,7 @@ import {
type TreeNode,
} from 'shared/assembly-tree'
import { NameField } from './name-field'
-import { SECTION_LABEL } from 'shared/type'
+import { DIALOG_EMPTY, DIALOG_NOTE, DIALOG_VALUE, SECTION_LABEL } from 'shared/type'
/**
* The stacks a feature is answered with, as a tree beside the tool table.
@@ -157,7 +157,7 @@ const SlotRow = ({
}) => (
@@ -168,7 +168,7 @@ const SlotRow = ({
aria-current={selected ? 'true' : undefined}
aria-label={`${slotLabel(assembly, slot)} for ${assembly.id}`}
onClick={onSelect}
- className="flex min-w-0 flex-1 items-center justify-start gap-2 border-0 bg-transparent px-0 py-0 text-xs hover:bg-transparent"
+ className="flex min-w-0 flex-1 items-center justify-start gap-2 border-0 bg-transparent px-0 py-0 hover:bg-transparent"
>
{/*
Filled or not, in one glyph. A slot nobody has answered is the question
@@ -183,18 +183,15 @@ const SlotRow = ({
)}
/>
{/*
- The row that heads a stack is the tool, and it reads as the head: the
- holding under it is what it is held by, not two more things of the same
- rank (Paul, 2026-09-08).
+ **Every slot name is the one section label** (Paul, 2026-09-11). The
+ tool's used to be a half-step brighter than the holding's, to say that
+ the row heading a stack is the tool and the two under it are what it is
+ held by (Paul, 2026-09-08) — but the indent and the rule down the left of
+ `StackRows` were added the same day for exactly that, and say it without
+ spending a grey on it. Two inks meaning one thing is what this pass took
+ out of these boxes.
*/}
-
- {slotLabel(assembly, slot)}
-
+ {slotLabel(assembly, slot)}
{/*
**A change shows on the row it is a change to** (Paul, 2026-09-07: "when
I make changes, they should show in the respective component rows (like
@@ -204,7 +201,7 @@ const SlotRow = ({
*/}
{ordered === null ? null : (
<>
-
+
{ordered}
@@ -214,8 +211,9 @@ const SlotRow = ({
)}
@@ -256,7 +254,7 @@ const SlotRow = ({
*/}
{shared === null ? null : (
{shared}
@@ -444,12 +442,13 @@ export const AssemblyTreePanel = ({
title="Rename this assembly"
onClick={() => setNaming(group.root.id)}
className={cn(
+ SECTION_LABEL,
// `w-full` so the box inside the button is the width of the
// button rather than of the name — where the ellipsis happens.
- 'text-2xs w-full min-w-0 flex-1 truncate text-left font-semibold tracking-wide text-zinc-400',
+ 'w-full min-w-0 flex-1 truncate text-left',
/* A name is somebody's words, so it is left as typed; a
number is a heading, and headings here are upper case. */
- group.root.name === undefined ? 'uppercase' : '',
+ group.root.name === undefined ? '' : 'normal-case',
)}
>
{/*
@@ -560,7 +559,7 @@ export const AssemblyTreePanel = ({
}}
/* Drawn as a slot row is drawn — same height, same padding,
same hover — so it sits in the list rather than on it. */
- className="flex w-full items-center gap-2 rounded border-0 bg-transparent px-2 py-1 text-left text-xs text-zinc-500 hover:bg-zinc-900 hover:text-zinc-300"
+ className="flex w-full items-center gap-2 rounded border-0 bg-transparent px-2 py-1 text-left hover:bg-zinc-900"
full
>
{/* Centred where the slot rows wear their dot, and a size up
@@ -572,7 +571,7 @@ export const AssemblyTreePanel = ({
>
- Add assembly
+ Add assembly
)}
))}
diff --git a/apps/catalog/app/components/catalog-drawing.test.tsx b/apps/catalog/app/components/catalog-drawing.test.tsx
index e594d5f..0f9ace4 100644
--- a/apps/catalog/app/components/catalog-drawing.test.tsx
+++ b/apps/catalog/app/components/catalog-drawing.test.tsx
@@ -3,7 +3,6 @@ import { act, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Assembly, CatalogTool, Collet, Holder } from '@toolpath/catalog-data'
import { assemblyOutline } from '@toolpath/tool-drawing/geometry'
-import { formatLength } from '@toolpath/tool-support'
import { toViewerAssembly } from 'shared/tool-drawing-input'
import { CatalogDrawing, MATERIAL_ROOM } from './catalog-drawing'
@@ -148,19 +147,26 @@ describe('the catalog drawing', () => {
})
/**
- * The drawing stopped writing its own figures in `@toolpath/tool-drawing`
- * 0.2.0 — it draws the lines and the panel's table carries the numbers — so
- * the unit no longer reaches it through a dimension. It still reaches the
- * sentences the package prints and does not compose: the clearance gaps,
- * which is why this asks for a stack and a feature rather than a bare tool.
+ * **The sheet carries no number of its own at all**, as of 2026-09-11.
+ *
+ * It stopped writing its figures in `@toolpath/tool-drawing` 0.2.0 — the
+ * lines are drawn and the panel's table has the numbers — and the last thing
+ * left that did was the clearance sentence under the verdict, which the three
+ * boxes under the sheet replaced. So the unit no longer reaches the drawing
+ * through anything, which is worth pinning in the direction it now runs: a
+ * unit in the sheet means a number has come back onto it.
+ *
+ * The invariant itself did not go anywhere. `clearance-entry.test.tsx` is
+ * where "the page's unit, because the package owns none" lives now.
*/
- it('writes the clearance in the unit the page is set to, because the package owns no unit', () => {
+ it('writes no number and so no unit on the sheet, whatever the page is set to', () => {
const container = drawn(
,
)
- expect(container.textContent).toMatch(/0\.276 in/)
+ expect(container.textContent).not.toMatch(/\bin\b/)
expect(container.textContent).not.toMatch(/\bmm\b/)
+ expect(container.textContent).not.toMatch(/tightest/)
})
/**
@@ -180,7 +186,16 @@ describe('the catalog drawing', () => {
expect(none.querySelectorAll('[data-lit="true"]')).toHaveLength(0)
})
- it('draws the material and the verdict this application reached, not one of its own', () => {
+ /**
+ * **The material and the collisions, and no verdict written over them.**
+ *
+ * The collisions are still this application's own — the package paints what
+ * it is handed rather than deciding anything — and they are what says a stack
+ * fouls, in the place it fouls. The sentence over the drawing went on
+ * 2026-09-11; `catalog-drawing.tsx`'s own note has the reading that made it
+ * a correctness matter rather than a layout one.
+ */
+ it('draws the material this application swept, and writes no verdict over it', () => {
const container = drawn(
{
expect(container.querySelector('[data-clearance]')).not.toBeNull()
expect(container.querySelector('[data-part="material"]')).not.toBeNull()
- expect(container.querySelector('[data-verdict]')).not.toBeNull()
+ expect(container.querySelector('[data-verdict]')).toBeNull()
})
it('says an undrawable form in words rather than drawing a plausible cylinder', () => {
@@ -264,33 +279,34 @@ describe('the overlay this application draws', () => {
* there is nothing to letter. Both halves matter: the leaders gone, and the
* sentence still carrying the number.
*/
- it('letters neither gap on the sheet, and still says both in the caption', () => {
+ it('letters neither gap on the sheet, and no longer says them under it either', () => {
const container = drawn(
,
)
expect(container.querySelectorAll('[data-clearance-dimension]')).toHaveLength(0)
- // The material it would have been lettered against is still drawn.
+ // The material both readouts were about is still drawn.
expect(container.querySelector('[data-part="material"]')).not.toBeNull()
- expect(container.textContent).toMatch(/tightest: .*(above|into) the wall at the/)
+ expect(container.textContent).not.toMatch(/tightest/)
})
/**
- * **A verdict with no length on it is a verdict about nothing in
- * particular** (Paul, 2026-09-08: "it's not clear what length below holder
- * this applies to"). The same stack clears at one stickout and fouls at
- * another, so the sentence under "clears the part" has to name the one it
- * was reached at — and it is the length the sheet is drawn at and the list's
- * column prints, not a third number.
+ * **Nothing is written over the drawing** (Paul, 2026-09-11, pointing at the
+ * verdict line: "remove the stuff outlined in red").
+ *
+ * The sentence named the length the verdict was reached at; that length is an
+ * editable box now. The verdict over it said "clears the part" from a sweep
+ * that, on a holder publishing no parametric dimensions, had checked the
+ * tool's shank and nothing else — so it was a claim the data did not support,
+ * printed in the same words as one that did.
*/
- it('says the length below the holder the verdict was reached at', () => {
+ it('writes neither a verdict nor a sentence over the sheet', () => {
const container = drawn(
,
)
- expect(container.textContent).toContain(
- `at ${formatLength(assembly.stickout ?? 0, 'millimeters')} below the holder`,
- )
+ expect(container.textContent).not.toMatch(/clears|collides/)
+ expect(container.textContent).not.toContain('below the holder')
})
it('draws the tool alone when there is no feature to clear', () => {
diff --git a/apps/catalog/app/components/catalog-drawing.tsx b/apps/catalog/app/components/catalog-drawing.tsx
index 1ac09df..a73b535 100644
--- a/apps/catalog/app/components/catalog-drawing.tsx
+++ b/apps/catalog/app/components/catalog-drawing.tsx
@@ -19,13 +19,7 @@ import {
type ViewerAssembly,
type Zoom,
} from '@toolpath/tool-drawing'
-import { assemblyOutline } from '@toolpath/tool-drawing/geometry'
-import {
- ClearanceOverlay,
- describeGaps,
- tightestGaps,
- type Gaps,
-} from '@toolpath/tool-drawing/clearance'
+import { ClearanceOverlay, type Gaps } from '@toolpath/tool-drawing/clearance'
import { useEffect, useRef } from 'react'
import { assemblyLabel } from 'shared/assemblies'
import {
@@ -34,8 +28,7 @@ import {
type ClearanceCase,
type ClearanceDebugInput,
} from 'shared/clearance-debug'
-import { getProfile } from 'shared/catalog'
-import { toViewerAssembly } from 'shared/tool-drawing-input'
+import { cuttingRadiusOf, gapsFor, viewerFor } from 'shared/assembly-gaps'
import { useTheme } from 'shared/use-theme'
/**
@@ -177,32 +170,27 @@ export interface CatalogDrawingProps {
}
/**
- * The verdict's sentence, and **the length below the holder it is about**.
+ * **Nothing is written over the drawing any more** (Paul, 2026-09-11).
+ *
+ * Two things used to be. The sentence went first: "at 47.00 mm below the
+ * holder · tightest: 24.80 mm into the wall at the shank — 0.51 mm up and
+ * 0.51 mm sideways wanted", five lines saying what the three clearance boxes
+ * under the sheet now say in three, and say editably.
*
- * The package writes "clears the part"; this writes what follows it. It used
- * to be the two tightest gaps alone, which left the reading unanswerable
- * (Paul, 2026-09-08: "it's not clear what length below holder this applies
- * to"): a stack clears at one stickout and fouls at another, so a verdict with
- * no length on it is a verdict about nothing in particular. The number is the
- * one the sheet is drawn at and the one the list's column now prints — the
- * same `drawnAssembly` stickout in all three places.
+ * The verdict over it went with it, and that one was not only a layout call.
+ * "clears the part" is reached by `clearance()`, which sweeps the holder's
+ * *parametric* nose, body and flange — and most holders publish none of those.
+ * Asked about `BT30-ER11-110DT` + `11ERSS0250` + `V2160617` it answers
+ * `clears: true` with `checked: ["shank"]`: a pass for the tool's own shank and
+ * nothing whatever about what holds it, printed in the same words as a pass
+ * that checked a whole stack. The clearance boxes read the measured silhouette
+ * instead, so they answer the same question about the holder that is actually
+ * drawn — and a gap that has gone into the material reads as a negative number
+ * there, which is the honest version of the same warning.
*
- * On a tool drawn alone there is no holder and so no such length, and the
- * sentence is the gaps by themselves as before.
+ * The collisions are still handed down, so the part that fouls is still painted
+ * on the drawing. That is the verdict in the place it is about.
*/
-const verdictNote = (
- stickout: number | null,
- gaps: Gaps | null,
- margins: Margins,
- format: (millimetres: number) => string,
-): string | null => {
- const said = gaps === null ? null : describeGaps(gaps, margins, format)
- if (stickout === null) {
- return said
- }
- const at = `at ${format(stickout)} below the holder`
- return said === null ? at : `${at} · ${said}`
-}
/**
* What the clearance wall was drawn from, in the console, while it is wrong.
@@ -306,15 +294,7 @@ export const CatalogDrawing = ({
const [theme] = useTheme()
const format = (millimetres: number) => formatLength(millimetres, unit)
const holder = assembly?.holder ?? null
- const holderProfile = measured && holder !== null ? getProfile(holder.guid) : null
- const viewer = toViewerAssembly(
- {
- tool,
- holder,
- stickout: assembly?.stickout ?? null,
- },
- holderProfile,
- )
+ const viewer = viewerFor({ tool, holder, stickout: assembly?.stickout ?? null }, measured)
const caption = assembly === null ? tool.catalogNumber : assemblyLabel(assembly)
/**
@@ -324,7 +304,6 @@ export const CatalogDrawing = ({
* answer this application's own engine already gave, so the number under the
* drawing is the number the tool list sorted on.
*/
- const outline = curve === null ? null : assemblyOutline(viewer)
const verdict = curve !== null && assembly !== null ? clearance(assembly, curve, margins) : null
/**
* The flank the material stands beside, and `null` where the tool has none.
@@ -335,30 +314,30 @@ export const CatalogDrawing = ({
* tool stating no cutting diameter, and the guard beside it —
* `DC !== undefined` — let one through: a `DC` of `null` or `0` is not
* `undefined`, so the wall was drawn, from `r = 0`, straight through the
- * tool it was supposed to stand clear of (Paul, 2026-09-11). The hatch
- * covered the tool's whole `+r` flank and the drawing said the cutter was
- * buried in the part.
- *
- * `typeof` rather than `!== undefined`, because the type says
- * `Record` and a catalog built from a vendor that published
- * no cutting diameter carries `null` at runtime — which is the case the old
- * guard was written for and the one it missed.
+ * tool it was supposed to stand clear of (Paul, 2026-09-11).
*
- * A tool with no cutting diameter has no flank, so there is nothing to
- * measure a gap from either: `gaps` goes with it, `overlaid` turns off, and
- * the sheet is the tool on its own. That is the honest picture — the
- * alternative is a wall drawn from a radius nobody stated.
+ * The guard is `cuttingRadiusOf` rather than this expression, because the
+ * three boxes under this sheet measure off the same radius: a fix applied
+ * to the drawing alone would leave a box reading a gap taken from `r = 0`
+ * while the picture beside it had stopped drawing one.
*/
- const stated = tool.geometry.DC
- const cuttingRadius = typeof stated === 'number' && stated > 0 ? stated / 2 : null
+ const cuttingRadius = cuttingRadiusOf(tool)
const profile =
curve !== null && cuttingRadius !== null ? materialProfile(curve, cuttingRadius) : null
- const gaps =
- curve !== null && outline !== null && cuttingRadius !== null
- ? tightestGaps(outline.segments, curve, cuttingRadius, margins)
- : null
+ /**
+ * The gaps, measured where every other reader of them measures them.
+ *
+ * `shared/assembly-gaps` and not an outline of its own: the three boxes under
+ * this sheet show the same two numbers the caption below writes out, and two
+ * measurements of one gap is the divergence-with-a-delay this repository has
+ * paid for once already.
+ *
+ * A tool with no flank has nothing to measure from, so the gaps go with it,
+ * `overlaid` turns off, and the sheet is the tool on its own.
+ */
+ const gaps = gapsFor(viewer, curve, cuttingRadius, margins)
- const overlaid = profile !== null && gaps !== null && outline !== null
+ const overlaid = profile !== null && gaps !== null
const padding: Partial = overlaid ? { plus: MATERIAL_ROOM } : {}
return (
@@ -373,21 +352,10 @@ export const CatalogDrawing = ({
{...(onDimensionHover === undefined ? {} : { onDimensionHover })}
padding={padding}
collisions={verdict?.collisions}
- verdict={
- verdict === null
- ? null
- : {
- clears: verdict.clears,
- note: verdictNote(viewer.stickout, gaps, margins, format),
- }
- }
+ verdict={null}
className="size-full"
>
- {overlaid &&
- profile !== null &&
- gaps !== null &&
- outline !== null &&
- cuttingRadius !== null ? (
+ {overlaid && profile !== null && gaps !== null && cuttingRadius !== null ? (
<>
,
+ room: { readonly axial: number; readonly radial: number },
+): ClearanceBoxes => ({
+ below: { entered: null, value: 20, clamped: false, overLimit: false, ...below },
+ axial: {
+ entered: null,
+ value: room.axial,
+ asked: 0.508,
+ short: room.axial < 0.508,
+ held: null,
+ },
+ radial: {
+ entered: null,
+ value: room.radial,
+ asked: 0.508,
+ short: room.radial < 0.508,
+ held: null,
+ },
+})
+
+/** The three boxes are folded away until somebody opens them. */
+const open = () => fireEvent.click(screen.getByRole('button', { name: /Clearance/ }))
+
+describe('the line under the three boxes', () => {
+ it('names the length to set instead of the clearance that came up short', () => {
+ render(
+ {}}
+ />,
+ )
+ open()
+
+ expect(screen.getByText('collision at this stickout. Increase to 0.945 in')).toBeInTheDocument()
+ expect(screen.queryByText(/under the/)).not.toBeInTheDocument()
+ })
+
+ /** Telling somebody to increase to a length that still fouls would be worse. */
+ it('says so plainly where no length this tool takes clears', () => {
+ render(
+ {}}
+ />,
+ )
+ open()
+
+ expect(
+ screen.getByText('collision at this stickout, and no length this tool can be set to clears'),
+ ).toBeInTheDocument()
+ })
+
+ /**
+ * A stated clearance is a different question — it drives the length, and the
+ * box that came up short is the *other* axis, which the length cannot answer.
+ */
+ it('leaves a short axis to say so where the length was not the entry', () => {
+ render(
+ {}}
+ />,
+ )
+ open()
+
+ expect(screen.getByText('under the 0.020 in wanted')).toBeInTheDocument()
+ })
+})
diff --git a/apps/catalog/app/components/clearance-entry.tsx b/apps/catalog/app/components/clearance-entry.tsx
new file mode 100644
index 0000000..82c60be
--- /dev/null
+++ b/apps/catalog/app/components/clearance-entry.tsx
@@ -0,0 +1,364 @@
+import { useState } from 'react'
+import {
+ CaretDownIcon,
+ CaretRightIcon,
+ PushPinIcon,
+ RulerIcon,
+ WarningIcon,
+} from '@phosphor-icons/react'
+import { Button, Input, cn } from '@toolpath/ui'
+import { convertLength, decimalsFor, type UnitSystem } from '@toolpath/tool-support'
+import {
+ shownIn,
+ type ClearanceBox,
+ type ClearanceBoxes,
+ type ClearanceEdit,
+ type ClearanceField,
+} from 'shared/clearance-entry'
+import { readEntry } from 'shared/range-entry'
+import { MeasurementIcon } from './feature-icons'
+import { SECTION_LABEL } from 'shared/type'
+
+/**
+ * The three numbers under the drawing, any one of which can be the stated one.
+ *
+ * **What this replaces is a sentence** — "at 0.625 in below the holder ·
+ * 0.135 in above the wall at the body" — which said all three numbers and let a
+ * shop set none of them. The clearances were the sheet's knobs, 0.020 in, with
+ * no control anywhere on the page; the length was whatever those knobs made
+ * necessary. Paul, 2026-09-11: the three decide each other, so state whichever
+ * one you know and read the other two off the stack it produces.
+ *
+ * `shared/clearance-entry.ts` is the whole of the rule — which direction an
+ * edit solves in, and what each box then says. This is the typing: a draft per
+ * box, the page's unit going in and coming out, and the caption under each box
+ * naming which of the three roles its number is playing.
+ *
+ * **It sits on `@toolpath/tool-drawing` rather than inside it.** The drawing
+ * package draws a stack and the clearance around it; deciding a stack is the
+ * application's, and the production application is going to want the same
+ * sheet under a different set of controls.
+ */
+
+/** The three, in the order they are read: the length, then the room it buys. */
+const FIELDS: ReadonlyArray = ['below', 'axial', 'radial']
+
+export interface ClearanceEntryProps {
+ readonly boxes: ClearanceBoxes
+ readonly unit: UnitSystem
+ readonly edit: ClearanceEdit | null
+ /**
+ * The shortest length that would clear, for a stated one that does not.
+ *
+ * `clearingLength` in `shared/clearance-entry.ts` is the number; null both
+ * where nothing is colliding and where no length this tool can be set to
+ * clears, which the line below tells apart by what it has to say.
+ */
+ readonly clearsAt: number | null
+ readonly onEdit: (edit: ClearanceEdit | null) => void
+}
+
+/** The words over each box, and what an empty one is measuring. */
+const LABEL: Record = {
+ below: 'Below holder',
+ axial: 'Axial',
+ radial: 'Radial',
+}
+
+/**
+ * The drawing for each of the three, folded or open.
+ *
+ * `LBH` is the panel's own icon for the length below the holder, so the folded
+ * row and the table of numbers under it name that measurement the same way; the
+ * two clearances are drawn beside it in `feature-icons.tsx` rather than picked
+ * out of an icon set, for the same reason.
+ */
+const MEASURES: Record = {
+ below: 'LBH',
+ axial: 'axialClearance',
+ radial: 'radialClearance',
+}
+
+const TITLE: Record = {
+ below: 'How far the tool stands out of the holder. State it, or read what the clearances need.',
+ axial: 'Room between the holder nose and the material above the cut.',
+ radial: 'Room between the stack and a wall standing taller than the cut.',
+}
+
+/** A number in the unit being read in, bare, because the box is labelled. */
+const draftOf = (millimetres: number | null, unit: UnitSystem): string =>
+ millimetres === null
+ ? ''
+ : convertLength(millimetres, 'millimeters', unit).toFixed(decimalsFor(unit))
+
+/**
+ * What one box is, said with an icon in the field rather than a line under it.
+ *
+ * **The words came out** (Paul, 2026-09-11: "get rid of the lines of text below
+ * and use icons in the text entry area to show if calculated or entered"). A
+ * caption under each of three boxes was three sentences in a third of a panel
+ * each — "what clearing needs", "measured", "under 0.51 mm" — which wrapped,
+ * pushed the sheet up, and said in six words what a pin says. The words are the
+ * field's `title` now, so the reading is still there for anybody who wants it.
+ *
+ * Three states, three marks: a pin for the number a shop stated, a rule for one
+ * the app worked out, and a warning for one that does not meet what it is being
+ * held to. Only the last is coloured, by the kit's own `invalid`, so it says the
+ * same thing in either theme.
+ */
+interface Mark {
+ readonly icon: typeof RulerIcon
+ /** What it would have said in words, which is what the field is titled with. */
+ readonly said: string
+ readonly amiss: boolean
+}
+
+const markForBelow = (
+ boxes: ClearanceBoxes,
+ edit: ClearanceEdit | null,
+ clearsAt: number | null,
+ say: (millimetres: number | null) => string,
+): Mark => {
+ const { below } = boxes
+ if (below.overLimit) {
+ return { icon: WarningIcon, said: 'past this tool’s limit', amiss: true }
+ }
+ if (below.clamped) {
+ return {
+ icon: WarningIcon,
+ said: `this tool cannot be set there — it holds at ${say(below.value)}`,
+ amiss: true,
+ }
+ }
+ /*
+ **A stated length that fouls the part is the length box's warning, and it
+ names the length to type** (Paul, 2026-09-11: "the messaging when I enter a
+ stickout too short isn't right — it says under 0.02 — it should say
+ collision at this stickout. Increase to ").
+
+ What was said instead came off the axial box — "under the 0.02 in wanted" —
+ which is the sheet's own figure read back at somebody who had just been told
+ the stack collides, on a box they had not touched. The clearances are short
+ *because* the length is, so the length is what has something to say, and the
+ line under the row takes the first field that is amiss: `below` is first.
+ */
+ if (below.entered !== null && (boxes.axial.short || boxes.radial.short)) {
+ return {
+ icon: WarningIcon,
+ said:
+ clearsAt === null
+ ? 'collision at this stickout, and no length this tool can be set to clears'
+ : `collision at this stickout. Increase to ${say(clearsAt)}`,
+ amiss: true,
+ }
+ }
+ if (below.entered !== null) {
+ return { icon: PushPinIcon, said: 'set here', amiss: false }
+ }
+ if (edit?.field === 'axial' || edit?.field === 'radial') {
+ return { icon: RulerIcon, said: 'the least that leaves that room', amiss: false }
+ }
+ return { icon: RulerIcon, said: 'what clearing the part needs', amiss: false }
+}
+
+const markFor = (box: ClearanceBox, say: (millimetres: number | null) => string): Mark => {
+ /*
+ The entry stands and the stack could not meet it. Which way it missed is
+ worth saying, because the two have different answers: more room than asked
+ means the tool is already as short as it goes, and less means nothing this
+ stack can be set to clears by that much.
+ */
+ if (box.held === 'more') {
+ return {
+ icon: WarningIcon,
+ said: `this tool cannot be set shorter — it leaves ${say(box.value)}`,
+ amiss: true,
+ }
+ }
+ if (box.held === 'less') {
+ return {
+ icon: WarningIcon,
+ said: `this stack cannot clear by that much — it leaves ${say(box.value)}`,
+ amiss: true,
+ }
+ }
+ if (box.value === null) {
+ return { icon: RulerIcon, said: 'nothing stands taller', amiss: false }
+ }
+ if (box.short) {
+ return { icon: WarningIcon, said: `under the ${say(box.asked)} wanted`, amiss: true }
+ }
+ if (box.entered !== null) {
+ return { icon: PushPinIcon, said: 'set here', amiss: false }
+ }
+ return { icon: RulerIcon, said: 'measured at this length', amiss: false }
+}
+
+export const ClearanceEntry = ({ boxes, unit, edit, clearsAt, onEdit }: ClearanceEntryProps) => {
+ /**
+ * The box being typed in, and nothing else.
+ *
+ * One draft rather than three kept in step: every other box is showing a
+ * number the stack just worked out, and a state of its own would only be a
+ * chance for it to show a stale one. The draft goes the moment it commits.
+ */
+ const [draft, setDraft] = useState<{
+ readonly field: ClearanceField
+ readonly text: string
+ } | null>(null)
+ const say = (millimetres: number | null) =>
+ millimetres === null ? '—' : `${draftOf(millimetres, unit)} ${unit === 'inches' ? 'in' : 'mm'}`
+
+ const commit = (field: ClearanceField, raw: string) => {
+ setDraft(null)
+ const text = raw.trim()
+ if (text === '') {
+ onEdit(null)
+ return
+ }
+ const value = readEntry(text, 'min', unit, 'length').min
+ if (value === undefined) {
+ return
+ }
+ onEdit({ field, value })
+ }
+
+ const box = (field: ClearanceField, value: number | null, mark: Mark) => (
+
+ )
+
+ /**
+ * **Folded to begin with** (Paul, 2026-09-11).
+ *
+ * The panel is a column with a sheet in it, and most of the time a shop is
+ * reading tools rather than setting one up — so the room goes to the drawing
+ * until somebody asks for it. Folded, the three numbers stay on the header
+ * with the pin if one of them was stated: a fold that hides the answer is a
+ * fold nobody opens twice.
+ */
+ const [open, setOpen] = useState(false)
+
+ const marks = {
+ below: markForBelow(boxes, edit, clearsAt, say),
+ axial: markFor(boxes.axial, say),
+ radial: markFor(boxes.radial, say),
+ }
+ /** The first of the three with something wrong, which is what gets the line. */
+ const amiss = FIELDS.map((field) => marks[field]).find((mark) => mark.amiss)
+
+ return (
+
+ {/*
+ **A warning gets words, and only a warning does** (Paul, 2026-09-11: "I
+ don't think it's reading out the messaging").
+
+ The captions under every box came out because three sentences in a third
+ of a panel each is noise on a row that is usually just telling you three
+ numbers. A stack that cannot be set where it was asked is the other case
+ — the one moment the row has something to say an icon cannot — and
+ putting it in a `title` meant it was said only to whoever thought to
+ hover. One line under the row, for the first thing that is amiss.
+ */}
+ {open && amiss !== undefined ? (
+
{amiss.said}
+ ) : null}
+ {/*
+ The way back to the app's own answer. Emptying the box that was typed
+ into does the same thing, and this is for the shop that has typed in two
+ of them in turn and wants the stack it started with.
+ */}
+ {edit === null || !open ? null : (
+
+ )}
+
+ )
+}
diff --git a/apps/catalog/app/components/column-filter.test.tsx b/apps/catalog/app/components/column-filter.test.tsx
index 39f0922..8667177 100644
--- a/apps/catalog/app/components/column-filter.test.tsx
+++ b/apps/catalog/app/components/column-filter.test.tsx
@@ -10,7 +10,6 @@ import {
RangeFilter,
TermFilter,
compareOf,
- menuRoom,
optionsMatching,
type Bound,
type Kind,
@@ -230,29 +229,78 @@ describe('the shape a stored bound has', () => {
})
})
-/**
- * How tall a box opened off a button is, and which way it opens.
- *
- * One rule for the filter menus and the column picker both: the picker ran off
- * the bottom of the screen with its last columns unreachable (Paul,
- * 2026-09-10), which is the defect the Type filter had a day earlier.
- */
-describe('the room a menu opens into', () => {
- it('takes the room under the button and opens downwards', () => {
- expect(menuRoom({ top: 100, bottom: 130 }, 900)).toEqual({ upwards: false, height: 758 })
+describe('the column picker', () => {
+ /**
+ * **The drag says where the row will land before it lands** (Paul,
+ * 2026-09-11: "the list should show a blue line (2px horizontal) where the
+ * item will be dropped to help the user see where it will go").
+ *
+ * Which edge is `shared/column-order.ts` § `dropEdge`, and its own tests tie
+ * that to where `movedTo` actually puts the row. What this pins is the wire:
+ * that a drag over a row draws the line, on the edge the rule names, on that
+ * row and no other — and that letting go anywhere puts it away.
+ */
+ const picker = (order: ReadonlyArray, onReorder = vi.fn()) => {
+ render(
+ ({ code, label: code }))}
+ shown={[...order]}
+ onToggle={vi.fn()}
+ onReorder={onReorder}
+ />,
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'Which columns to show' }))
+ const rows = screen.getByRole('group', { name: 'Columns' }).children
+ return {
+ rows,
+ lines: () => Array.from(document.querySelectorAll('[data-drop-edge]')),
+ grab: (code: string) =>
+ fireEvent.dragStart(screen.getByRole('button', { name: `Move ${code}` })),
+ release: (code: string) =>
+ fireEvent.dragEnd(screen.getByRole('button', { name: `Move ${code}` })),
+ }
+ }
+
+ it('draws one line, on the row under the pointer, while a column is dragged', () => {
+ const { rows, lines, grab } = picker(['a', 'b', 'c', 'd'])
+
+ expect(lines()).toHaveLength(0)
+
+ grab('a')
+ fireEvent.dragOver(rows[2]!)
+
+ expect(lines()).toHaveLength(1)
+ expect(rows[2]!.querySelector('[data-drop-edge]')).not.toBeNull()
})
- it('opens upwards where what is left under the button is a strip', () => {
- expect(menuRoom({ top: 700, bottom: 730 }, 900)).toEqual({ upwards: true, height: 688 })
+ /** Down lands after the row, up lands before it — `movedTo` decides which. */
+ it('puts the line under the row dragging down and over it dragging up', () => {
+ const { rows, lines, grab, release } = picker(['a', 'b', 'c', 'd'])
+
+ grab('a')
+ fireEvent.dragOver(rows[2]!)
+ expect(lines()[0]).toHaveAttribute('data-drop-edge', 'below')
+
+ release('a')
+ grab('d')
+ fireEvent.dragOver(rows[1]!)
+ expect(lines()[0]).toHaveAttribute('data-drop-edge', 'above')
})
- /** A strip above and a strip below still opens downwards, and overhangs. */
- it('never squeezes itself below the least height worth reading', () => {
- expect(menuRoom({ top: 40, bottom: 70 }, 200)).toEqual({ upwards: false, height: 220 })
+ it('draws nothing over the row being dragged, and nothing once it is let go', () => {
+ const { rows, lines, grab, release } = picker(['a', 'b', 'c'])
+
+ grab('b')
+ fireEvent.dragOver(rows[1]!)
+ expect(lines()).toHaveLength(0)
+
+ fireEvent.dragOver(rows[0]!)
+ expect(lines()).toHaveLength(1)
+
+ release('b')
+ expect(lines()).toHaveLength(0)
})
-})
-describe('the column picker', () => {
it('keeps the pencil at the table header touch target size', () => {
render(
{
/**
* The list scrolls inside the room the screen leaves it rather than running
* off the bottom of the page with its last columns out of reach.
+ *
+ * `--available-height` is the kit menu's own measurement of what its
+ * positioner found, which is what replaced a height this component used to
+ * work out for itself (Paul, 2026-09-11: "why aren't these menus just using
+ * the menu component from @toolpath/ui?"). A class rather than an inline
+ * style, because the number is the positioner's to write.
*/
it('scrolls inside a height the screen bounds', () => {
render(
@@ -282,7 +336,59 @@ describe('the column picker', () => {
const list = screen.getByRole('group', { name: 'Columns' })
expect(list).toHaveClass('overflow-y-auto')
- expect(list.style.maxHeight).not.toBe('')
+ expect(list).toHaveClass('max-h-[var(--available-height)]')
+ })
+
+ /**
+ * **The box is the kit's, and so is the way out of it.** A picker that drew
+ * its own absolutely-positioned box was cut off by the card it stood in, and
+ * every part of the answer — the portal, the placing, Escape, a press
+ * outside — is what `Menu.Popover` is.
+ */
+ it('opens the kit menu rather than a box of its own', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Which columns to show' }))
+
+ expect(
+ screen.getByRole('group', { name: 'Columns' }).closest('[data-base-ui-portal]'),
+ ).not.toBe(null)
+ })
+
+ /**
+ * The pencil stands in the bar floating at the bottom of the viewer, which is
+ * inside a panel that clips: a list positioned inside that box opened
+ * downwards into the table and was cut off at the panel's edge (Paul,
+ * 2026-09-11). It is drawn on the page instead.
+ */
+ it('draws the list on the page rather than inside the clipping panel', () => {
+ const { container } = render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Which columns to show' }))
+
+ const list = screen.getByRole('group', { name: 'Columns' })
+ expect(container.contains(list)).toBe(false)
+ /*
+ The bound, rather than `fixed`. The placing is `Menu.Popover`'s as of
+ brad/table — a portal against its trigger, bounded by the window — so the
+ positioning class this used to read is the kit's business now. What is
+ still this application's, and still the thing Paul asked for, is that the
+ list scrolls inside the room the screen left rather than running off the
+ bottom with its last columns out of reach.
+ */
+ expect(list).toHaveClass('max-h-[var(--available-height)]', 'overflow-y-auto')
})
})
diff --git a/apps/catalog/app/components/column-filter.tsx b/apps/catalog/app/components/column-filter.tsx
index c9bb990..e52ff96 100644
--- a/apps/catalog/app/components/column-filter.tsx
+++ b/apps/catalog/app/components/column-filter.tsx
@@ -1,4 +1,4 @@
-import { Button, Checkbox, IconButton, Input, cn } from '@toolpath/ui'
+import { Button, Checkbox, IconButton, Input, Menu, cn } from '@toolpath/ui'
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react'
import { createPortal } from 'react-dom'
import {
@@ -15,7 +15,8 @@ import {
convertLength,
decimalsFor,
} from '@toolpath/tool-support'
-import { movedBy, movedTo } from 'shared/column-order'
+import { dropEdge, movedBy, movedTo } from 'shared/column-order'
+import { MENU_GAP, MENU_LEAST, placeMenu, type Placed } from 'shared/menu-place'
import { sameBound } from 'shared/filter'
import { readEntry, readRange, type Side } from 'shared/range-entry'
import { LAYER_COLUMN_FILTER, useEscape, useKeyLayer } from 'shared/use-escape'
@@ -103,7 +104,16 @@ const says = (
return Math.abs(meant - value) < 1e-9
}
-/** What the boxes take besides a number, said in the characters a keyboard has. */
+/**
+ * What the boxes take besides a number, for the one place it is said.
+ *
+ * **On the box, not under it** (Paul, 2026-09-11: "it's a good party trick but
+ * maybe hide the note"). This stood as a line of its own while the caret was in
+ * either box, and a line of bare symbols is a line that has to be explained —
+ * `>6` and `=6` say nothing about which end they are without their words, and
+ * the boxes already say min and max on their own. So the shorthand is a
+ * shortcut somebody finds rather than a legend everybody reads past.
+ */
const howToType = (kind: Kind): string =>
kind === 'length' ? 'A number — or 6-12, >6, <12, =6, 1/4"' : 'A number — or 6-12, >6, <12, =6'
@@ -133,8 +143,6 @@ export const RangeFilter = ({
}: RangeFilterProps) => {
const [lower, setLower] = useState(() => toDraft(bound?.min, unit, kind))
const [upper, setUpper] = useState(() => toDraft(bound?.max, unit, kind))
- /** Whether the caret is in either box, which is when the shorthand is worth saying. */
- const [typing, setTyping] = useState(false)
const first = useRef(null)
const min = bound?.min
@@ -250,27 +258,13 @@ export const RangeFilter = ({
)
return (
-
setTyping(true)}
- onBlur={(event) => {
- // Moving between the two boxes is not leaving the filter, and the hint
- // blinking out and back in between them would be the only thing on the
- // screen that moved.
- if (!event.currentTarget.contains(event.relatedTarget)) {
- setTyping(false)
- }
- }}
- >
-
)
}
@@ -406,51 +400,6 @@ export const OverrideNotice = ({
)
}
-/** Room kept between the menu and the edge of the screen. */
-const MENU_EDGE = 12
-
-/**
- * The least room worth opening downwards into.
- *
- * Below this the menu opens upwards instead. It is a floor on the height as
- * well: a menu squeezed into eighty pixels is one nobody can read, so it takes
- * this much and overhangs rather than becoming a slot.
- */
-const MENU_LEAST = 220
-
-/**
- * How tall a box opened off a button may be, and which way it opens.
- *
- * **A menu is as tall as the screen leaves it** (Paul, 2026-09-10, of the Type
- * filter and then of the column picker: "the edit columns drop down list should
- * be scrollable if it runs off the screen"). Both boxes are opened from a
- * header that can sit anywhere down the page, and both were drawn at whatever
- * height their contents came to, so the rows past the bottom edge were
- * unreachable — the column picker's last column could not be ticked at all.
- *
- * One rule for both: the room under the button is measured, the box takes it
- * and scrolls inside itself, and where what is left under the button is a strip
- * it opens upwards into the larger room instead.
- */
-export const menuRoom = (
- button: { readonly top: number; readonly bottom: number },
- viewport: number,
-): { readonly upwards: boolean; readonly height: number } => {
- const below = viewport - button.bottom - MENU_EDGE
- const above = button.top - MENU_EDGE
- const upwards = below < MENU_LEAST && above > below
- return { upwards, height: Math.max(MENU_LEAST, upwards ? above : below) }
-}
-
-/** Where the menu stands: by its top, or by its bottom where it opened upwards. */
-type Placed = {
- readonly top: number | null
- readonly bottom: number | null
- readonly left: number
- /** The most it may be, which is the room the screen left it. */
- readonly height: number
-}
-
/**
* The box a column's funnel opens, drawn over the page and away from the table.
*
@@ -563,23 +512,16 @@ export const FilterMenu = ({
})
return
}
- const button = anchor.getBoundingClientRect()
- const width = box.current?.getBoundingClientRect().width ?? 0
- const wanted = align === 'right' ? button.right - width : button.left
- const left = Math.max(8, Math.min(wanted, window.innerWidth - width - 8))
- /*
- `menuRoom` is the rule — and where it says upwards the menu is anchored
- by its bottom rather than placed by a height it has not been measured at
- yet, which is the one way to flip a box without a frame of it in the
- wrong place.
- */
- const room = menuRoom(button, window.innerHeight)
- setAt({
- top: room.upwards ? null : button.bottom + 4,
- bottom: room.upwards ? window.innerHeight - button.top + 4 : null,
- left,
- height: room.height,
- })
+ // `shared/menu-place` is the rule, and the same one the picker and the
+ // quick filters follow — this menu only finds its button the hard way.
+ setAt(
+ placeMenu(
+ anchor.getBoundingClientRect(),
+ box.current?.getBoundingClientRect().width ?? 0,
+ align,
+ { width: window.innerWidth, height: window.innerHeight },
+ ),
+ )
}
/**
@@ -1088,6 +1030,14 @@ export const TextFilter = ({
* columns move with it (Paul, 2026-08-31). The handle is left of the tick
* because the tick is the row's own control and dragging must not toggle it;
* arrow keys on a focused handle do the same thing without a pointer.
+ *
+ * **The list is a portal, like every other menu here** (Paul, 2026-09-11: "the
+ * edit filters pencil icon is going behind the table, it needs to go in front
+ * to be usable"). The pencil moved into the bar floating at the bottom of the
+ * viewer, and that bar is inside a panel that clips — so a box positioned
+ * inside it opened downwards into the table and was cut off at the panel's own
+ * edge. Placed against the pencil and drawn on the page instead, the same way
+ * `FilterMenu` escapes the table's scroll box.
*/
export const ColumnPicker = ({
columns,
@@ -1104,154 +1054,178 @@ export const ColumnPicker = ({
}) => {
const [open, setOpen] = useState(false)
const [held, setHeld] = useState(null)
- const box = useRef(null)
- const pencil = useRef(null)
- const [room, setRoom] = useState({ upwards: false, height: MENU_LEAST })
+ /** Which row the pointer is over mid-drag, so the line knows where to be. */
+ const [over, setOver] = useState(null)
const order = columns.map((column) => column.code)
const move = (code: string, index: number) => {
+ setOver(null)
const next = movedTo(order, code, index)
if (next.join() !== order.join()) {
onReorder?.(next)
}
}
- useEffect(() => {
- if (!open) {
- return
- }
- const onDown = (event: PointerEvent) => {
- if (!box.current?.contains(event.target as Node)) {
- setOpen(false)
- }
- }
- document.addEventListener('pointerdown', onDown)
- return () => document.removeEventListener('pointerdown', onDown)
- }, [open])
-
/*
- The list is as long as the table has columns — twenty on the tool list — and
- the pencil is at the top of a table that can sit anywhere down the page, so
- the bottom of the list ran off the screen and the columns there could not be
- ticked. `menuRoom` is the same rule the filter menus follow: take the room
- the screen leaves and scroll inside it, or open upwards where what is under
- the pencil is a strip.
+ **The kit's `Menu`, rather than a box drawn under the pencil** (Paul,
+ 2026-09-11: "the 'which columns to show' menu is now hidden behind the table
+ when opened", then "why aren't these menus just using the menu component
+ from @toolpath/ui?"). The strip this button stands on floats over the
+ viewer, which is a card that clips, so a box positioned inside it was cut
+ off at the card's bottom edge with the tool list showing through the rest of
+ it. Every part of the answer — the portal out of the card, the placing
+ against the pencil, turning over where the room is above, the height the
+ screen leaves, Escape, and a press outside — is what `Menu.Popover` already
+ is, and this had a hand-written half of each.
*/
- useLayoutEffect(() => {
- if (!open) {
- return
- }
- const measure = () => {
- const button = pencil.current?.getBoundingClientRect()
- if (button !== undefined) {
- setRoom(menuRoom(button, window.innerHeight))
- }
- }
- // A scroll inside the list is the list's own business, exactly as it is
- // inside a filter menu.
- const onScroll = (event: Event) => {
- if (event.target instanceof Node && box.current?.contains(event.target) === true) {
- return
- }
- measure()
- }
- measure()
- window.addEventListener('resize', measure)
- window.addEventListener('scroll', onScroll, true)
- return () => {
- window.removeEventListener('resize', measure)
- window.removeEventListener('scroll', onScroll, true)
- }
- }, [open])
-
- // Escape puts it away as well, without going back to find the header.
- useEscape(open, () => setOpen(false))
-
return (
-
- setOpen(!open)}
- /* **A press keeps its own ground** (Paul, 2026-09-11: "the buttons
- shouldn't be transparent"). The chrome this stands in floats over the
- part now, so the pencil wears the same chip the buttons beside it
- wear rather than sitting bare on the geometry. */
- className="rounded border border-zinc-800 bg-zinc-900 p-1 text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
+
+ }
+ />
+ {/*
+ **Under its button, not flipped above it** (Paul, 2026-09-11: "don't
+ just show the menus above, that's a lazy solution"). This strip sits two
+ thirds of the way down the page, so a menu free to pick the roomier side
+ picks *above* every time and hangs over the part rather than over the
+ list it narrows. Pinned to the bottom, it opens where a menu belongs and
+ scrolls inside `--available-height`, which is the positioner's own
+ measurement of what is left there — and a hair off the pencil, so the
+ two read as a control and its answer.
+ */}
+
-
-
- {open ? (
+ {/*
+ `--available-height` is the positioner's own measurement of the room
+ it found, so the list scrolls inside what the screen left rather than
+ running off the bottom of the page with its last columns out of reach
+ (Paul, 2026-09-10).
+ */}
- {columns.map((column, at) => (
-
{
- if (held !== null) {
+ {columns.map((column, at) => {
+ const edge = held === null ? null : dropEdge(order, held, over === at ? at : -1)
+ return (
+
{
+ if (held !== null) {
+ event.preventDefault()
+ // Set here rather than on enter and cleared on leave: this
+ // fires for as long as the pointer is on the row, so the
+ // one under it is always the last to have spoken, and a
+ // drag over a child never reads as a drag out of the row.
+ setOver(at)
+ }
+ }}
+ onDrop={(event) => {
event.preventDefault()
- }
- }}
- onDrop={(event) => {
- event.preventDefault()
- if (held !== null) {
- move(held, at)
- setHeld(null)
- }
- }}
- className={cn(
- 'text-2xs flex items-center gap-1.5 px-2 py-1 whitespace-nowrap hover:bg-zinc-900',
- held === column.code && 'opacity-50',
- )}
- >
- {onReorder === undefined ? null : (
- setHeld(column.code)}
- onDragEnd={() => setHeld(null)}
- onKeyDown={(event) => {
- const by = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0
- if (by !== 0) {
- event.preventDefault()
- onReorder(movedBy(order, column.code, by))
- }
- }}
- className="focus-visible:ring-info/60 shrink-0 cursor-grab rounded text-zinc-600 transition hover:text-zinc-300 focus-visible:ring-1 focus-visible:outline-none active:cursor-grabbing"
- >
-
-
- )}
-
- onToggle(column.code)}
- size="sm"
- aria-label={column.label}
- />
- {column.label}
+ if (held !== null) {
+ move(held, at)
+ setHeld(null)
+ }
+ }}
+ className={cn(
+ 'text-2xs relative flex items-center gap-1.5 px-2 py-1 whitespace-nowrap hover:bg-zinc-900',
+ held === column.code && 'opacity-50',
+ )}
+ >
+ {/*
+ **Where it would land** (Paul, 2026-09-11), drawn on the edge
+ `shared/column-order.ts` says the drop resolves to rather than
+ on the one under the pointer — the two differ whenever the
+ drag is downwards, and a line that lies about the drop is
+ worse than none.
+
+ Absolutely positioned, so the rows under it do not step down
+ by two pixels as the line moves between them; `-top-px` and
+ `-bottom-px` put it *on* the boundary rather than inside one
+ of the two rows it divides.
+
+ Blue, and not the `info` accent every other affordance here
+ wears: this is a thing being carried rather than a control
+ being answered, and the accent is teal against this ground —
+ which is not what was asked for, and reads as one more
+ highlighted control on a panel already full of them.
+ */}
+ {edge === null ? null : (
+
+ )}
+ {onReorder === undefined ? null : (
+ setHeld(column.code)}
+ onDragEnd={() => {
+ setHeld(null)
+ setOver(null)
+ }}
+ onKeyDown={(event) => {
+ const by = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0
+ if (by !== 0) {
+ event.preventDefault()
+ onReorder(movedBy(order, column.code, by))
+ }
+ }}
+ className="focus-visible:ring-info/60 shrink-0 cursor-grab rounded text-zinc-600 transition hover:text-zinc-300 focus-visible:ring-1 focus-visible:outline-none active:cursor-grabbing"
+ >
+
+
+ )}
+
+
+
)
}
diff --git a/apps/catalog/app/components/component-table.tsx b/apps/catalog/app/components/component-table.tsx
index dde339f..0d6ed9a 100644
--- a/apps/catalog/app/components/component-table.tsx
+++ b/apps/catalog/app/components/component-table.tsx
@@ -12,6 +12,8 @@ import { Table, cn } from '@toolpath/ui'
import type { Collet, Holder } from '@toolpath/catalog-data'
import type { UnitSystem } from '@toolpath/tool-support'
import { orderedCodes } from 'shared/column-order'
+import { DEFAULT_COLUMN_WIDTH, fillingWidth, widthId } from 'shared/column-width'
+import { useFittedColumns } from 'shared/use-fitted-columns'
import {
colletTypeLabel,
familyLabel,
@@ -23,7 +25,7 @@ import {
} from 'shared/component-columns'
import { askOfComponentColumn } from 'shared/column-filters'
import { setBound, setTerm, setText, type ComponentQuery } from 'shared/component-query'
-import { TABLE_FACE, TABLE_INK } from 'shared/type'
+import { TABLE_FACE, TABLE_HEAD, TABLE_INK } from 'shared/type'
import {
ColumnFilterMenu,
ColumnHeading,
@@ -146,7 +148,8 @@ interface Selection {
readonly ids: Array
}
-const flexible = (width: string): string => `minmax(${width}, 1fr)`
+/** The grid track a column asks for — `shared/column-width` owns the rule. */
+const flexible = fillingWidth
const columnsShown = (
columns: ReadonlyArray,
@@ -239,6 +242,11 @@ export const ComponentTable = ({
/** The open column, or nothing where it has since been hidden. */
const openColumn = shown.find((column) => column.code === openFilter) ?? null
const inside = useRef(null)
+ const codes = useMemo(() => shown.map((column) => column.code), [shown])
+ /** Where the kit keeps what somebody dragged — `PartToolTable` says why. */
+ const widths = widthId(`part-${kind}s`, codes)
+ // The columns divide the panel, the same rule the tool list keeps.
+ useFittedColumns(inside, codes.join(' '))
/**
* Whose move the selection was — the same guard `PartToolTable` keeps, and
* for the same reason: without it the row the tree already holds is reported
@@ -318,7 +326,7 @@ export const ComponentTable = ({
)
const header = (
-
+
{shown.map((column) => (
{heading(column.code, column.label)}
@@ -354,8 +362,9 @@ export const ComponentTable = ({
className={cn(TABLE_FACE, TABLE_INK, 'flex min-h-0 min-w-0 flex-1 flex-col')}
>
+ {/* An id named after the columns, and no `min-w-max`: `PartToolTable` says why. */}
(
)
+/**
+ * Axial clearance: the room over the material, up to the holder's nose face.
+ *
+ * The rails run the full width where {@link DepthIcon}'s stop short, because
+ * this measures the gap between two *faces* rather than the extent of one
+ * thing — and it is deliberately {@link RadialClearanceIcon} stood up, which is
+ * what the two measurements are.
+ */
+const AxialClearanceIcon = () => (
+
+
+
+
+
+
+
+)
+
+/** Radial clearance: the room sideways, out to a wall standing taller. */
+const RadialClearanceIcon = () => (
+
+
+
+
+
+
+
+)
+
/** Radius: an inside corner, and the arc the cutter leaves in it. */
const RadiusIcon = () => (
@@ -421,6 +450,11 @@ const MEASUREMENT_ICONS: Record React
OAL: DepthIcon,
SFDM: ShankIcon,
NOF: FlutesIcon,
+ // The two clearances, which are not geometry a vendor states but room a shop
+ // wants — read beside `LBH` on the panel's clearance row, so they are drawn
+ // in the same hand as the numbers they sit with.
+ axialClearance: AxialClearanceIcon,
+ radialClearance: RadialClearanceIcon,
}
/**
diff --git a/apps/catalog/app/components/filter-panel.test.tsx b/apps/catalog/app/components/filter-panel.test.tsx
index 0d013bf..846ad08 100644
--- a/apps/catalog/app/components/filter-panel.test.tsx
+++ b/apps/catalog/app/components/filter-panel.test.tsx
@@ -293,6 +293,91 @@ describe('the table filter toolbar', () => {
).toHaveClass('w-32')
expect(document.querySelector('[data-filter-toolbar]')).toHaveClass('flex', 'flex-wrap')
})
+
+ /**
+ * **"The part material filters go behind the table! They need to go up
+ * front!"** (Paul, 2026-09-11).
+ *
+ * The bar these buttons stand in floats along the bottom of the viewer, and
+ * the viewer clips what it holds. An absolutely positioned box inside it was
+ * cut off at that seam the moment it opened downwards — no `z-index` reaches
+ * past a clip — so the menu read as a thing hiding behind the tool list. It
+ * is drawn on the `body` now, fixed against the button, which is the answer
+ * the column funnels already reached.
+ */
+ it('draws an open filter over the page rather than inside the clipped bar', () => {
+ render(
+ new Map()}
+ unit="millimeters"
+ materialGroup={null}
+ onMaterial={vi.fn()}
+ holding={{ tapers: [], series: [] }}
+ only={['materialGroups']}
+ toolbar
+ />,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Part material' }))
+
+ const menu = document.querySelector('[data-tool-filter-menu]')
+ expect(menu).not.toBeNull()
+ /*
+ See the twin in `column-filter.test.tsx`: `Menu.Popover` places the box
+ now, so the bound it is held to is what this application still owns. The
+ two assertions either side of this one are the invariant — the menu is
+ outside the bar that would clip it, and the options are inside the menu.
+ */
+ expect(menu).toHaveClass('max-h-[var(--available-height)]', 'overflow-y-auto')
+ expect(document.querySelector('[data-filter-toolbar]')?.contains(menu ?? null)).toBe(false)
+ /*
+ Present and populated, rather than `toBeVisible`. jsdom performs no
+ layout, so `getClientRects()` is empty for every element it renders and
+ the kit's popover has no box for jest-dom to call visible — while the
+ options themselves render in full. Whether the menu is actually on screen
+ is a question only a browser can answer, and `tests/on-the-part.spec.ts`
+ is where it is asked; what this pins is the half that clipping broke —
+ the menu is outside the bar, and the options are inside the menu.
+ */
+ const options = within(menu as HTMLElement).getByRole('group', { name: 'Part material' })
+ expect(options).toBeInTheDocument()
+ expect(within(options).getAllByRole('button').length).toBeGreaterThan(1)
+ })
+
+ /**
+ * A tick inside a portalled menu is a pointer down outside the element the
+ * close-on-outside rule watches. Without the exemption the filter shut on the
+ * one press it exists for.
+ */
+ it('stays open when a material inside the portalled menu is pressed', () => {
+ const onMaterial = vi.fn()
+ render(
+ new Map()}
+ unit="millimeters"
+ materialGroup={null}
+ onMaterial={onMaterial}
+ holding={{ tapers: [], series: [] }}
+ only={['materialGroups']}
+ toolbar
+ />,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Part material' }))
+ const menu = document.querySelector('[data-tool-filter-menu]') as HTMLElement
+ const steel = within(menu).getByRole('button', { name: /P · Steel/ })
+ fireEvent.pointerDown(steel)
+ fireEvent.click(steel)
+
+ expect(onMaterial).toHaveBeenCalledWith('P')
+ expect(document.querySelector('[data-tool-filter-menu]')).not.toBeNull()
+ })
})
/**
diff --git a/apps/catalog/app/components/filter-panel.tsx b/apps/catalog/app/components/filter-panel.tsx
index a2900ad..0c7c8cd 100644
--- a/apps/catalog/app/components/filter-panel.tsx
+++ b/apps/catalog/app/components/filter-panel.tsx
@@ -1,5 +1,5 @@
-import { Button, cn, Input } from '@toolpath/ui'
-import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
+import { Button, cn, Input, Menu } from '@toolpath/ui'
+import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
BookmarksSimpleIcon,
CaretDownIcon,
@@ -16,9 +16,10 @@ import { formatLength, type UnitSystem } from '@toolpath/tool-support'
import { brandsOfFamily, brandsOfProductLine, getFamily } from 'shared/catalog'
import { toggleTerm, type ToolQuery } from 'shared/filter'
import { AXES_IN_TOOL_COLUMNS, AXES_PARKED } from 'shared/column-filters'
+import { MENU_GAP } from 'shared/menu-place'
import { useEscape } from 'shared/use-escape'
import { Chip, ChipGroup } from './chip'
-import { menuRoom, RangeFilter, type Bound, type Kind } from './column-filter'
+import { RangeFilter, type Bound, type Kind } from './column-filter'
import { DrillDeviationFields } from './drill-deviation'
import {
ColletIcon,
@@ -430,94 +431,78 @@ const ToolbarFilterBody = ({
onOpen: () => void
children: ReactNode
}) => {
- const menu = useRef(null)
- const press = useRef(null)
- const [menuOffset, setMenuOffset] = useState(0)
- /**
- * Which way it opens, and the most it may be.
- *
- * **These buttons stand at the bottom of the viewer now** (Paul, 2026-09-11),
- * so a box opening downwards opens past the edge of a viewer that clips, and
- * what is under the button is a strip. `menuRoom` is the same rule the column
- * funnels and the column picker follow: take the room the screen leaves, and
- * turn over where there is none.
- */
- const [room, setRoom] = useState<{ readonly upwards: boolean; readonly height: number }>({
- upwards: false,
- height: 0,
- })
-
- useLayoutEffect(() => {
- if (!open || menu.current === null) {
- setMenuOffset(0)
- return
- }
-
- const place = () => {
- const element = menu.current
- if (element === null) {
- return
- }
- const menuRect = element.getBoundingClientRect()
- const chrome = element.closest('[data-list-chrome]')?.getBoundingClientRect()
- const left = Math.max(chrome?.left ?? 0, 8) + 8
- const right = Math.min(chrome?.right ?? window.innerWidth, window.innerWidth) - 8
- const correction =
- menuRect.left < left
- ? left - menuRect.left
- : menuRect.right > right
- ? right - menuRect.right
- : 0
- setMenuOffset(correction)
- const button = press.current?.getBoundingClientRect()
- if (button !== undefined) {
- setRoom(menuRoom(button, window.innerHeight))
- }
- }
-
- place()
- window.addEventListener('resize', place)
- return () => window.removeEventListener('resize', place)
- }, [open])
+ /*
+ **The kit's `Menu`, rather than a box drawn under the button** (Paul,
+ 2026-09-11: "the 'part material' menu", then "why aren't these menus just
+ using the menu component from @toolpath/ui?"). These buttons stand on the
+ strip that floats over the bottom of the viewer, and the viewer is a card
+ that clips — so the box was cut off at the card's edge whichever way it
+ opened, with the tool list showing through the rest of it. `Menu.Popover`
+ is a portal placed against its trigger and bounded by the window, which is
+ every part of the answer.
+ Controlled, because which filter is open is the toolbar's business: opening
+ one closes the last.
+ */
return (
-
-
+ }
+ />
+ {/*
+ **Under its button, not flipped above it** (Paul, 2026-09-11: "don't
+ just show the menus above, that's a lazy solution"). This strip sits two
+ thirds of the way down the page, so a menu free to pick the roomier side
+ picks *above* every time and hangs over the part rather than over the
+ list it narrows. Pinned to the bottom, it opens where a menu belongs and
+ scrolls inside `--available-height`, which is the positioner's own
+ measurement of what is left there — and a hair off the button, so the
+ two read as a control and its answer.
+ */}
+
- {icon}
-
- {summary === 'Any' ? label : `${label}: ${summary}`}
-
-
-
- {open ? (
-
- {children}
-
- ) : null}
-
+ {children}
+
+
)
}
@@ -626,9 +611,33 @@ const useCloseOnOutside = (open: boolean, close: () => void) => {
return
}
const onDown = (event: PointerEvent) => {
- if (!box.current?.contains(event.target as Node)) {
+ const target = event.target instanceof Element ? event.target : null
+ if (target === null) {
+ close()
+ return
+ }
+ /*
+ **A menu drawn on the page is still inside the strip that opened it.**
+ `ToolbarFilterBody` puts its box in a portal so the viewer card cannot
+ clip it, which takes it out of this element — and a press on the thing
+ somebody just opened read as a press outside and shut it again.
+ */
+ if (target.closest('[data-tool-filter-menu]') !== null) {
+ return
+ }
+ if (!box.current?.contains(target)) {
close()
}
+ /*
+ **A menu drawn in a portal is still inside the thing that opened it.**
+ `ToolbarFilterBody` puts its box on the `body` so the viewer cannot clip
+ it, which makes every tick in it a press outside this element — and the
+ filter shut on the one press it exists for.
+ */
+ if (target !== null && target.closest('[data-tool-filter-menu]') !== null) {
+ return
+ }
+ close()
}
document.addEventListener('pointerdown', onDown)
return () => document.removeEventListener('pointerdown', onDown)
diff --git a/apps/catalog/app/components/group-editor.tsx b/apps/catalog/app/components/group-editor.tsx
index b53edfc..a8ca100 100644
--- a/apps/catalog/app/components/group-editor.tsx
+++ b/apps/catalog/app/components/group-editor.tsx
@@ -1,5 +1,5 @@
import { XIcon } from '@phosphor-icons/react'
-import { Button } from '@toolpath/ui'
+import { Button, cn } from '@toolpath/ui'
import type { UnitSystem } from '@toolpath/tool-support'
import type { Results } from 'shared/feature-list'
import { readingText } from 'shared/feature-defaults'
@@ -8,7 +8,7 @@ import type { HoleMode, ThreadSpec } from 'shared/threads'
import { useEscape } from 'shared/use-escape'
import { MeasurementIcon } from './feature-icons'
import { ThreadPicker } from './thread-picker'
-import { SECTION_LABEL } from 'shared/type'
+import { DIALOG_EMPTY, DIALOG_NOTE, DIALOG_TEXT, DIALOG_VALUE, SECTION_LABEL } from 'shared/type'
/**
* Building a group: which features are in it.
@@ -177,7 +177,7 @@ export const GroupEditor = ({
2026-09-08). One tool for all of them is the only question a group asks,
so the sentence that used to sit under a radio says it instead.
*/}
-
+
Select a feature on the part to add it to the group. The Tool Catalog will find tools
compatible with all features in the group.
@@ -185,7 +185,13 @@ export const GroupEditor = ({
{/* What is in it, each with the way out. Empty says so rather than
leaving a gap somebody has to interpret. */}
{tags.length === 0 ? (
-
{identical.count - 1 === 1
? 'One other hole on this part is identical'
: `${String(identical.count - 1)} other holes on this part are identical`}{' '}
@@ -272,7 +281,7 @@ export const GroupEditor = ({
unit={unit}
/>
) : mixed ? (
-
+
The holes in this group are different sizes, so they cannot share one thread. Thread them
one at a time.
{tags.length > 0 && results === 'each' && matching === 'pending' ? (
-
+
) : null}
{tags.length > 0 && results === 'each' && matching === 'error' ? (
-
+
Unable to match tools. Change the group to retry.
) : null}
{tags.length > 0 && results === 'each' && matching === 'nothing-fits' ? (
-
- Nothing in the catalog fits at least one feature.
-
+ Nothing in the catalog fits at least one feature.
) : null}
{tags.length > 0 && !picked && !(results === 'each' && matching !== 'ready') ? (
-
+
Pick a tool from the list below, then add the assembly to the order list.
) : null}
diff --git a/apps/catalog/app/components/panel-resizer.test.tsx b/apps/catalog/app/components/panel-resizer.test.tsx
new file mode 100644
index 0000000..fa5cb3d
--- /dev/null
+++ b/apps/catalog/app/components/panel-resizer.test.tsx
@@ -0,0 +1,74 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { PanelResizer } from './panel-resizer'
+import { NARROWEST, OPENS_AT, OPENS_AT_FOR_A_GROUP } from 'shared/panel-width'
+
+/**
+ * The handle on the right edge of the panel over the part (Paul, 2026-09-11).
+ *
+ * What a width may *be* is `shared/panel-width.ts` and pinned there. What is
+ * pinned here is that the edge can be moved without a mouse at all, that a
+ * double-click gives the defaults back, and that the handle is reported as a
+ * separator with its two ends on it — a drag target nothing announces is a drag
+ * target only a mouse can find.
+ *
+ * The drag itself is in `tests/on-the-part.spec.ts`: it is a pointer capture
+ * against a measured viewer, and jsdom has neither.
+ */
+const show = (width = OPENS_AT) => {
+ const onResize = vi.fn()
+ const onReset = vi.fn()
+ render()
+ return { handle: screen.getByRole('separator'), onResize, onReset }
+}
+
+describe('the panel handle', () => {
+ it('says what it is and where its two ends are', () => {
+ const { handle } = show(360)
+
+ expect(handle).toHaveAttribute('aria-orientation', 'vertical')
+ expect(handle).toHaveAttribute('aria-valuenow', '360')
+ expect(handle).toHaveAttribute('aria-valuemin', String(NARROWEST))
+ })
+
+ /** The arrows move the edge, so widening the panel is not a mouse-only act. */
+ it('widens and narrows on the arrow keys', () => {
+ const { handle, onResize } = show(320)
+
+ fireEvent.keyDown(handle, { key: 'ArrowRight' })
+ expect(onResize).toHaveBeenLastCalledWith(336)
+
+ fireEvent.keyDown(handle, { key: 'ArrowLeft' })
+ expect(onResize).toHaveBeenLastCalledWith(304)
+ })
+
+ /**
+ * Rendered outside the viewer it measures, the clamp still has two ends —
+ * `widestPanel` answers a width rather than nothing for a room it cannot
+ * measure, which is what stops an arrow press snapping the panel shut.
+ */
+ it('holds the narrowest against a press that would go under it', () => {
+ const { handle, onResize } = show(NARROWEST)
+
+ fireEvent.keyDown(handle, { key: 'ArrowLeft' })
+
+ expect(onResize).toHaveBeenLastCalledWith(NARROWEST)
+ })
+
+ it('does not move on a press that is not an arrow', () => {
+ const { handle, onResize } = show()
+
+ fireEvent.keyDown(handle, { key: 'Enter' })
+
+ expect(onResize).not.toHaveBeenCalled()
+ })
+
+ /** Double-click is the way back to what the box in the panel opens at. */
+ it('puts the defaults back on a double-click', () => {
+ const { handle, onReset } = show(OPENS_AT_FOR_A_GROUP)
+
+ fireEvent.doubleClick(handle)
+
+ expect(onReset).toHaveBeenCalled()
+ })
+})
diff --git a/apps/catalog/app/components/panel-resizer.tsx b/apps/catalog/app/components/panel-resizer.tsx
new file mode 100644
index 0000000..eedff84
--- /dev/null
+++ b/apps/catalog/app/components/panel-resizer.tsx
@@ -0,0 +1,138 @@
+import { useRef, useState } from 'react'
+import { cn } from '@toolpath/ui'
+import { clampPanel, widestPanel, NARROWEST } from 'shared/panel-width'
+
+/**
+ * The right edge of the panel over the part, as something to drag.
+ *
+ * **Widen it by pulling the edge** (Paul, 2026-09-11: "I should have the ability
+ * to make the order list (and feature/group/tool assembly) wider by clicking the
+ * edge and expanding to the right"). One panel carries all of them, so this is
+ * one handle, and `shared/panel-width.ts` holds every number it clamps to.
+ *
+ * **It measures the viewer itself.** The clamp needs the room the panel stands
+ * in, and the one place that room is known without threading a measurement
+ * through the route is here, at the pointer: the handle walks up to the overlay
+ * the panel lives in and takes its offset parent, which is the viewer's own
+ * box. Measured at the press rather than held in state, so a window resized
+ * between two drags is a different clamp rather than a stale one.
+ *
+ * It is deliberately narrow and invisible until it is wanted. The strip takes
+ * the pointer, and a wide `pointer-events: auto` strip down the middle of the
+ * canvas is the curtain `tests/on-the-part.spec.ts` § "at a laptop width"
+ * exists for — six pixels centred on the edge is a handle; forty is a wall.
+ */
+export interface PanelResizerProps {
+ /** What the panel is drawn at now, which is where a drag starts from. */
+ readonly width: number
+ /** A width dragged to, already the panel's own — this clamps before calling. */
+ readonly onResize: (width: number) => void
+ /** Double-click: forget the stated width and put the defaults back. */
+ readonly onReset: () => void
+}
+
+/** What one arrow press moves the edge by. */
+const STEP = 16
+
+/**
+ * The room the panel has: the viewer it is drawn over.
+ *
+ * The overlay is `absolute` inside the viewer's `relative` section, so that
+ * section is its offset parent and its width is the whole canvas — which is what
+ * `WIDEST_SHARE` is a share of.
+ */
+const roomFor = (handle: HTMLElement | null): number => {
+ const overlay = handle?.closest('[data-questions]')
+ if (!(overlay instanceof HTMLElement)) {
+ return 0
+ }
+ return overlay.offsetParent instanceof HTMLElement ? overlay.offsetParent.clientWidth : 0
+}
+
+export const PanelResizer = ({ width, onResize, onReset }: PanelResizerProps) => {
+ /** Where the drag started, and how wide the panel was then. */
+ const from = useRef<{ readonly x: number; readonly width: number; readonly room: number } | null>(
+ null,
+ )
+ const [dragging, setDragging] = useState(false)
+ const [room, setRoom] = useState(0)
+
+ const moveTo = (wanted: number, against: number) => {
+ onResize(clampPanel(wanted, against))
+ }
+
+ return (
+
{
+ const handle = event.currentTarget
+ const measured = roomFor(handle)
+ from.current = { x: event.clientX, width, room: measured }
+ setRoom(measured)
+ setDragging(true)
+ handle.setPointerCapture(event.pointerId)
+ // The part is under this: a press here must not also start an orbit.
+ event.preventDefault()
+ event.stopPropagation()
+ }}
+ onPointerMove={(event) => {
+ const start = from.current
+ if (start === null) {
+ return
+ }
+ moveTo(start.width + (event.clientX - start.x), start.room)
+ }}
+ onPointerUp={(event) => {
+ from.current = null
+ setDragging(false)
+ event.currentTarget.releasePointerCapture(event.pointerId)
+ }}
+ onPointerCancel={() => {
+ from.current = null
+ setDragging(false)
+ }}
+ onDoubleClick={onReset}
+ onKeyDown={(event) => {
+ const by = event.key === 'ArrowRight' ? STEP : event.key === 'ArrowLeft' ? -STEP : 0
+ if (by === 0) {
+ return
+ }
+ event.preventDefault()
+ const measured = roomFor(event.currentTarget)
+ setRoom(measured)
+ moveTo(width + by, measured)
+ }}
+ className={cn(
+ /*
+ Centred on the edge — half over the panel, half over the part — so the
+ width the pointer aims at is the width it lands on. `cursor-col-resize`
+ is the whole affordance until it is hovered, which is what keeps a
+ permanent line off the geometry.
+ */
+ 'group pointer-events-auto absolute top-0 -right-1 z-10 h-full w-2 cursor-col-resize',
+ 'focus-visible:outline-none',
+ )}
+ >
+ {/* The line itself: a hairline down the middle of the strip, on hover. */}
+
+
+ )
+}
diff --git a/apps/catalog/app/components/part-tool-table.test.tsx b/apps/catalog/app/components/part-tool-table.test.tsx
index f0a7bbf..22bc873 100644
--- a/apps/catalog/app/components/part-tool-table.test.tsx
+++ b/apps/catalog/app/components/part-tool-table.test.tsx
@@ -181,18 +181,36 @@ describe('PartToolTable', () => {
expect(screen.queryByText('holder needs')).not.toBeInTheDocument()
})
- it('keeps the table grid wider than its scroll container', () => {
+ /**
+ * **The grid is never wider than the box it is read in** (Paul, 2026-09-11).
+ * It used to be, by `min-w-max` here and a `min-width: max-content` in
+ * `app/styles.css`, and under max-content sizing every `1fr` track came out
+ * at the widest floor in the map — thirteen 192px columns in a 1169px panel.
+ */
+ it('lets the scroll container size the table grid', () => {
show({
columns: TOOL_COLUMNS,
hiddenColumns: [],
columnOrder: TOOL_COLUMNS.map((column) => column.code),
})
- expect(document.querySelector('[data-table-library_table]')).toHaveClass('min-w-max')
+ expect(document.querySelector('[data-table-library_table]')).not.toHaveClass('min-w-max')
})
- it('uses flexible tracks for initial column widths', () => {
- expect(flexibleColumnWidth('10rem')).toBe('minmax(10rem, 1fr)')
+ /**
+ * **A dragged width is remembered, and only for the columns it was about.**
+ * The kit stores its track list under the `id` it is given, positionally, so
+ * the id is the column set — `shared/column-width.ts` says why at length, and
+ * `shared/column-width.test.ts` pins the id itself.
+ *
+ * Nothing here can check the id reaches the kit: it is hung on no DOM node,
+ * and the write happens on a `mouseup` inside the kit that jsdom cannot
+ * produce. `tests/on-the-part.spec.ts` § "keeps a dragged column width" is
+ * where that is answered, with a real pointer.
+ */
+
+ it('asks for tracks that divide the panel rather than floors under it', () => {
+ expect(flexibleColumnWidth('10rem')).toBe('minmax(0, 10fr)')
})
})
@@ -337,11 +355,12 @@ describe('the filters a heading asks', () => {
})
/**
- * **The tap list asks two of them** (Paul, 2026-09-09: "when I am in the TAPs
- * row or table, it should be filtering to taps"). Its rows are the thread's
- * rather than the query's, so its numbers and its vendor are not questions it
- * can answer — but which kind of tap is, because that is the tap half of the
- * `form` filter a threaded hole writes.
+ * **The tap list asks what it can answer** (Paul, 2026-09-09: "when I am in
+ * the TAPs row or table, it should be filtering to taps"). Its rows are the
+ * thread's rather than the query's, so its numbers are not questions it can
+ * answer — but which kind of tap is, because that is the tap half of the
+ * `form` filter a threaded hole writes, and so are the vendor and the family
+ * the page narrows the swept pool on (Paul, 2026-09-11).
*/
it('asks what the list it is drawn for says it asks', () => {
const onTerm = vi.fn()
@@ -363,8 +382,8 @@ describe('the filters a heading asks', () => {
'Filtered by Type',
)
expect(screen.getByRole('button', { name: 'Filter by Catalog number' })).toBeVisible()
- // A vendor and a number are the thread's, so no funnel offers to change them.
- expect(screen.queryByRole('button', { name: 'Filter by Vendor' })).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Filter by Vendor' })).toBeVisible()
+ // A number is the thread's, so no funnel offers to change it.
expect(screen.queryByRole('button', { name: 'Filter by Diameter' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Filter by Type' }))
diff --git a/apps/catalog/app/components/part-tool-table.tsx b/apps/catalog/app/components/part-tool-table.tsx
index d70f724..90d4e46 100644
--- a/apps/catalog/app/components/part-tool-table.tsx
+++ b/apps/catalog/app/components/part-tool-table.tsx
@@ -19,6 +19,8 @@ import type { ToolQuery } from 'shared/filter'
import { markWords, type Mark } from 'shared/tool-marks'
import type { BelowHolder } from 'shared/drawn-assembly'
import { orderedCodes } from 'shared/column-order'
+import { DEFAULT_COLUMN_WIDTH, fillingWidth, widthId } from 'shared/column-width'
+import { useFittedColumns } from 'shared/use-fitted-columns'
import { ToolTypeIcon } from './tool-icons'
import {
ColumnFilterMenu,
@@ -27,7 +29,7 @@ import {
type ColumnHeadingProps,
} from './column-heading'
import type { Bound, ColumnOverride } from './column-filter'
-import { TABLE_FACE, TABLE_INK } from 'shared/type'
+import { TABLE_FACE, TABLE_HEAD, TABLE_INK } from 'shared/type'
export interface PartToolColumn {
readonly code: string
@@ -101,7 +103,13 @@ export const TAP_COLUMNS: ReadonlyArray = [
export const hiddenByDefault = (columns: ReadonlyArray): Array =>
columns.filter((column) => !column.default).map((column) => column.code)
-export const flexibleColumnWidth = (width: string): string => `minmax(${width}, 1fr)`
+/**
+ * The grid track a column asks for — `shared/column-width` owns the rule.
+ *
+ * Kept as a name here because the header reads as a column asking for a width,
+ * and `components/component-table.tsx` asks the same module the same thing.
+ */
+export const flexibleColumnWidth = fillingWidth
/**
* A row of the list, in pixels — `@toolpath/ui`'s compact `Table`.
@@ -168,14 +176,14 @@ export const isStack = (code: string): boolean => code === 'LBH'
export const isIdentity = (code: string): boolean => IDENTITY.some((column) => column.code === code)
/**
- * How wide a column starts, by what it holds rather than by its numbers.
+ * How wide a column is, by what it holds rather than by its numbers.
*
- * **Only the largest of these is doing anything.** `@toolpath/ui`'s table gives
- * every column the width of the widest `minmax()` floor it is handed, so
- * raising one entry here raises all thirteen — measured on 2026-09-11 by
- * setting `type` to `20rem` and watching each column become 320px. The map
- * reads as a per-column decision and is not one. Left as it was rather than
- * tuned around, because the column sizing is the kit's to fix.
+ * **Read as a share of the panel, not as a floor under it** — the rem is a
+ * weight and `shared/column-width` is the rule. Until 2026-09-11 only the
+ * largest entry here did anything: every column came out at the width of the
+ * widest `minmax()` floor in the map, so the list opened 2120px wide inside a
+ * 1169px panel with thirteen 192px columns. Raising one entry now widens that
+ * column and narrows the rest.
*/
const WIDTH: Readonly> = {
catalogNumber: '10rem',
@@ -468,6 +476,18 @@ export const PartToolTable = ({
/** The open column, or nothing where it has since been hidden. */
const openColumn = shown.find((column) => column.code === openFilter) ?? null
const inside = useRef(null)
+ const codes = useMemo(() => shown.map((column) => column.code), [shown])
+ /**
+ * Where the kit keeps what somebody dragged — named after these columns.
+ *
+ * A stored track list is positional, so it is only ever an answer about the
+ * column set it was dragged on: `shared/column-width.ts` says why that is the
+ * id rather than a fixed one.
+ */
+ const widths = widthId('part-tools', codes)
+ // The columns divide the panel; anything the kit's resizer froze onto it goes
+ // when the panel or the column set changes.
+ useFittedColumns(inside, codes.join(' '))
const selectionCameFromTable = useRef(false)
const setSelection = useCallback((next: SetStateAction) => {
setSelectedRows((current) => {
@@ -560,7 +580,7 @@ export const PartToolTable = ({
)
const header = (
-
+
{shown.map((column) => (
{heading(column.code, column.label)}
@@ -598,8 +618,16 @@ export const PartToolTable = ({
className={cn(TABLE_FACE, TABLE_INK, 'flex min-h-0 min-w-0 flex-1 flex-col')}
>