From 7c1058b307a5bda9ad7c94e9b606105d2d67a17d Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Fri, 11 Sep 2026 13:09:18 -0400 Subject: [PATCH 01/19] holder clearance calculator and minor UI fixes --- AGENTS.md | 3 + .../app/components/catalog-drawing.test.tsx | 51 +-- .../app/components/catalog-drawing.tsx | 88 ++--- .../app/components/clearance-entry.tsx | 311 ++++++++++++++++++ apps/catalog/app/components/column-filter.tsx | 41 +-- .../app/components/component-table.tsx | 4 +- apps/catalog/app/components/feature-icons.tsx | 34 ++ .../app/components/part-tool-table.tsx | 4 +- apps/catalog/app/components/tool-details.tsx | 99 +++++- apps/catalog/app/routes/part.tsx | 102 ++++-- .../app/shared/assembly-actions.test.ts | 63 +++- apps/catalog/app/shared/assembly-actions.ts | 60 +++- apps/catalog/app/shared/assembly-gaps.ts | 110 +++++++ apps/catalog/app/shared/assembly-tree.test.ts | 27 ++ apps/catalog/app/shared/assembly-tree.ts | 21 ++ .../app/shared/clearance-entry.test.ts | 206 ++++++++++++ apps/catalog/app/shared/clearance-entry.ts | 224 +++++++++++++ apps/catalog/app/shared/component-usage.ts | 8 +- apps/catalog/app/shared/type.ts | 18 + apps/catalog/app/styles.css | 21 ++ apps/catalog/tests/on-the-part.spec.ts | 69 ++++ 21 files changed, 1416 insertions(+), 148 deletions(-) create mode 100644 apps/catalog/app/components/clearance-entry.tsx create mode 100644 apps/catalog/app/shared/assembly-gaps.ts create mode 100644 apps/catalog/app/shared/clearance-entry.test.ts create mode 100644 apps/catalog/app/shared/clearance-entry.ts diff --git a/AGENTS.md b/AGENTS.md index 3fef690..7b07c7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -305,6 +305,9 @@ application unless that application says otherwise. | 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` | | 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` | diff --git a/apps/catalog/app/components/catalog-drawing.test.tsx b/apps/catalog/app/components/catalog-drawing.test.tsx index 32fa950..4264013 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' @@ -127,19 +126,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/) }) /** @@ -243,33 +249,36 @@ 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. + * **The verdict stays; the sentence under it went** (Paul, 2026-09-11). + * + * The sentence named the length the verdict was reached at, because a stack + * clears at one stickout and fouls at another. That length is a box now, and + * an editable one — so the reading it answered is better answered than it was + * — and five lines of the panel went back to the drawing. + * + * What must not go with it is the verdict itself: a clearance under what was + * wanted and a stack actually into the material are different readings, and + * only this says the second. */ - it('says the length below the holder the verdict was reached at', () => { + it('says whether the stack clears, and nothing else', () => { const container = drawn( , ) - expect(container.textContent).toContain( - `at ${formatLength(assembly.stickout ?? 0, 'millimeters')} below the holder`, - ) + expect(container.textContent).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 d19feaa..4791b1a 100644 --- a/apps/catalog/app/components/catalog-drawing.tsx +++ b/apps/catalog/app/components/catalog-drawing.tsx @@ -17,16 +17,9 @@ import { type Sheet, type ViewerAssembly, } 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 { assemblyLabel } from 'shared/assemblies' -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' /** @@ -133,32 +126,24 @@ export interface CatalogDrawingProps { } /** - * The verdict's sentence, and **the length below the holder it is about**. + * **The sentence under the verdict came out on 2026-09-11.** + * + * It read "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", which was five lines of + * the panel saying what the three clearance boxes under the sheet now say in + * three — and say editably, which a sentence cannot. Paul's call once the boxes + * were on screen: "you can remove the previous messaging above the tool + * visualization and make the tool visualization bigger." * - * 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 itself stays.** "collides with the part" is the one thing the + * boxes do not say: a clearance under what was wanted and a stack actually into + * the material are different readings, and the second is the one a shop must + * not miss. The package writes it from `clears` alone, with no note under it. * - * On a tool drawn alone there is no holder and so no such length, and the - * sentence is the gaps by themselves as before. + * What was lost with the sentence is *where* the tightest point was — "at the + * shank" — which is `describeGaps`'s and has no box. The drawing paints the + * colliding part red, which is the same answer 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}` -} export const CatalogDrawing = ({ tool, @@ -176,15 +161,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) /** @@ -194,17 +171,21 @@ 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 - const cuttingRadius = (tool.geometry.DC ?? 0) / 2 + const cuttingRadius = cuttingRadiusOf(tool) const profile = curve !== null && tool.geometry.DC !== undefined ? materialProfile(curve, cuttingRadius) : null - const gaps = - curve !== null && outline !== 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. + */ + 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: materialRoom } : {} return ( @@ -218,17 +199,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={verdict === null ? null : { clears: verdict.clears, note: null }} className="size-full" > - {overlaid && profile !== null && gaps !== null && outline !== null ? ( + {overlaid && profile !== null && gaps !== null ? ( = ['below', 'axial', 'radial'] + +export interface ClearanceEntryProps { + readonly boxes: ClearanceBoxes + readonly unit: UnitSystem + readonly edit: ClearanceEdit | 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, + 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, + } + } + 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, 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) => ( +
+ + {LABEL[field]} + + setDraft({ field, text: next ?? '' })} + onBlur={() => { + if (draft?.field === field) { + commit(field, draft.text) + } + }} + onKeyDown={(event) => { + if (event.key === 'Enter' && draft?.field === field) { + commit(field, draft.text) + } + if (event.key === 'Escape' && draft?.field === field) { + setDraft(null) + } + }} + variant="ghost" + size="md" + textEnd + className="inline-flex w-full rounded border border-zinc-800 px-1.5 py-1 font-mono focus-within:border-zinc-600" + /> +
+ ) + + /** + * **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) + + return ( +
+ +
+ {box('below', shownIn(boxes.below), markForBelow(boxes, edit, say))} + {box('axial', shownIn(boxes.axial), markFor(boxes.axial, say))} + {box('radial', shownIn(boxes.radial), markFor(boxes.radial, say))} +
+ {/* + 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.tsx b/apps/catalog/app/components/column-filter.tsx index c9bb990..e8949f8 100644 --- a/apps/catalog/app/components/column-filter.tsx +++ b/apps/catalog/app/components/column-filter.tsx @@ -103,7 +103,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 +142,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 +257,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) - } - }} - > -
- {box('min', lower, (raw) => commit(raw, upper))} - – - {box('max', upper, (raw) => commit(lower, raw))} - {kind === 'length' ? ( - {UNIT_ABBREVIATION[unit]} - ) : null} -
- {typing ?

{howToType(kind)}

: null} +
+ {box('min', lower, (raw) => commit(raw, upper))} + – + {box('max', upper, (raw) => commit(lower, raw))} + {kind === 'length' ? ( + {UNIT_ABBREVIATION[unit]} + ) : null}
) } diff --git a/apps/catalog/app/components/component-table.tsx b/apps/catalog/app/components/component-table.tsx index dde339f..c2d5cb8 100644 --- a/apps/catalog/app/components/component-table.tsx +++ b/apps/catalog/app/components/component-table.tsx @@ -23,7 +23,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, @@ -318,7 +318,7 @@ export const ComponentTable = ({ ) const header = ( - + {shown.map((column) => ( ( ) +/** + * 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/part-tool-table.tsx b/apps/catalog/app/components/part-tool-table.tsx index d70f724..01284aa 100644 --- a/apps/catalog/app/components/part-tool-table.tsx +++ b/apps/catalog/app/components/part-tool-table.tsx @@ -27,7 +27,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 @@ -560,7 +560,7 @@ export const PartToolTable = ({ ) const header = ( - + {shown.map((column) => ( ({ key: stackKey, edit: null }) + const edit = stated.key === stackKey ? stated.edit : null + + /** + * The least this stack can stand out of the holder and still leave the room + * asked for — `clearance()`'s own answer, behind the name the rule wants. + */ + const requiredAt = (limits: Margins): number | null => + holderChosen === undefined || curve === null + ? null + : clearance( + { tool, holder: holderChosen, collet: stack?.collet ?? null, stickout: 0 }, + curve, + limits, + ).requiredStickout + const ask = askFor(edit, margins, requiredAt) + const drawn = drawnAssembly( tool, - { holder: chosen.holderGuid, collet: chosen.colletGuid, stickout: null }, + { holder: chosen.holderGuid, collet: chosen.colletGuid, stickout: ask.stickout }, curve, - margins, + ask.margins, thresholdsFrom(), holderChosen === undefined ? [] : [holderChosen], ) /** Whether the sheet below is the stack rather than the bare tool. */ const drawnAsStack = drawn.assembly !== null && view === 'stack' + /** + * The room this stack actually leaves, at the length it is actually drawn at. + * + * Measured after the stack has had the ask, because the stack floors and caps + * a stated length — a box showing the room at a length nobody is looking at + * would be worse than no box. Memoised on the stack and the length: the sweep + * is a loop over a few dozen segments, but it is one the panel would otherwise + * run on every keystroke anywhere on the page. + */ + const room = useMemo( + () => + drawnAsStack + ? roomAt({ tool, holder: drawn.holder }, drawn.stickout, curve, ask.margins) + : { axial: null, radial: null }, + [ + drawnAsStack, + tool, + drawn.holder, + drawn.stickout, + curve, + ask.margins.axial, + ask.margins.radial, + ], + ) + const boxes = boxesFor( + edit, + margins, + { stickout: drawn.stickout, overLimit: drawn.overLimit }, + room, + ) + return ( /* **The panel is a grey wash, and everything on it floats** (Paul, @@ -371,7 +436,18 @@ export const ToolDetails = ({ panel only makes it more portrait. */}
-
+ {/* + **Bigger, now that nothing is written over it** (Paul, 2026-09-11). + The five lines the verdict's sentence took went to the sheet, and + the clearance row under it is folded to begin with. + + Still a capped width under a floored height, and for the reason the + note above gives: `orientationFor` is `width >= height`, measured + once, so the two figures have to stay apart or a tall panel lays the + tool on its side. 18 rem under 22 rem cannot be landscape whatever + the panel does. + */} +
+ {/* + **The three numbers that decide each other**, under the sheet they are + about (Paul, 2026-09-11). Only with a stack and a feature: a clearance + is room between something and something else, and a cutter drawn on its + own has neither. + */} + {drawnAsStack && curve !== null ? ( + setStated({ key: stackKey, edit: next })} + /> + ) : null} + {/* The numbers it is chosen on, at the bottom: two columns, big enough to read across the desk, each saying what it is rather than only its code (Paul, 2026-09-01) — or whatever else is being read, where the diff --git a/apps/catalog/app/routes/part.tsx b/apps/catalog/app/routes/part.tsx index 0c4ac28..b949f9a 100644 --- a/apps/catalog/app/routes/part.tsx +++ b/apps/catalog/app/routes/part.tsx @@ -140,6 +140,7 @@ import { groupOf as groupWith, stacksOf, markOrdered, + orderedAs, removeAssembly, renameAssembly, restoreAssembly, @@ -3710,13 +3711,32 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: const pressRow = useCallback( (id: string | null, tag: string | null, toolGuid?: string) => { const item = itemNamed(list, id) + /* + **A press on a line opens the stack that line came from** (Paul, + 2026-09-11: "when I click on a specific tool assembly row in the order + list, focus should go to the top line component of the specific assembly + I clicked"). The line names its tool and `orderedAs` names its stack, so + the tree beside the box can open on it — without this it opened on + `firstNode`, which is the first *unanswered* slot, so pressing the third + assembly of a row landed on the first one's empty collet and the stack + somebody pressed was not on screen. + + Its top line is the tool slot: the stack is a tool with its holding + under it, and the tool is what the line stands for. + */ + if (item !== null && toolGuid !== undefined) { + const stack = orderedAs(trees[item.id] ?? [], toolGuid) + if (stack !== null) { + setNodeHeld({ itemId: item.id, node: { assemblyId: stack.id, slot: 'tool' } }) + } + } if (item?.kind === 'group' && tag === null) { startEdit(item.id) return } selectRow(id, tag, toolGuid) }, - [list, startEdit, selectRow], + [list, trees, startEdit, selectRow], ) /** @@ -3808,6 +3828,38 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: [commit, sheet], ) + /** + * One row off the list, with its lines and its tree. + * + * The whole of what removing a row means, in one place, because there are two + * presses that mean it: right-click → *Remove* on the list, and the press + * under an emptied tree (`assembly-actions` § `drop`, Paul, 2026-09-11). They + * were one press and one handler inside the list's JSX, so the second one + * would have been a second reading of what *gone* includes — and a row + * removed without `unbill` goes on being ordered by a bill nobody can see any + * more. + * + * **And its tree with it.** Ids are arithmetic, so a part emptied of rows + * starts again at `feature-1`; a tree left behind would attach itself to + * whatever row took that id next. + */ + const removeRow = useCallback( + (id: string) => { + const going = itemNamed(list, id) + if (going !== null) { + // Its features, or — for a part-level assembly — the key its own lines + // are kept under. + unbill(sheetKeysOf(going)) + } + forgetTree(id) + setList((current) => removeItem(current, id)) + if (selectedId === id) { + selectRow(null) + } + }, + [list, unbill, forgetTree, setList, selectedId, selectRow], + ) + /** * The draft's stacks become the row's, under the id it has just been given. * @@ -4815,7 +4867,13 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: stacks, treeLines, activeItem !== null, - draft?.kind === 'group' ? 'group' : draft?.kind === 'assembly' ? 'assembly' : 'feature', + /* + **What the press is about is the row, where there is one.** A draft + names what it would create; an existing row names itself — the drop + press says *Remove group from list* over a group, and saying *feature* + there names something the list does not hold (Paul, 2026-09-11). + */ + draft?.kind ?? activeItem?.kind ?? 'feature', componentName, ).map((action) => ({ key: `${stacks[0]?.id ?? 'assembly'}-${action.kind}`, @@ -4844,6 +4902,21 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: ? { disabled: true } : {}), onClick: () => { + /* + **The row itself goes, and the box goes with it** (Paul, + 2026-09-11). Offered only under a tree with no tool in it at all — + `assembly-actions` § `drop` is the rule — so there is nothing in the + tree left to write; `removeRow` takes the row's lines and its tree, + and what is left on screen is an editor for a row that no longer + exists. + */ + if (action.kind === 'drop') { + if (activeItem !== null) { + removeRow(activeItem.id) + } + putDown() + return + } /* **Backing out touches the tree, not the bill.** The change was never written, so there is nothing to undo on the sheet — what is put back @@ -4881,6 +4954,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: componentName, writeTree, putDown, + removeRow, ], ) @@ -6722,9 +6796,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: */ assemblyOf={(itemId, toolGuid) => { const stacks = trees[itemId] ?? [] - const stack = stacks.find( - (each) => (each.orderedTool ?? each.toolGuid) === toolGuid, - ) + const stack = orderedAs(stacks, toolGuid) if (stack?.name === undefined) { return null } @@ -6746,25 +6818,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: setRenamingId(null) }} onRenameCancel={() => setRenamingId(null)} - onRemove={(id) => { - const going = itemNamed(list, id) - if (going !== null) { - // Its features, or — for a part-level assembly - // — the key its own lines are kept under. - unbill(sheetKeysOf(going)) - } - /* - **And its tree with it.** Ids are arithmetic, so - a part emptied of rows starts again at - `feature-1` — a tree left behind would attach - itself to whatever row took that id next. - */ - forgetTree(id) - setList((current) => removeItem(current, id)) - if (selectedId === id) { - selectRow(null) - } - }} + onRemove={removeRow} /> )}
diff --git a/apps/catalog/app/shared/assembly-actions.test.ts b/apps/catalog/app/shared/assembly-actions.test.ts index 5d60b92..0dbc807 100644 --- a/apps/catalog/app/shared/assembly-actions.test.ts +++ b/apps/catalog/app/shared/assembly-actions.test.ts @@ -49,11 +49,52 @@ describe('what is offered', () => { * pressed and nothing else about it. */ it('offers the press greyed out for a stack with no tool', () => { + expect(assemblyActions(stack({ toolGuid: null }), [])[0]).toEqual({ + kind: 'add', + label: 'Add to order list', + disabled: true, + }) + }) + + /** + * **The emptied box is not a dead end** (Paul, 2026-09-11: "if I have removed + * all the tools from an assembly on a feature, it should give me the option + * to remove the feature as the button. This is a spot you can get stuck + * currently"). The greyed press was the whole of what a row on the list with + * an emptied tree offered, so the one thing somebody had just said — *nothing + * goes here after all* — had no press to finish it. + */ + it('offers the row itself off the list where it is on it with no tool left', () => { expect(assemblyActions(stack({ toolGuid: null }), [])).toEqual([ { kind: 'add', label: 'Add to order list', disabled: true }, + { kind: 'drop', label: 'Remove feature from list', danger: true }, ]) }) + it('names the row it would take off, for each kind of row', () => { + const labelled = (subject: 'feature' | 'group' | 'assembly') => + assemblyActions(stack({ toolGuid: null }), [], true, subject).find( + (each) => each.kind === 'drop', + )?.label + expect(labelled('group')).toBe('Remove group from list') + expect(labelled('assembly')).toBe('Remove tool assembly from list') + }) + + /** Nothing to take off a list the row is not on yet — that is Cancel's job. */ + it('offers no removal where the row is not on the list yet', () => { + expect( + assemblyActions(stack({ toolGuid: null }), [], false).map((each) => each.kind), + ).not.toContain('drop') + }) + + /** + * The greyed press only stands where there is nothing to order. A stack with + * a tool in it has a press that works, so nothing is offered beside it. + */ + it('offers no removal while there is still a tool in the stack', () => { + expect(assemblyActions(stack(), []).map((each) => each.kind)).not.toContain('drop') + }) + it('offers Add to order list where the tool is not on the bill for this feature', () => { expect(assemblyActions(stack(), []).map((each) => each.kind)).toEqual(['add']) }) @@ -439,8 +480,23 @@ describe('what a whole assembly offers', () => { it('greys the one press where no stack of it has a tool', () => { expect( - offered([emptyAssembly('assembly-1', 'tap'), emptyAssembly('assembly-2', 'drill')], []), - ).toEqual([{ kind: 'add', label: 'Add to order list', disabled: true }]) + offered([emptyAssembly('assembly-1', 'tap'), emptyAssembly('assembly-2', 'drill')], []).map( + (each) => each.kind, + ), + ).toEqual(['add', 'drop']) + }) + + /** + * The press under the box reads every stack in the tree, so a threaded hole + * emptied of both its tap and its drill is the same dead end a single stack + * was (Paul, 2026-09-11). + */ + it('offers the feature off the list where the tap and the drill are both emptied', () => { + const [, second] = offered( + [emptyAssembly('assembly-1', 'tap'), emptyAssembly('assembly-2', 'drill')], + [], + ) + expect(second).toEqual({ kind: 'drop', label: 'Remove feature from list', danger: true }) }) /** The same reversal, over a group of stacks: an empty tree can still be kept. */ @@ -536,5 +592,8 @@ describe('orderingPress', () => { // Enter is the way *on*. Remove and Cancel are presses somebody makes. expect(orderingPress([{ kind: 'remove' }])).toBeNull() expect(orderingPress([{ kind: 'revert' }])).toBeNull() + /* And not the one that takes the row itself off: a key that can delete a + feature is a key nobody can press with confidence. */ + expect(orderingPress([{ kind: 'add', disabled: true }, { kind: 'drop' }])).toBeNull() }) }) diff --git a/apps/catalog/app/shared/assembly-actions.ts b/apps/catalog/app/shared/assembly-actions.ts index 018aa03..3c7cc13 100644 --- a/apps/catalog/app/shared/assembly-actions.ts +++ b/apps/catalog/app/shared/assembly-actions.ts @@ -33,6 +33,8 @@ import { ROLE_LABEL, isEmpty, type TreeAssembly } from './assembly-tree' * | --------------------------------------------- | ---------------------------- | * | nothing in it, and not a row yet | **Add feature to list** | * | nothing in it, on the list already | **Add to order list**, greyed | + * | | and **Remove feature from | + * | | list** under it | * | no tool yet — a holder or collet on its own | the same two | * | a tool, and not a row on the list yet | **Add to order list**, and | * | | it makes the row as well | @@ -92,6 +94,23 @@ export type AssemblyActionKind = * the only way to keep one used to be picking a tool for it. */ | 'list' + /** + * The row itself off the list, where there is nothing in it left to order + * (Paul, 2026-09-11: "if I have removed all the tools from an assembly on a + * feature, it should give me the option to remove the feature as the button. + * This is a spot you can get stuck currently"). + * + * A row on the list with an emptied tree offered one greyed press and nothing + * else: taking the last tool out of a stack is how somebody says *this face is + * not worth machining after all*, and the only way to finish that thought was + * to close the box, find the row and right-click it. The greyed press stays — + * it is what says the tree is what fills it in — and this stands under it, so + * both endings of an emptied box are in the box. + * + * It takes the row and everything it put on the order list, which is the same + * thing right-click → *Remove* has always done. + */ + | 'drop' /** What one press would put on the list, where it is not on it yet. */ export type Subject = 'feature' | 'group' | 'assembly' @@ -138,6 +157,19 @@ const LISTING: Readonly> = { assembly: null, } +/** + * What an emptied row's press takes off the list, named for the row it is. + * + * Every row kind can be emptied and every one of them can be dropped, so unlike + * {@link LISTING} there is no silence here: a part-level assembly with nothing + * in it is the clearest case of the three, since the row *is* its order. + */ +const DROPPING: Readonly> = { + feature: 'Remove feature from list', + group: 'Remove group from list', + assembly: 'Remove tool assembly from list', +} + /** * The press under a stack with nothing in it to order. * @@ -159,10 +191,21 @@ const LISTING: Readonly> = { * changes the button rather than moving it. `nothingToConfirm` is what says why * an ordering press cannot be made yet, beside the component being read. */ -const nothingYet = (onList: boolean, subject: Subject): AssemblyAction => { - const listing = onList ? null : LISTING[subject] +const nothingYet = (onList: boolean, subject: Subject): Array => { + if (onList) { + /* + **And a way out of the empty box** (Paul, 2026-09-11). The greyed press is + the first line — see `drop` — and the second is what an emptied row can + actually do, so the box is never a dead end. + */ + return [ + { kind: 'add', label: 'Add to order list', disabled: true }, + { kind: 'drop', label: DROPPING[subject], danger: true }, + ] + } + const listing = LISTING[subject] return listing === null - ? { kind: onList ? 'add' : 'confirm', label: 'Add to order list', disabled: true } + ? [{ kind: 'confirm', label: 'Add to order list', disabled: true }] : /* **The button is the whole of it** (Paul, 2026-09-11). It carried a note saying the row would go on the list marked incomplete, which is what the @@ -171,7 +214,7 @@ const nothingYet = (onList: boolean, subject: Subject): AssemblyAction => { sentences of amber under the press said it before there was anything to say it about. */ - { kind: 'list', label: listing } + [{ kind: 'list', label: listing }] } /** The line this stack would write, or null while it has no tool to write one for. */ @@ -406,7 +449,7 @@ export const assemblyActions = ( ): Array => { const line = lineOf(assembly) if (line === null) { - return [nothingYet(onList, subject)] + return nothingYet(onList, subject) } /* Not a row yet, so there is nothing to add *to*: one press makes the feature @@ -493,6 +536,7 @@ const groupSaid = ( * | The group | Offered | * | -------------------------------------------- | --------------------------- | * | no tool anywhere in it | **Add to order list**, greyed| + * | | and the row's own removal | * | not a row on the list yet | **Add to order list** | * | no stack of it on the bill | **Add to order list** | * | every stack on the bill, unchanged | **Remove from order list** | @@ -523,7 +567,7 @@ export const groupActions = ( return line === null ? [] : [{ stack, line, had: savedFor(stack, onSheet) }] }) if (parts.length === 0) { - return [nothingYet(onList, subject)] + return nothingYet(onList, subject) } if (!onList) { return [ @@ -560,8 +604,8 @@ export const groupActions = ( /** * The presses that put an assembly on the order list, or change what is on it. * - * `remove` and `revert` are the two that take something *off* or put it back, - * and the difference matters twice over: the box closes on an order being + * `remove`, `drop` and `revert` are the ones that take something *off* or put + * it back, and the difference matters twice over: the box closes on an order being * placed (Paul, 2026-09-10: "clicking 'Add to Order List' should close the * feature, group, or tool assembly dialog"), and Enter presses one of these and * never one of those — a key that could silently remove an order is a key diff --git a/apps/catalog/app/shared/assembly-gaps.ts b/apps/catalog/app/shared/assembly-gaps.ts new file mode 100644 index 0000000..82af7b6 --- /dev/null +++ b/apps/catalog/app/shared/assembly-gaps.ts @@ -0,0 +1,110 @@ +import type { ReachCurve } from '@toolpath/part-contracts' +import type { CatalogTool, Holder, Margins } from '@toolpath/catalog-data' +import type { ViewerAssembly } from '@toolpath/tool-drawing/geometry' +import { assemblyOutline } from '@toolpath/tool-drawing/geometry' +import { tightestGaps, type Gaps } from '@toolpath/tool-drawing/clearance' +import { getProfile } from './catalog' +import { toViewerAssembly, type DrawableAssembly } from './tool-drawing-input' + +/** + * The room a stack actually leaves around a feature, measured once. + * + * Two things ask it now — the sentence under the drawing, and the three boxes + * beside it that let a shop set any one of the numbers and read the other two + * — and a gap worked out twice is the defect `stickout.ts` was written to end, + * one package over. So the outline and `tightestGaps` are called here and + * nowhere else: `catalog-drawing.tsx` reads this for its caption and its + * overlay, and `clearance-entry.tsx` for its boxes, so the number in a box is + * the number in the sentence by construction rather than by agreement. + * + * **This is a measurement, not a verdict.** `Gap.gap` is the room found, in + * millimetres and negative into the material, whatever the shop asked for; + * only `Gap.clears` reads the margins. The verdict — whether a stack clears, + * and the least stickout at which it would — stays `clearance()` in + * `@toolpath/tool-support`, which has a dozen callers that draw nothing. + * + * ## The two are measured off different holders, on purpose + * + * `clearance()` sweeps the parametric holder: a nose, a body and a flange off + * the vendor's published numbers. This measures the holder the drawing draws, + * which is the measured silhouette wherever one exists. They can disagree, and + * `CatalogDrawingProps.measured` already says that noticing the disagreement is + * the reason both are kept. The boxes take the same side as the picture — a + * shop reads the number against the holder it can see — and mark a gap that + * comes up short of what was asked rather than quietly restating the ask. + */ + +/** The stack a measurement is taken on, with the holder it is drawn from. */ +export type MeasuredStack = DrawableAssembly + +/** + * The stack as the drawing package sees it, measured where it has been. + * + * The one construction of a `ViewerAssembly` for a stack on the part page, so + * the gaps are measured off exactly the silhouette that gets drawn. + */ +export const viewerFor = (stack: MeasuredStack, measured = true): ViewerAssembly => { + const profile = measured && stack.holder !== null ? getProfile(stack.holder.guid) : null + return toViewerAssembly(stack, profile) +} + +/** How wide the cut itself is, which is what every gap is measured past. */ +export const cuttingRadiusOf = (tool: Pick): number => + (tool.geometry.DC ?? 0) / 2 + +/** + * Both tightest gaps for one stack against one feature, or null with nothing + * to measure — no feature, or a form the package draws no outline for. + */ +export const gapsFor = ( + viewer: ViewerAssembly, + curve: ReachCurve | null, + cuttingRadius: number, + margins: Margins, +): Gaps | null => { + if (curve === null) { + return null + } + const outline = assemblyOutline(viewer) + if (outline === null) { + return null + } + return tightestGaps(outline.segments, curve, cuttingRadius, margins) +} + +/** What the three boxes read: the room found, in millimetres, either way. */ +export interface MeasuredRoom { + /** Up from the material to the part standing over it, mm. */ + readonly axial: number | null + /** Sideways to a wall taller than the part, mm. Null where none stands taller. */ + readonly radial: number | null +} + +export const NOTHING_MEASURED: MeasuredRoom = { axial: null, radial: null } + +/** The two gaps as bare numbers, which is all a box can show. */ +export const roomIn = (gaps: Gaps | null): MeasuredRoom => + gaps === null + ? NOTHING_MEASURED + : { axial: gaps.axial?.gap ?? null, radial: gaps.radial?.gap ?? null } + +/** + * The room this stack leaves at one stickout, measured off the drawn holder. + * + * The whole of what the boxes need from the geometry: hand it a length below + * the holder and it says what clearance that length buys. + */ +export const roomAt = ( + stack: { readonly tool: CatalogTool; readonly holder: Holder | null }, + stickout: number | null, + curve: ReachCurve | null, + margins: Margins, +): MeasuredRoom => + roomIn( + gapsFor( + viewerFor({ tool: stack.tool, holder: stack.holder, stickout }), + curve, + cuttingRadiusOf(stack.tool), + margins, + ), + ) diff --git a/apps/catalog/app/shared/assembly-tree.test.ts b/apps/catalog/app/shared/assembly-tree.test.ts index 982e073..5c919d9 100644 --- a/apps/catalog/app/shared/assembly-tree.test.ts +++ b/apps/catalog/app/shared/assembly-tree.test.ts @@ -17,6 +17,7 @@ import { isOverride, linesOf, nextAssemblyId, + orderedAs, readTrees, groupOf, removeAssembly, @@ -309,6 +310,32 @@ describe('where the tree opens', () => { }) }) +describe('the stack a line of the bill stands for', () => { + const second: TreeAssembly = { + id: 'assembly-2', + role: 'cut', + toolGuid: 'tool-b', + holderGuid: null, + colletGuid: null, + } + + it("finds the stack holding the line's tool", () => { + expect(orderedAs([filled, second], 'tool-b')?.id).toBe('assembly-2') + }) + + it('answers by what the stack was ordered as, not what stands in it now', () => { + // The cutter was swapped after the line was written; the line still belongs + // to this stack, which is what keeps a press on it opening that stack. + const swapped: TreeAssembly = { ...filled, toolGuid: 'tool-c', orderedTool: 'tool-a' } + expect(orderedAs([swapped, second], 'tool-a')?.id).toBe('assembly-1') + expect(orderedAs([swapped, second], 'tool-c')).toBeNull() + }) + + it('has no stack for a line no tree holds', () => { + expect(orderedAs([filled], 'tool-z')).toBeNull() + }) +}) + describe('the bill', () => { it('writes a line only for a stack that has a tool', () => { expect(linesOf([filled, emptyAssembly('assembly-2', 'cut')])).toEqual([ diff --git a/apps/catalog/app/shared/assembly-tree.ts b/apps/catalog/app/shared/assembly-tree.ts index aecc8ac..99e5637 100644 --- a/apps/catalog/app/shared/assembly-tree.ts +++ b/apps/catalog/app/shared/assembly-tree.ts @@ -540,6 +540,27 @@ export const assemblyNamed = ( id: string | null, ): TreeAssembly | null => (id === null ? null : (assemblies.find((each) => each.id === id) ?? null)) +/** + * The stack a line of the bill stands for, by the tool it was ordered as. + * + * `orderedTool` is what a stack stands as on the order list, so it is asked + * first; a tree written before that field existed answers by the tool standing + * in it. Swapping the cutter therefore keeps the line pointing at its own + * stack — which is what lets a press on a line of the order list reach the + * assembly it came from. + * + * One rule in one place: the order list names a line's assembly with it + * ({@link assemblyName}), the badges say where a component is already spoken + * for (`component-usage.ts`), and a press on that line opens it. Three answers + * to "which stack is this line" is how a badge and a tree come to name the same + * stack differently. + */ +export const orderedAs = ( + assemblies: ReadonlyArray, + toolGuid: string, +): TreeAssembly | null => + assemblies.find((each) => (each.orderedTool ?? each.toolGuid) === toolGuid) ?? null + /** * The first slot worth opening on a tree nobody has clicked into. * diff --git a/apps/catalog/app/shared/clearance-entry.test.ts b/apps/catalog/app/shared/clearance-entry.test.ts new file mode 100644 index 0000000..5a98739 --- /dev/null +++ b/apps/catalog/app/shared/clearance-entry.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from 'vitest' +import type { Margins } from '@toolpath/catalog-data' +import { askFor, boxesFor, shownIn, type RequiredAt } from './clearance-entry' + +/** The sheet's own figures, 0.020 in both ways, which is what the page opens on. */ +const SHEET: Margins = { axial: 0.508, radial: 0.508 } + +/** + * A stand-in for `clearance().requiredStickout`: the holder has to stand one + * millimetre off the tip to touch nothing, plus whatever room is asked for. + * + * Linear in both axes, which the real sweep is not, and that is the point — + * this module decides *what* to ask and what the answer means, and a fake that + * can be read at a glance is what keeps those two questions apart. + */ +const required: RequiredAt = (margins) => 1 + margins.axial + margins.radial + +describe('askFor', () => { + it('asks the stack for nothing while all three boxes are the app’s', () => { + expect(askFor(null, SHEET, required)).toEqual({ + stickout: null, + margins: SHEET, + solve: SHEET, + }) + }) + + it('takes a stated length as the length, and leaves the limits alone', () => { + expect(askFor({ field: 'below', value: 20 }, SHEET, required)).toEqual({ + stickout: 20, + margins: SHEET, + solve: SHEET, + }) + }) + + /** + * The rule of 2026-09-11: a stated clearance is solved for **alone**. With + * the other limit left in, the length that came back would be the least that + * met both, and the box the shop had just typed into would be the one number + * on screen that did not drive anything. + */ + it('solves a stated axial clearance with the radial limit dropped', () => { + const ask = askFor({ field: 'axial', value: 2 }, SHEET, required) + expect(ask.solve).toEqual({ axial: 2, radial: 0 }) + expect(ask.stickout).toBe(3) + }) + + it('solves a stated radial clearance the same way about', () => { + const ask = askFor({ field: 'radial', value: 2 }, SHEET, required) + expect(ask.solve).toEqual({ axial: 0, radial: 2 }) + expect(ask.stickout).toBe(3) + }) + + /** + * The drawing is told both limits, not the one being solved for: the overlay + * draws a margin line and writes a verdict, and drawing them against a nought + * would say the stack clears a wall it is a thou off. + */ + it('tells the drawing both limits, with the stated one in place', () => { + expect(askFor({ field: 'axial', value: 2 }, SHEET, required).margins).toEqual({ + axial: 2, + radial: 0.508, + }) + }) + + it('leaves the length to the stack where the holder states no nose', () => { + expect(askFor({ field: 'axial', value: 2 }, SHEET, () => null).stickout).toBeNull() + }) +}) + +describe('boxesFor', () => { + const drawn = { stickout: 20, overLimit: false } + + it('reads all three off the stack while nothing has been stated', () => { + const boxes = boxesFor(null, SHEET, drawn, { axial: 0.6, radial: 0.9 }) + expect(boxes.below).toEqual({ + entered: null, + value: 20, + clamped: false, + overLimit: false, + }) + expect(boxes.axial).toEqual({ + entered: null, + value: 0.6, + asked: 0.508, + short: false, + held: null, + }) + expect(boxes.radial).toEqual({ + entered: null, + value: 0.9, + asked: 0.508, + short: false, + held: null, + }) + }) + + it('holds a box that was typed into to what was typed', () => { + const boxes = boxesFor({ field: 'axial', value: 2 }, SHEET, drawn, { axial: 2, radial: 0.9 }) + expect(boxes.axial.entered).toBe(2) + expect(boxes.axial.asked).toBe(2) + expect(boxes.axial.short).toBe(false) + }) + + /** + * The whole reason the other limit may be dropped for the solve: it still + * marks its box. A stated axial clearance that walks the holder into a wall + * says so on the radial box rather than on nothing. + */ + it('marks the untouched axis when the stated one walks it under the sheet', () => { + const boxes = boxesFor({ field: 'axial', value: 2 }, SHEET, drawn, { axial: 2, radial: 0.2 }) + expect(boxes.radial.short).toBe(true) + expect(boxes.radial.asked).toBe(0.508) + }) + + it('marks a gap that has gone into the material', () => { + expect(boxesFor(null, SHEET, drawn, { axial: -0.3, radial: 0.9 }).axial.short).toBe(true) + }) + + it('does not mark a gap that meets its limit exactly', () => { + expect(boxesFor(null, SHEET, drawn, { axial: 0.508, radial: 0.508 }).axial.short).toBe(false) + }) + + /** + * Paul, 2026-09-11: "why is it overriding some values I enter? I am entering + * 0.03 in and it is jumping to 0.056 in" — the stack was already as short as + * its flutes allow, so every length it can be set to leaves more room than + * that. The entry stands and this says the stack could not meet it. + */ + it('says when the stack leaves more room than was asked for', () => { + const boxes = boxesFor({ field: 'axial', value: 0.762 }, SHEET, drawn, { + axial: 1.422, + radial: 0.9, + }) + expect(boxes.axial.held).toBe('more') + expect(shownIn(boxes.axial)).toBe(0.762) + }) + + it('says when the stack cannot clear by as much as was asked', () => { + const boxes = boxesFor({ field: 'axial', value: 2 }, SHEET, drawn, { axial: 1, radial: 0.9 }) + expect(boxes.axial.held).toBe('less') + }) + + it('says nothing about a met entry, or about a box nobody typed in', () => { + expect( + boxesFor({ field: 'axial', value: 2 }, SHEET, drawn, { axial: 2, radial: 0.9 }).axial.held, + ).toBeNull() + expect(boxesFor(null, SHEET, drawn, { axial: 2, radial: 0.9 }).axial.held).toBeNull() + }) + + /** + * **The box obeys the entry** (Paul, 2026-09-11: "it should always obey the + * value I enter and revise the other two"). Every other box is a reading off + * the stack that was actually drawn, which is the only thing they can + * honestly be — and the box that was typed in keeps the number that was typed + * in, with {@link ClearanceBox.held} saying where the stack could not follow. + */ + it('shows the entry where there is one and the reading everywhere else', () => { + const boxes = boxesFor({ field: 'axial', value: 0.762 }, SHEET, drawn, { + axial: 1.422, + radial: 0.9, + }) + expect(shownIn(boxes.axial)).toBe(0.762) + expect(shownIn(boxes.radial)).toBe(0.9) + expect(shownIn(boxes.below)).toBe(20) + }) + + /** + * The tool is floored at its flutes and capped at what the holder can still + * grip, so a stated length outside that is a length the stack will not take. + * The box still shows it, and `clamped` is what says the drawing is elsewhere. + */ + it('shows a stated length the stack would not take, rather than the length it took', () => { + const boxes = boxesFor( + { field: 'below', value: 60 }, + SHEET, + { ...drawn, stickout: 42 }, + { + axial: 0.6, + radial: 0.9, + }, + ) + expect(shownIn(boxes.below)).toBe(60) + expect(boxes.below.clamped).toBe(true) + expect(boxes.below.value).toBe(42) + }) + + it('says nothing about an axis nothing was measured on', () => { + const boxes = boxesFor(null, SHEET, drawn, { axial: 0.6, radial: null }) + expect(boxes.radial.value).toBeNull() + expect(boxes.radial.short).toBe(false) + }) + + it('does not mark a stated length the stack took', () => { + expect( + boxesFor({ field: 'below', value: 20 }, SHEET, drawn, { axial: 0.6, radial: 0.9 }).below + .clamped, + ).toBe(false) + }) + + it('carries the stack’s own over-limit through', () => { + expect( + boxesFor(null, SHEET, { stickout: 42, overLimit: true }, { axial: 0.6, radial: 0.9 }).below + .overLimit, + ).toBe(true) + }) +}) diff --git a/apps/catalog/app/shared/clearance-entry.ts b/apps/catalog/app/shared/clearance-entry.ts new file mode 100644 index 0000000..eadc0a4 --- /dev/null +++ b/apps/catalog/app/shared/clearance-entry.ts @@ -0,0 +1,224 @@ +import type { Margins } from '@toolpath/catalog-data' +import type { MeasuredRoom } from './assembly-gaps' + +/** + * Three numbers, any one of which may be the one a shop states. + * + * A stack, a feature and a length below the holder decide each other. Until + * now the page picked one corner of that and printed it: the two clearances + * were the sheet's knobs, fixed at 0.020 in and editable nowhere, and the + * length below the holder was whatever those knobs made necessary. That + * answers one question — "how far out do I have to set this tool" — and cannot + * answer either of the two a machinist asks just as often: "what room do I + * actually have at the length I set it up at", and "how far out would I have to + * go to keep a thirty-thou wall off the holder". + * + * So all three are boxes, **one of them is the shop's and the other two follow + * from it**, and every box says both numbers — what was entered, and what the + * geometry gives back. The last field touched is the one that drives; the other + * two are readings off the stack it produced. + * + * ## Which direction each edit solves in + * + * | Edited | How the other two are reached | + * | ------------------- | --------------------------------------------------- | + * | Length below holder | measured: the room left at that length | + * | Axial clearance | solved: the least length that leaves that much, then | + * | | the radial room measured at it | + * | Radial clearance | the same, the other way about | + * + * Both directions already exist in `@toolpath/tool-support` and neither is + * reimplemented here: `clearance().requiredStickout` solves, and + * `assembly-gaps.ts` measures. This module is only which of them to ask, with + * what, and what the three boxes then say — which is the part worth testing + * without a catalog, a curve or a drawing in the room. + * + * ## A clearance is asked alone + * + * Entering an axial clearance solves for that clearance **and nothing else**: + * the radial limit is dropped to nought for the solve, so the length that comes + * back is the least that meets what was asked rather than the least that meets + * what was asked and something else besides. Paul's rule (2026-09-11): the + * other two adjust to the condition set by the edit. What the dropped limit + * still does is mark its box — a radial room that lands under the sheet's own + * figure says so, rather than being silently accepted because nobody typed it. + */ + +/** Which of the three a shop stated. */ +export type ClearanceField = 'below' | 'axial' | 'radial' + +/** What a shop stated, in millimetres, or nothing while all three are the app's. */ +export interface ClearanceEdit { + readonly field: ClearanceField + /** Millimetres, the basis everything downstream of a box is held in. */ + readonly value: number +} + +/** + * Float slack, in millimetres. + * + * A tenth of a micron: far under anything a shop can set a tool to and far + * over the noise in a sweep of a few dozen segments, so a gap that meets its + * limit exactly never reads as short. + */ +const SLACK = 1e-6 + +/** + * What to ask the stack for, given what was stated. + * + * Two answers rather than one, because the margins have two jobs and they are + * not the same job. `solve` is what the length is worked out from — one axis + * only, per the rule above. `margins` is what the drawing is told, so the + * overlay's margin line and its verdict are drawn against the limits the boxes + * are showing, both of them, rather than against the one being solved for. + */ +export interface ClearanceAsk { + /** The length to draw at, or null to let the stack choose as it does today. */ + readonly stickout: number | null + /** The limits the drawing and its verdict are held to. */ + readonly margins: Margins + /** The limits the length was solved from: one axis, or the sheet's where nothing was stated. */ + readonly solve: Margins +} + +/** + * The least length below the holder that leaves a given room. + * + * `clearance().requiredStickout` behind a name, so this module can be tested + * with a curve made of two numbers instead of a part report. Null where the + * holder states no nose and there is nothing to sweep. + */ +export type RequiredAt = (margins: Margins) => number | null + +export const askFor = ( + edit: ClearanceEdit | null, + defaults: Margins, + requiredAt: RequiredAt, +): ClearanceAsk => { + if (edit === null) { + return { stickout: null, margins: defaults, solve: defaults } + } + if (edit.field === 'below') { + return { stickout: edit.value, margins: defaults, solve: defaults } + } + const solve: Margins = + edit.field === 'axial' ? { axial: edit.value, radial: 0 } : { axial: 0, radial: edit.value } + return { + stickout: requiredAt(solve), + margins: { ...defaults, [edit.field]: edit.value }, + solve, + } +} + +/** One clearance box: what was asked of this axis, and what it gives. */ +export interface ClearanceBox { + /** What the shop typed here, or null while this box is the app's answer. */ + readonly entered: number | null + /** The room measured at the drawn length, mm. Null where nothing was measured. */ + readonly value: number | null + /** What this axis is being held to: what was typed, or the sheet's own figure. */ + readonly asked: number + /** The measured room is under what this axis is held to. */ + readonly short: boolean + /** + * Stated, and the stack does not give exactly that — `'more'` where it gives + * more room than was asked, `'less'` where it cannot give that much. + * + * **The number a shop typed is not always available** (Paul, 2026-09-11: "why + * is it overriding some values I enter? I am entering 0.03 in and it is + * jumping to 0.056 in"). Ask for a thirty-thou axial gap on a stack whose + * shortest setting already leaves fifty-six, and there is no length that + * gives thirty: the tool is floored where its flutes clear the collet, and + * every length above that floor gives *more* room, not less. The same happens + * the other way when clearing needs more than the tool can be set out at. + * + * **The box obeys the entry** (Paul, again, 2026-09-11: "it should always + * obey the value I enter and revise the other two"). The number a shop typed + * stays in the box it was typed into, whatever the stack then does; this is + * what says the stack could not be set there, so the box is not lying about a + * length it cannot reach. The other two boxes are read off the stack that was + * actually drawn, which is the only thing they can honestly be. + */ + readonly held: 'more' | 'less' | null +} + +/** The length box, which is a length rather than a limit and marks differently. */ +export interface BelowBox { + readonly entered: number | null + /** The length actually drawn, after the stack's own floor and ceiling. */ + readonly value: number | null + /** A stated length the stack would not take, so the number beside it is not it. */ + readonly clamped: boolean + /** What clearing the part needs is past what this tool can be set out at. */ + readonly overLimit: boolean +} + +export interface ClearanceBoxes { + readonly below: BelowBox + readonly axial: ClearanceBox + readonly radial: ClearanceBox +} + +/** + * What a box shows: the entry where there is one, and the reading otherwise. + * + * The whole of "obey the value I enter". A box that was typed into shows that + * number for as long as it stands; every other box shows what the stack gives. + */ +export const shownIn = (box: ClearanceBox | BelowBox): number | null => box.entered ?? box.value + +/** What the stack settled on, once its own floor and ceiling have had the ask. */ +export interface DrawnLength { + /** The length drawn, mm. */ + readonly stickout: number | null + /** True when what clearing needs is more than the tool allows. */ + readonly overLimit: boolean +} + +const boxFor = ( + field: 'axial' | 'radial', + edit: ClearanceEdit | null, + defaults: Margins, + room: MeasuredRoom, +): ClearanceBox => { + const entered = edit?.field === field ? edit.value : null + const asked = entered ?? defaults[field] + const value = room[field] + const missed = entered !== null && value !== null && Math.abs(value - entered) > SLACK + return { + entered, + value, + asked, + short: value !== null && value < asked - SLACK, + held: missed && value !== null && entered !== null ? (value > entered ? 'more' : 'less') : null, + } +} + +/** + * The three boxes, after the stack has answered. + * + * Asked last rather than as part of {@link askFor} because a stated length is + * not necessarily the length drawn — the stack floors it at the flutes and caps + * it at what the holder can still grip — and the room has to be measured at the + * length that was actually drawn. A box showing the room at a length nobody is + * looking at would be the worst of the three readings. + */ +export const boxesFor = ( + edit: ClearanceEdit | null, + defaults: Margins, + drawn: DrawnLength, + room: MeasuredRoom, +): ClearanceBoxes => { + const entered = edit?.field === 'below' ? edit.value : null + return { + below: { + entered, + value: drawn.stickout, + clamped: + entered !== null && drawn.stickout !== null && Math.abs(drawn.stickout - entered) > SLACK, + overLimit: drawn.overLimit, + }, + axial: boxFor('axial', edit, defaults, room), + radial: boxFor('radial', edit, defaults, room), + } +} diff --git a/apps/catalog/app/shared/component-usage.ts b/apps/catalog/app/shared/component-usage.ts index e6f8458..7becfb0 100644 --- a/apps/catalog/app/shared/component-usage.ts +++ b/apps/catalog/app/shared/component-usage.ts @@ -1,4 +1,4 @@ -import { assemblyName, type TreeAssembly } from './assembly-tree' +import { assemblyName, orderedAs, type TreeAssembly } from './assembly-tree' import type { Choice } from './setup-sheet' /** @@ -43,8 +43,8 @@ export interface UsedRow { * off, or a tree the browser has since forgotten — is still a use, and it takes * the tool's own name rather than inventing a stack that is not there. */ -const stackFor = (stacks: ReadonlyArray, line: Choice): TreeAssembly | undefined => - stacks.find((each) => (each.orderedTool ?? each.toolGuid) === line.toolGuid) +const stackFor = (stacks: ReadonlyArray, line: Choice): TreeAssembly | null => + orderedAs(stacks, line.toolGuid) /** * Every component on the bill, by guid, with where it is used. @@ -59,7 +59,7 @@ export const usesByGuid = (rows: ReadonlyArray): Map const stack = stackFor(row.stacks, line) const use: Use = { feature: row.name, - assembly: stack === undefined ? '' : assemblyName(row.stacks, stack), + assembly: stack === null ? '' : assemblyName(row.stacks, stack), itemId: row.itemId, } for (const guid of [line.toolGuid, line.holderGuid, line.colletGuid]) { diff --git a/apps/catalog/app/shared/type.ts b/apps/catalog/app/shared/type.ts index 46afd3a..e5195d7 100644 --- a/apps/catalog/app/shared/type.ts +++ b/apps/catalog/app/shared/type.ts @@ -92,3 +92,21 @@ export const TABLE_FACE = 'font-body tabular-nums' * dimmed merely for being prose. */ export const TABLE_INK = 'text-zinc-300' + +/** + * The heading row's ground — the accent, because that row is where the + * answering happens. + * + * Every column heading sorts the list and opens the filter that narrows it, so + * the row of them is the one strip of a table a shop presses rather than reads + * (Paul, 2026-09-11: "blue is our selection color and that's a spot to make + * selections"). `--table-head` in `styles.css` is the colour and carries the + * reasoning; this is the pair of classes that reaches it. + * + * **Both halves, and both with `!`.** `@toolpath/ui`'s `HeaderRow` paints + * itself `!bg-white dark:!bg-zinc-900`, and the table library sets a third + * background through its own theme. `cn` is `twMerge`, so naming the same two + * variants takes the kit's pair off — one of them alone would leave the other + * theme's white or zinc standing. + */ +export const TABLE_HEAD = '!bg-(--table-head) dark:!bg-(--table-head)' diff --git a/apps/catalog/app/styles.css b/apps/catalog/app/styles.css index d2e1152..0aa1be0 100644 --- a/apps/catalog/app/styles.css +++ b/apps/catalog/app/styles.css @@ -29,6 +29,27 @@ :root { /** A lift off the page, the same weight either way round. */ --row-stripe: rgba(255, 255, 255, 0.045); + /** + * The ground under a table's headings. + * + * The heading row is where a list is narrowed and sorted, and the accent is + * already what this page paints a set filter with, so the row that takes + * those answers carries the same colour (Paul, 2026-09-11: "blue is our + * selection color and that's a spot to make selections"). + * + * **The same blue the chosen feature is drawn in** (Paul, 2026-09-11: "the + * blue should match the feature or group blue shown in that dialog in the + * upper left"). That box is `bg-info/15` on the page's own ground, so this + * is that composite written out: 15 % of the accent in `srgb`, which is what + * a 15 % alpha over `--color-zinc-950` resolves to. One blue means one + * thing, and two near-misses of it read as two different states. + * + * Opaque rather than an alpha, because the heading row is sticky and the + * rows scroll underneath it. Both halves are theme tokens, so light mode + * needs no second value — the same mix over the mirrored ramp is a pale + * blue band. + */ + --table-head: color-mix(in srgb, var(--color-info) 15%, var(--color-zinc-950)); color-scheme: dark; font-family: var(--font-body); background: var(--color-zinc-950); diff --git a/apps/catalog/tests/on-the-part.spec.ts b/apps/catalog/tests/on-the-part.spec.ts index 1ed4921..9f7f6b3 100644 --- a/apps/catalog/tests/on-the-part.spec.ts +++ b/apps/catalog/tests/on-the-part.spec.ts @@ -1200,6 +1200,46 @@ test('opens a group, with the assembly it was ordered with, from its row', async await expect(tree.getByRole('button', { name: /^TOOL for / })).toContainText(tool) }) +/** + * **A press on a line opens the stack that line came from** (Paul, 2026-09-11: + * "when I click on a specific tool assembly row in the order list, focus should + * go to the top line component of the specific assembly I clicked"). + * + * The tree opened on `firstNode` whichever line was pressed — the first + * *unanswered* slot of the row — so on a feature answered by two stacks the one + * somebody pressed was not the one on screen, and the second assembly could + * only be reached by hunting for it in the tree. `orderedAs` is the rule that + * turns a line back into its stack, and the tool slot is that stack's top line. + */ +test('opens the stack a pressed line of the order list came from', async ({ page }) => { + const tree = page.locator('[data-assembly-tree]') + const list = await orderList(page) + + // One feature answered by two stacks, the second named so its line says which + // stack it is — `assemblyOf` is what puts that name on the line. + await ready(page) + await keepFeature(page) + await buildStack(page) + await tree.getByRole('button', { name: 'Add assembly' }).click() + await tree.getByRole('button', { name: 'TOOL for assembly-2' }).click() + const tools = page.getByRole('grid').first() + await expect(tools.getByRole('row').nth(1)).toBeVisible() + await tools.getByRole('row').nth(1).click() + await tree.getByRole('button', { name: 'Assembly 2', exact: true }).click() + await tree.getByRole('textbox').fill('Finisher') + await tree.getByRole('textbox').press('Enter') + // One press covers every stack in the box, and closes it behind them. + await orderPress(page).click() + + await list.getByText('Finisher').click() + + await expect(tree).toBeVisible() + await expect(tree.getByRole('button', { name: 'TOOL for assembly-2' })).toHaveAttribute( + 'aria-current', + 'true', + ) +}) + /** * **The + Tool Assembly tree has no second stack** (Paul, 2026-09-08: "we can * also remove the add assembly button from + Tool Assembly"). It answers no @@ -2628,6 +2668,35 @@ test.describe('the tool assembly tree', () => { await expect(again.getByRole('button', { name: 'Add to order list' })).toHaveCount(0) }) + /** + * **An emptied box is not a dead end** (Paul, 2026-09-11: "if I have removed + * all the tools from an assembly on a feature, it should give me the option + * to remove the feature as the button. This is a spot you can get stuck + * currently"). Clearing the last tool left the greyed *Add to order list* and + * nothing else: the thing somebody had just said — nothing goes here after + * all — had no press in the box to finish it, and the row went on being + * ordered by lines the tree no longer showed. + */ + test('offers the feature off the list once the last tool is cleared', async ({ page }) => { + await ready(page) + await keepFeature(page) + const tree = await buildStack(page) + await tree.getByRole('button', { name: 'Add to order list' }).click() + + const again = await openRow(page) + await again.getByRole('button', { name: 'Clear the tool' }).click() + + // The press that orders stays where it is, greyed — it is what says the + // tree is what fills it in — and the way out stands under it. + await expect(again.getByRole('button', { name: 'Add to order list' })).toBeDisabled() + await again.getByRole('button', { name: 'Remove feature from list' }).click() + + // The row goes, and the box goes with it: an editor for a row that is no + // longer on the list is a form about nothing. + await expect(tree).toBeHidden() + await expect(await orderList(page)).toBeHidden() + }) + /** * **A stack can be called something, and the list says so** (Paul, * 2026-09-08: "I need to be able to name tool assemblies", and "it still From d4f6ea49690f427d09923850414cd34e9cad7ed3 Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Fri, 11 Sep 2026 13:32:29 -0400 Subject: [PATCH 02/19] text fixes --- AGENTS.md | 2 +- .../app/components/assembly-tree-panel.tsx | 52 ++++++++--------- apps/catalog/app/components/group-editor.tsx | 37 +++++++----- .../app/components/selection-panel.tsx | 23 ++++---- apps/catalog/app/components/thread-picker.tsx | 10 ++-- apps/catalog/app/shared/type.ts | 57 ++++++++++++++++++- apps/catalog/app/type-scale.test.ts | 45 +++++++++++++++ 7 files changed, 165 insertions(+), 61 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7b07c7b..e9df555 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -275,7 +275,7 @@ application unless that application says otherwise. | 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` | +| 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` | 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}
@@ -598,14 +597,13 @@ export const AssemblyTreePanel = ({
))} 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 ? ( -

+

Nothing in this group yet.

) : ( @@ -210,7 +216,10 @@ export const GroupEditor = ({ size="sm" aria-label={`Take ${nameOf(tag)} out of the group`} onClick={() => onDrop(tag)} - className="text-2xs focus-visible:ring-info/60 flex items-center gap-1 rounded border border-zinc-800 bg-zinc-900 px-1.5 py-0.5 text-zinc-300 hover:border-zinc-700 hover:text-zinc-100 focus-visible:ring-1 focus-visible:outline-none" + className={cn( + DIALOG_TEXT, + 'focus-visible:ring-info/60 flex items-center gap-1 rounded border border-zinc-800 bg-zinc-900 px-1.5 py-0.5 hover:border-zinc-700 hover:text-zinc-100 focus-visible:ring-1 focus-visible:outline-none', + )} > {nameOf(tag)}
))} diff --git a/apps/catalog/app/components/thread-picker.tsx b/apps/catalog/app/components/thread-picker.tsx index e17cb0d..8578b30 100644 --- a/apps/catalog/app/components/thread-picker.tsx +++ b/apps/catalog/app/components/thread-picker.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Combobox } from '@toolpath/ui' +import { Combobox, cn } from '@toolpath/ui' import { formatLength, type UnitSystem } from '@toolpath/tool-support' import { THREADS, @@ -13,7 +13,7 @@ import { type ThreadSpec, } from 'shared/threads' import { CatalogComboboxButton } from './catalog-combobox-button' -import { SECTION_LABEL } from 'shared/type' +import { DIALOG_NOTE, DIALOG_VALUE, SECTION_LABEL } from 'shared/type' /** * How this hole is made, and for what thread. @@ -97,9 +97,9 @@ export const ThreadPicker = ({ holeDiameter, mode, spec, onChange, unit }: Threa
-
+
Modeled hole diameter: - ⌀{formatLength(holeDiameter, unit)} + ⌀{formatLength(holeDiameter, unit)}
{/* @@ -111,7 +111,7 @@ export const ThreadPicker = ({ holeDiameter, mode, spec, onChange, unit }: Threa first group in the list, each saying what it read as and by how much the model is off it. */} -
+
Thread: each.name)]} diff --git a/apps/catalog/app/shared/type.ts b/apps/catalog/app/shared/type.ts index e5195d7..e6b1cdf 100644 --- a/apps/catalog/app/shared/type.ts +++ b/apps/catalog/app/shared/type.ts @@ -22,7 +22,16 @@ * carry the emphasis instead; if a real bold mono is ever wanted, the weight * goes in the font request first and this comment comes out. * - * `app/type-scale.test.ts` is the sensor for all three, in the same file that + * **A dialog says a thing one way.** The three boxes over the part — a feature, + * a group, a tool assembly — had fifteen recipes between them: three sizes, two + * faces, two weights and six greys, with the same job done differently in each + * (Paul, 2026-09-11: "can we get less text sizes and types in the feature, + * group, and tool assembly dialogs?"). `DIALOG_*` below is the whole vocabulary + * they are allowed, and `type-scale.test.ts` § "names no type of its own" is the + * sensor: those components write no `text-*` or `font-*` class at all, so a + * sixteenth recipe cannot be typed into one of them. + * + * `app/type-scale.test.ts` is the sensor for all of it, in the same file that * pins the scale — a rule about type without a check on it is how this drifted. */ @@ -110,3 +119,49 @@ export const TABLE_INK = 'text-zinc-300' * theme's white or zinc standing. */ export const TABLE_HEAD = '!bg-(--table-head) dark:!bg-(--table-head)' + +/** + * The title of a dialog: the one thing the box is about. + * + * The reading's name in the feature box, and nothing else — a box has one + * subject, so a second thing set this way is a second subject. + */ +export const DIALOG_TITLE = 'text-sm font-semibold text-zinc-100' + +/** + * An answer inside a dialog: a measured value, a catalog number, a thing chosen. + * + * Add `font-mono` where it is a number or an identifier; the face is the only + * thing a value ever varies by. The ink is the page's brightest because a value + * is what somebody opened the box to read. + */ +export const DIALOG_VALUE = 'text-xs text-zinc-100' + +/** + * A sentence the box says to somebody — an offer, a caution, an explanation. + * + * Distinct from {@link DIALOG_NOTE} by ink alone, and the distinction is the + * job: this is prose addressed to a reader, that is a caption attached to a + * value. Prose is the brighter of the two because it is read rather than + * glanced at, and because both of the boxes it sits in have a ground of their + * own under it. + */ +export const DIALOG_TEXT = 'text-2xs text-zinc-300' + +/** + * A caption, a hint or a status: whatever names or qualifies an answer. + * + * The label under a measurement, the machining direction beside a name, the + * line saying what is still missing before a stack can be ordered. + */ +export const DIALOG_NOTE = 'text-2xs text-zinc-500' + +/** + * Where there is no value at all — **an ink, over one of the recipes above**. + * + * The same rule the tables keep (`TABLE_INK`): colour in a dialog means + * something happened to that value, and the one thing that happens most often + * is that there is nothing there yet. An empty slot and an empty group are the + * same fact, so they are the same grey. + */ +export const DIALOG_EMPTY = 'text-zinc-600' diff --git a/apps/catalog/app/type-scale.test.ts b/apps/catalog/app/type-scale.test.ts index 62a9ebb..0a7e14b 100644 --- a/apps/catalog/app/type-scale.test.ts +++ b/apps/catalog/app/type-scale.test.ts @@ -194,6 +194,51 @@ describe('the faces and weights', () => { ).toEqual([]) }) + /** + * The three boxes over the part, and the two controls drawn inside them. + * + * `+ Feature`, `+ Group` and `+ Tool Assembly` each open one of these, and + * between them they held fifteen type recipes — three sizes, two faces, two + * weights and six greys, with the same job done differently in each box + * (Paul, 2026-09-11: "can we get less text sizes and types in the feature, + * group, and tool assembly dialogs?"). The slot names were the clearest case: + * `TOOL`, `HOLDER` and `COLLET` are capitals typed into the data rather than + * set with `uppercase`, so the small-capitals check above could not see them + * and they had drifted a size and two greys away from every other label. + */ + const DIALOGS = [ + 'app/components/selection-panel.tsx', + 'app/components/group-editor.tsx', + 'app/components/assembly-tree-panel.tsx', + 'app/components/thread-picker.tsx', + ] + + /** The named recipes — `shared/type.ts` is where a dialog's type comes from. */ + const RECIPE = /\b(HEADING|SECTION_LABEL|TABLE_FACE|TABLE_INK|DIALOG_[A-Z]+)\b/ + + /** + * A copy without `/g`, because `SIZE` and `FACE` carry one. + * + * `RegExp.test` on a global pattern resumes from `lastIndex`, so calling it + * down a list of strings answers every second one `false` whatever it holds — + * a check that passes by alternating rather than by being kept. + */ + const has = (pattern: RegExp, text: string): boolean => new RegExp(pattern.source).test(text) + + it('names no type of its own in a dialog', () => { + const typed = DIALOGS.flatMap((file) => + expressions(code(readFileSync(file, 'utf8'))) + .filter((classes) => has(SIZE, classes) || has(FACE, classes)) + .filter((classes) => !RECIPE.test(classes)) + .map((classes) => `${file}: ${classes}`), + ) + + expect( + typed, + "A size, face or weight written into a dialog rather than taken from `shared/type.ts`. The vocabulary is DIALOG_TITLE, DIALOG_VALUE, DIALOG_TEXT, DIALOG_NOTE, DIALOG_EMPTY and SECTION_LABEL; `cn(DIALOG_VALUE, 'font-mono')` is how a value takes the mono face.", + ).toEqual([]) + }) + it('sets small capitals one way', () => { const wrong = sourceFiles(appDir).flatMap((file) => expressions(code(readFileSync(file, 'utf8'))) From e76ef24188e882d025fea38f35e1b7ff95c8e073 Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Fri, 11 Sep 2026 14:10:39 -0400 Subject: [PATCH 03/19] UI fixes --- .../app/components/assembly-panel.test.tsx | 62 +++++ .../catalog/app/components/assembly-panel.tsx | 10 +- .../app/components/catalog-drawing.test.tsx | 33 ++- .../app/components/catalog-drawing.tsx | 33 +-- .../app/components/clearance-entry.tsx | 28 ++- .../app/components/column-filter.test.tsx | 22 ++ apps/catalog/app/components/column-filter.tsx | 169 ++++++++----- .../app/components/part-tool-table.test.tsx | 15 +- apps/catalog/app/components/part-viewer.tsx | 11 +- apps/catalog/app/components/tool-details.tsx | 58 ++++- apps/catalog/app/routes/part.tsx | 226 +++++++++++++----- .../app/shared/assembly-actions.test.ts | 24 +- apps/catalog/app/shared/assembly-actions.ts | 10 - .../app/shared/clearance-entry.test.ts | 77 +++++- apps/catalog/app/shared/clearance-entry.ts | 94 +++++++- .../catalog/app/shared/column-filters.test.ts | 27 ++- apps/catalog/app/shared/column-filters.ts | 26 +- apps/catalog/app/styles.css | 59 +++-- apps/catalog/tests/on-the-part.spec.ts | 6 +- apps/catalog/tests/threaded-hole.spec.ts | 64 +++++ docs/TOOL-CATALOG-PLAN.md | 40 +++- 21 files changed, 835 insertions(+), 259 deletions(-) create mode 100644 apps/catalog/app/components/assembly-panel.test.tsx diff --git a/apps/catalog/app/components/assembly-panel.test.tsx b/apps/catalog/app/components/assembly-panel.test.tsx new file mode 100644 index 0000000..5a1efc4 --- /dev/null +++ b/apps/catalog/app/components/assembly-panel.test.tsx @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import type { Holder } from '@toolpath/catalog-data' +import { AssemblyPanel } from './assembly-panel' + +const holder: Holder = { + guid: 'one', + familyId: 'bt30_er_collet_adapters_metric', + brand: 'Kennametal', + vendor: 'Kennametal', + catalogNumber: 'BT30ER11060M', + materialNumber: '6694846', + taper: 'BT30', + contact: 'taper', + clamping: 'collet', + gaugeLength: 60, + colletSeries: 'ER11', + boreDiameter: null, + noseDiameter: null, + noseLength: null, + bodyDiameter: null, + bodyLength: null, + projection: null, + flangeDiameter: null, + colletProtrusion: null, + productLink: null, + cadModelUrl: null, + provenance: {}, +} + +const read = (notice: string | null) => + render( + , + ) + +describe('the panel with no cutter in the stack', () => { + /** + * **One message, not two** (Paul, 2026-09-11). A holder chosen with no tool + * asked for the same tool twice — `nothingToConfirm` in a strip at the top + * and the drawing frame's own "Choose a tool to draw the assembly." under it. + */ + it("says why the drawing is empty once, in the notice's words", () => { + read('Pick a tool for this assembly.') + + expect(screen.getByText('Pick a tool for this assembly.')).toBeInTheDocument() + expect(screen.queryByText('Choose a tool to draw the assembly.')).toBeNull() + }) + + /** With no reason to give — a rack being browsed with no feature — the frame still says what it wants. */ + it("falls back to the frame's own words where there is no notice", () => { + read(null) + + expect(screen.getByText('Choose a tool to draw the assembly.')).toBeInTheDocument() + }) +}) diff --git a/apps/catalog/app/components/assembly-panel.tsx b/apps/catalog/app/components/assembly-panel.tsx index bf3a7ab..b16e3b5 100644 --- a/apps/catalog/app/components/assembly-panel.tsx +++ b/apps/catalog/app/components/assembly-panel.tsx @@ -171,7 +171,7 @@ export const AssemblyPanel = ({ about, so what is left here is the reading and, where a stack cannot be confirmed at all, why. */} - {notice === null ? null : ( + {notice === null || tool === null ? null : (

{notice}

@@ -194,8 +194,14 @@ export const AssemblyPanel = ({ tool and what holds it, and a holder on its own is a picture the package does not offer. Saying so beats an empty frame — and the component's own numbers are still read below. + + **Said once** (Paul, 2026-09-11). `nothingToConfirm` asks for the + same tool in the same words, so a holder with no cutter carried + two notices stacked on each other — the reason the frame is empty + and the reason nothing can be ordered are one sentence. Where + there is a notice it fills this frame and the strip above goes. */} - Choose a tool to draw the assembly. + {notice ?? 'Choose a tool to draw the assembly.'}

{facts}
diff --git a/apps/catalog/app/components/catalog-drawing.test.tsx b/apps/catalog/app/components/catalog-drawing.test.tsx index 4264013..60610e3 100644 --- a/apps/catalog/app/components/catalog-drawing.test.tsx +++ b/apps/catalog/app/components/catalog-drawing.test.tsx @@ -165,7 +165,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', () => { @@ -261,23 +270,21 @@ describe('the overlay this application draws', () => { }) /** - * **The verdict stays; the sentence under it went** (Paul, 2026-09-11). - * - * The sentence named the length the verdict was reached at, because a stack - * clears at one stickout and fouls at another. That length is a box now, and - * an editable one — so the reading it answered is better answered than it was - * — and five lines of the panel went back to the drawing. + * **Nothing is written over the drawing** (Paul, 2026-09-11, pointing at the + * verdict line: "remove the stuff outlined in red"). * - * What must not go with it is the verdict itself: a clearance under what was - * wanted and a stack actually into the material are different readings, and - * only this says the second. + * 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 whether the stack clears, and nothing else', () => { + it('writes neither a verdict nor a sentence over the sheet', () => { const container = drawn( , ) - expect(container.textContent).toMatch(/(clears|collides)/) + expect(container.textContent).not.toMatch(/clears|collides/) expect(container.textContent).not.toContain('below the holder') }) diff --git a/apps/catalog/app/components/catalog-drawing.tsx b/apps/catalog/app/components/catalog-drawing.tsx index 4791b1a..92e2d75 100644 --- a/apps/catalog/app/components/catalog-drawing.tsx +++ b/apps/catalog/app/components/catalog-drawing.tsx @@ -126,23 +126,26 @@ export interface CatalogDrawingProps { } /** - * **The sentence under the verdict came out on 2026-09-11.** + * **Nothing is written over the drawing any more** (Paul, 2026-09-11). * - * It read "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", which was five lines of - * the panel saying what the three clearance boxes under the sheet now say in - * three — and say editably, which a sentence cannot. Paul's call once the boxes - * were on screen: "you can remove the previous messaging above the tool - * visualization and make the tool visualization bigger." + * 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 verdict itself stays.** "collides with the part" is the one thing the - * boxes do not say: a clearance under what was wanted and a stack actually into - * the material are different readings, and the second is the one a shop must - * not miss. The package writes it from `clears` alone, with no note under it. + * 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. * - * What was lost with the sentence is *where* the tightest point was — "at the - * shank" — which is `describeGaps`'s and has no box. The drawing paints the - * colliding part red, which is the same answer in the place it is about. + * 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. */ export const CatalogDrawing = ({ @@ -199,7 +202,7 @@ export const CatalogDrawing = ({ {...(onDimensionHover === undefined ? {} : { onDimensionHover })} padding={padding} collisions={verdict?.collisions} - verdict={verdict === null ? null : { clears: verdict.clears, note: null }} + verdict={null} className="size-full" > {overlaid && profile !== null && gaps !== null ? ( diff --git a/apps/catalog/app/components/clearance-entry.tsx b/apps/catalog/app/components/clearance-entry.tsx index ef20d14..6cd100c 100644 --- a/apps/catalog/app/components/clearance-entry.tsx +++ b/apps/catalog/app/components/clearance-entry.tsx @@ -241,6 +241,14 @@ export const ClearanceEntry = ({ boxes, unit, edit, onEdit }: ClearanceEntryProp */ const [open, setOpen] = useState(false) + const marks = { + below: markForBelow(boxes, edit, 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 diff --git a/apps/catalog/app/components/column-filter.test.tsx b/apps/catalog/app/components/column-filter.test.tsx index 39f0922..39557bd 100644 --- a/apps/catalog/app/components/column-filter.test.tsx +++ b/apps/catalog/app/components/column-filter.test.tsx @@ -284,6 +284,28 @@ describe('the column picker', () => { expect(list).toHaveClass('overflow-y-auto') expect(list.style.maxHeight).not.toBe('') }) + + /** + * 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) + expect(list).toHaveClass('fixed') + }) }) /** diff --git a/apps/catalog/app/components/column-filter.tsx b/apps/catalog/app/components/column-filter.tsx index e8949f8..4f9419f 100644 --- a/apps/catalog/app/components/column-filter.tsx +++ b/apps/catalog/app/components/column-filter.tsx @@ -1081,6 +1081,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, @@ -1098,8 +1106,9 @@ export const ColumnPicker = ({ const [open, setOpen] = useState(false) const [held, setHeld] = useState(null) const box = useRef(null) + const menu = useRef(null) const pencil = useRef(null) - const [room, setRoom] = useState({ upwards: false, height: MENU_LEAST }) + const [spot, setSpot] = useState({ top: 0, bottom: null, left: 0, height: MENU_LEAST }) const order = columns.map((column) => column.code) const move = (code: string, index: number) => { @@ -1114,7 +1123,11 @@ export const ColumnPicker = ({ return } const onDown = (event: PointerEvent) => { - if (!box.current?.contains(event.target as Node)) { + const target = event.target as Node + // The list is drawn in a portal now, so it is outside this box in the + // document as well as on the screen: a press in it is still a press + // inside the picker, and closing on it would undo the tick that made it. + if (box.current?.contains(target) !== true && menu.current?.contains(target) !== true) { setOpen(false) } } @@ -1136,14 +1149,35 @@ export const ColumnPicker = ({ } const measure = () => { const button = pencil.current?.getBoundingClientRect() - if (button !== undefined) { - setRoom(menuRoom(button, window.innerHeight)) + if (button === undefined) { + return } + /* + `menuRoom` is the rule for which way it opens, and where it says upwards + the list is anchored by its bottom rather than placed at a height it has + not been measured at yet — the same flip `FilterMenu` makes. + */ + const room = menuRoom(button, window.innerHeight) + const width = menu.current?.getBoundingClientRect().width ?? 0 + setSpot({ + top: room.upwards ? null : button.bottom + 4, + bottom: room.upwards ? window.innerHeight - button.top + 4 : null, + // Its right edge under the pencil's, and never off the left of the screen. + left: Math.max( + MENU_EDGE, + Math.min(button.right - width, window.innerWidth - width - MENU_EDGE), + ), + height: room.height, + }) } // 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) { + if ( + event.target instanceof Node && + (box.current?.contains(event.target) === true || + menu.current?.contains(event.target) === true) + ) { return } measure() @@ -1178,73 +1212,78 @@ export const ColumnPicker = ({ > - {open ? ( -
- {columns.map((column, at) => ( + {open + ? createPortal(
{ - if (held !== null) { - event.preventDefault() - } - }} - onDrop={(event) => { - event.preventDefault() - if (held !== null) { - move(held, at) - setHeld(null) - } + ref={menu} + role="group" + aria-label="Columns" + style={{ + ...(spot.top === null ? { bottom: spot.bottom ?? 0 } : { top: spot.top }), + left: spot.left, + maxHeight: spot.height, }} - 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', - )} + className="fixed z-50 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-950 py-1 shadow-xl" > - {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) { + {columns.map((column, at) => ( +
{ + if (held !== null) { 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" + 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', + )} > -
- ))} -
- ) : null} + {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} +
+
+ ))} +
, + document.body, + ) + : null}
) } diff --git a/apps/catalog/app/components/part-tool-table.test.tsx b/apps/catalog/app/components/part-tool-table.test.tsx index f0a7bbf..b541720 100644 --- a/apps/catalog/app/components/part-tool-table.test.tsx +++ b/apps/catalog/app/components/part-tool-table.test.tsx @@ -337,11 +337,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 +364,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-viewer.tsx b/apps/catalog/app/components/part-viewer.tsx index 6df1af2..7a4998a 100644 --- a/apps/catalog/app/components/part-viewer.tsx +++ b/apps/catalog/app/components/part-viewer.tsx @@ -451,7 +451,16 @@ export const PartViewer = ({ ref={bottom} /* Close to the table under it (Paul, 2026-09-11: "reduce the vertical spacing between the buttons and the table"). */ - className="pointer-events-none absolute inset-x-3 bottom-1 z-30 flex flex-col gap-2" + /* + **Full width, so the page can line its chrome up with the table** + (Paul, 2026-09-11: "move the tools, holders, collets, etc buttons + left so they are in line with the left edge of the table"). An inset + here was a margin the page could not see or undo, and the list's + chrome came out 12px in from an edge it is meant to share with the + list below. The slot is the viewer's whole width; what stands in it + decides its own. + */ + className="pointer-events-none absolute inset-x-0 bottom-1 z-30 flex flex-col gap-2" > {bottomChrome}
diff --git a/apps/catalog/app/components/tool-details.tsx b/apps/catalog/app/components/tool-details.tsx index 5b9be45..05258f5 100644 --- a/apps/catalog/app/components/tool-details.tsx +++ b/apps/catalog/app/components/tool-details.tsx @@ -3,7 +3,7 @@ import { ArrowSquareOutIcon } from '@phosphor-icons/react' import { Badge, Button, Combobox, Toggle, cn } from '@toolpath/ui' import { NO_MARGINS, - clearance, + stickoutLimits, type CatalogTool, type Collet, type Holder, @@ -15,7 +15,7 @@ import { formatGeometry } from 'shared/geometry' import { getFamily } from 'shared/catalog' import { drawnAssembly } from 'shared/drawn-assembly' import { roomAt } from 'shared/assembly-gaps' -import { askFor, boxesFor, type ClearanceEdit } from 'shared/clearance-entry' +import { askFor, boxesFor, lengthFor, type ClearanceEdit } from 'shared/clearance-entry' import { thresholdsFrom } from 'shared/holder-choice' import { ToolTypeIcon, formLabel } from './tool-icons' import { MeasurementIcon } from './feature-icons' @@ -237,18 +237,52 @@ export const ToolDetails = ({ const edit = stated.key === stackKey ? stated.edit : null /** - * The least this stack can stand out of the holder and still leave the room - * asked for — `clearance()`'s own answer, behind the name the rule wants. + * The least this stack can stand out and still leave the room asked for. + * + * **Solved on the holder that is drawn, not the one the vendor tabulated.** + * `clearance().requiredStickout` was the obvious answer and is the wrong one: + * it sweeps the parametric nose, body and flange, and most holders publish + * none of those — `RequiredAt` in `shared/clearance-entry.ts` has the reading + * off `BT30-ER11-110DT` that settled it. `lengthFor` halves its way down the + * same measurement the other two boxes are read from, so the three of them + * describe one stack rather than two. + * + * Memoised on what it depends on, because a solve is forty sweeps and an + * entry that stands must not re-run them on every keystroke elsewhere. */ - const requiredAt = (limits: Margins): number | null => - holderChosen === undefined || curve === null + /* + The ends to search between. `StickoutRange.max` is null where the tool + states no overall length — an unbounded range rather than a bound of + nothing — and a search needs a far end, so the tool's own length stands in + and the ends collapse onto the floor where there is not even that. + */ + const bracket = useMemo(() => { + const range = stickoutLimits(tool, stack?.collet ?? null) + return range === null ? null - : clearance( - { tool, holder: holderChosen, collet: stack?.collet ?? null, stickout: 0 }, - curve, - limits, - ).requiredStickout - const ask = askFor(edit, margins, requiredAt) + : { min: range.min, max: range.max ?? tool.geometry.OAL ?? range.min } + }, [tool, stack?.collet]) + const requiredAt = useMemo( + () => + (field: 'axial' | 'radial', wanted: number): number | null => { + if (holderChosen === undefined || curve === null || bracket === null) { + return null + } + return lengthFor( + wanted, + bracket, + (stickout) => roomAt({ tool, holder: holderChosen }, stickout, curve, margins)[field], + ) + }, + [tool, holderChosen, curve, margins, bracket], + ) + /* + The ask is memoised, not just the search behind it: `askFor` calls the + search, the search is forty sweeps of the outline, and this panel re-renders + on anything the page does. Without this, every keystroke in a filter on the + other side of the screen would re-solve a length nobody had touched. + */ + const ask = useMemo(() => askFor(edit, margins, requiredAt), [edit, margins, requiredAt]) const drawn = drawnAssembly( tool, diff --git a/apps/catalog/app/routes/part.tsx b/apps/catalog/app/routes/part.tsx index b949f9a..f144e96 100644 --- a/apps/catalog/app/routes/part.tsx +++ b/apps/catalog/app/routes/part.tsx @@ -200,6 +200,7 @@ import { askOfToolColumn, narrowingNames, sayBound, + type ColumnAsk, } from 'shared/column-filters' import { DERIVED_AXES, @@ -2655,21 +2656,14 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: ) /** - * The taps narrow on their catalog number and which kind of tap they are. + * What narrows a tap list, which is not what narrows the tool list. * - * **Everything else about them is the thread's** (Paul, 2026-09-09: "when I - * am in the TAPs row or table, it should be filtering to taps"). They are - * swept out of the whole catalog by the thread — the tool filters never reach - * `makersFor` — so a funnel on the tap list's Vendor or Flute length heading - * would be a control that changes nothing, and the list carried none at all - * while the chrome over it counted three filters. `askOfTapColumn` is the - * pair it does answer. - * - * Offered only while the `form` axis is saying something, which on a threaded - * hole is always: choosing a thread writes `THREADED_FORMS`. With the filters - * cleared the list is genuinely unconstrained, the column has no answer to - * show, and a tick would then be the only form in the filter — which is the - * one shape that would empty the drills. + * The taps are swept out of the whole catalog by the thread — the tool query + * never reaches `makersFor` — so every narrowing on them is one this route + * applies itself over that pool: the catalog number, which kind of tap, and + * **the vendor and the family** (Paul, 2026-09-11). The rest of the columns + * are the thread's own numbers, stated rather than asked. + * `shared/column-filters.ts` § `askOfTapColumn` is the list. */ /** * The two numbers the thread and the depth put on this list, as bounds. @@ -2683,45 +2677,130 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: [threadSpec, threadReach], ) + /** + * The vendor and the family this list is being narrowed on. + * + * The **same** `query.terms` every other list reads, rather than a second + * set of ticks for the taps: a shop that has asked for one vendor has asked + * for it, and a tap list quietly ignoring the question was the defect here + * (Paul, 2026-09-11). Read out as their own object because the pool, the + * counts and the rows are three readers of one answer. + */ + const tapTerms = useMemo( + () => ({ brand: query.terms.brand ?? [], family: query.terms.family ?? [] }), + [query.terms.brand, query.terms.family], + ) + + /** + * Every tap the thread turned up, of the kinds the filter is asking for. + * + * The pool the vendor and family columns are counted over and the rows are + * drawn from — measured before those two axes are applied, so that an axis + * never narrows itself and the counts beside the other vendors still say + * what each would bring back. + */ + const tapPool = useMemo(() => { + const forms = query.terms.form ?? [] + return forms.length === 0 + ? makers.made + : makers.made.filter((each) => forms.includes(each.form)) + }, [makers.made, query.terms.form]) + + /** + * What the vendor and the family columns count, each against the other. + * + * `shared/filter.ts` § `countsByAxis` is the rule the tool list's own facets + * follow: an axis is counted over the pool narrowed by every term but its + * own, so choosing one vendor does not take the rest off the list. + */ + const tapFacets = useMemo( + () => countsByAxis(tapPool, { ...EMPTY_QUERY, terms: tapTerms }, ['brand', 'family']), + [tapPool, tapTerms], + ) + + /** + * What the catalog has that this thread's taps do not — behind the `…` row, + * greyed at nought. + * + * **A vendor with no tap for this thread reads zero rather than vanishing** + * (Paul, 2026-09-11: "if vendors don't have taps, it should simply show + * zero"). {@link hiddenOn} is the same answer for the tool list; this is its + * tap half, measured against the swept pool rather than against the matcher. + */ + const tapHidden = useCallback( + (axis: string): ReadonlyArray<{ value: string; label: string }> => { + const offered = tapFacets.get(axis) + return [...(everyValue.get(axis) ?? new Map()).keys()] + .filter((value) => !(offered?.has(value) ?? false)) + .map((value) => ({ value, label: axis === 'family' ? familyName(value) : value })) + .sort((a, b) => a.label.localeCompare(b.label, 'en', { numeric: true })) + }, + [tapFacets, everyValue], + ) + + /** + * The Type column asks nothing while the `form` axis is silent. + * + * With the filters cleared the list is genuinely unconstrained, the column + * has no answer to show, and a tick would then be the only form in the + * filter — which is the one shape that would empty the drills on the stack + * beside it. The vendor and the family have no such coupling: they narrow + * this pool and the drill list the same way, whatever the forms say. + */ + const askOfTapHeading = useCallback( + (code: string): ColumnAsk | null => + code === 'type' && (query.terms.form ?? []).length === 0 ? null : askOfTapColumn(code), + [query.terms.form], + ) + const tapFiltering = useMemo( () => ({ search: { value: numberSearch, onChange: setNumberSearch }, - ask: askOfTapColumn, - ...((query.terms.form ?? []).length === 0 - ? {} - : { - catalog: { - query: { - ...EMPTY_QUERY, - terms: { type: shownTapTypes }, - ranges: tapRanges, - }, - onTerm: applyTapTerm, - options: () => tapTypes, - /* + ask: askOfTapHeading, + catalog: { + query: { + ...EMPTY_QUERY, + terms: { ...tapTerms, type: shownTapTypes }, + ranges: tapRanges, + }, + onTerm: applyTapTerm, + options: (axis: string) => + axis === 'type' + ? tapTypes + : [...(tapFacets.get(axis) ?? new Map())] + .map(([value, count]) => ({ + value, + label: axis === 'family' ? familyName(value) : value, + count, + })) + .sort((a, b) => a.label.localeCompare(b.label, 'en', { numeric: true })), + hidden: (axis: string) => (axis === 'type' ? [] : tapHidden(axis)), + /* Stated, not asked: `onRange` is absent, so the two headings say the number and where it came from instead of offering boxes. The list is swept on them — and a short list's near misses are the very rows that break them, which a filter would hide along with the reason it was showing them. */ - stated: (code: string) => - threadSpec === null - ? undefined - : code === 'DC' - ? `Every tap the ${threadSpec.name} thread takes. The list is swept on it rather than filtered, so there is nothing to change here.` - : code === 'LCF' && threadReach !== null - ? `The thread has to cover the ${formatLength(threadReach.depth, unit)} depth of what is selected. A tap that falls short is on the list only when nothing reaches, and its length is painted red.` - : undefined, - }, - }), + stated: (code: string) => + threadSpec === null + ? undefined + : code === 'DC' + ? `Every tap the ${threadSpec.name} thread takes. The list is swept on it rather than filtered, so there is nothing to change here.` + : code === 'LCF' && threadReach !== null + ? `The thread has to cover the ${formatLength(threadReach.depth, unit)} depth of what is selected. A tap that falls short is on the list only when nothing reaches, and its length is painted red.` + : undefined, + }, }), [ numberSearch, - query.terms.form, + askOfTapHeading, + tapTerms, shownTapTypes, applyTapTerm, tapTypes, + tapFacets, + tapHidden, tapRanges, threadSpec, threadReach, @@ -2743,13 +2822,25 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: */ const clearTapFilters = useCallback(() => { setNumberSearch('') - applyTerm( - 'form', - formsAskingTaps(query.terms.form ?? [], [ - ...new Set(tapTypes.flatMap((each) => formOfTypeLabel(each.value) ?? [])), - ]), - ) - }, [applyTerm, query.terms.form, tapTypes]) + /* + **The vendor and the family go with it** (Paul, 2026-09-11). They are + narrowing this list now, so a press that says it clears the filters and + leaves one vendor ticked is the same disagreement between the button and + the funnels that the count above was fixed for. Cleared on the shared + query, because that is where the ticks are. + */ + apply({ + ...query, + terms: { + ...query.terms, + brand: [], + family: [], + form: formsAskingTaps(query.terms.form ?? [], [ + ...new Set(tapTypes.flatMap((each) => formOfTypeLabel(each.value) ?? [])), + ]), + }, + }) + }, [apply, query, tapTypes]) /** * What the rules said about each tool, column by column — a tick on what @@ -2942,18 +3033,18 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: const tapRows = useMemo(() => { const wanted = numberSearch.trim().toLowerCase() /* - **And by which kind of tap the filter is asking for** (Paul, 2026-09-09). - The whole `form` axis rather than its tap half: a filter naming forms and - no tap among them is a question this list has no answer to, where an empty - axis is nobody asking. `hole-mode.ts` § `formsAskingTaps` is what writes it. + **By which kind of tap the filter is asking for** (Paul, 2026-09-09) — + {@link tapPool} is that half, the whole `form` axis rather than its tap + half — **and by the vendor and the family** (Paul, 2026-09-11), which are + the two term axes this list can answer over a pool the tool query never + reaches. `filterTools` so that the rows are narrowed by the same rule the + counts in their headings were measured with. */ - const forms = query.terms.form ?? [] - const asked = - forms.length === 0 ? makers.made : makers.made.filter((each) => forms.includes(each.form)) + const asked = filterTools(tapPool, { ...EMPTY_QUERY, terms: tapTerms }) return wanted === '' ? asked : asked.filter((each) => `${each.catalogNumber} ${each.brand}`.toLowerCase().includes(wanted)) - }, [makers.made, numberSearch, query.terms.form]) + }, [tapPool, tapTerms, numberSearch]) /** * What is wrong with a tap, in the column it is about — the red the tap @@ -5528,7 +5619,16 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: follow: the controls take a click and the strip between them does not, so a drag that starts on the sky still turns the part. */ - className="pointer-events-auto grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-2 gap-y-1 rounded-lg bg-zinc-950/75 px-2 py-1 text-sm" + /* + **Flush with the table under it** (Paul, 2026-09-11: "move the tools, + holders, collets, etc buttons left so they are in line with the left + edge of the table"). The buttons and the list are one control and one + answer, and the bar standing 20px in from the table's edge — the + viewer's inset plus this padding — read as a second, narrower thing + floating above it. No horizontal padding here and none in the viewer's + slot, so the first press starts where the first column does. + */ + className="pointer-events-auto grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-2 gap-y-1 rounded-lg bg-zinc-950/75 px-0 py-1 text-sm" > {/* **One row, always** (Paul, 2026-09-11: "all information we show there @@ -5619,10 +5719,20 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: of the buttons"). Ellipsising every note to keep one line turned the heading into "Cuts the bl…" and each note into three words and a dot, which is a bar that says nothing. So the text takes a box of its own - beside the buttons and wraps inside it, two lines of `text-2xs` being - about the height of the presses it stands beside. + beside the buttons and wraps inside it. + + **And nothing here is clipped** (Paul, 2026-09-11: "this text can't + cut off"). It was capped at the height of the presses and cut what + did not fit — but the cut-tap and form-tap buttons stand on the first + line of this very box, so the second line of a two-line note started + below the cap and was sliced in half: `matched on ⌀0.089 in — the cut + tap's predrill for #4-40 UNC` read as a row of half-letters. The box + asks for the height of the presses (`min-h-8`) and takes another line + where the words need one; the bar is measured, so the column of + questions above it moves up by that line rather than the words + disappearing under it. */} -
+
{/* The heading of whichever list is on screen. A threaded hole used to put two tabs here — taps, then drills — @@ -5907,7 +6017,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: ? narrowingNames( { text: numberSearch, - terms: { type: shownTapTypes }, + terms: { ...tapTerms, type: shownTapTypes }, /* **The bounds the part set count too** (Paul, 2026-09-09: "button should show to diff --git a/apps/catalog/app/shared/assembly-actions.test.ts b/apps/catalog/app/shared/assembly-actions.test.ts index 0dbc807..ea6c92f 100644 --- a/apps/catalog/app/shared/assembly-actions.test.ts +++ b/apps/catalog/app/shared/assembly-actions.test.ts @@ -200,17 +200,15 @@ describe('a feature that is not a row yet', () => { /* Named for where it puts the tool rather than for the row it writes against (Paul, 2026-09-07: "I should just have an 'add to order list' - button (or update, context aware)"). That it makes the row as well is the - note under it, because it is the half somebody would not expect. + button (or update, context aware)"). It makes the row as well, and says + nothing about that: the note under it came out on 2026-09-11. */ expect(offered[0]?.label).toBe('Add to order list') - // The list is headed *Order list* since 2026-09-08, so the note names it - // as the list rather than as the feature list. - expect(offered[0]?.note).toContain('to the list as well') + expect(offered[0]?.note).toBeUndefined() }) - it('says group in the note where a group is what would be created', () => { - expect(assemblyActions(stack(), [], false, 'group')[0]?.note).toContain('the group') + it('says nothing under the press where a group is what would be created', () => { + expect(assemblyActions(stack(), [], false, 'group')[0]?.note).toBeUndefined() }) /** @@ -513,19 +511,19 @@ describe('what a whole assembly offers', () => { it('makes the feature and writes the assembly in one press', () => { const [first] = offered([tap, drill], [], false) expect(first?.kind).toBe('confirm') - expect(first?.note).toContain('to the list as well') + expect(first?.note).toBeUndefined() }) /** * **A tool assembly the part needs is named for itself** (Paul, 2026-09-08). - * It answers no feature, so "adds the feature to the list" would be the one - * sentence it cannot say — and it is a draft until this press precisely - * because an assembly with nothing on the order list is a row about nothing. + * It answers no feature, and it is a draft until this press precisely because + * an assembly with nothing on the order list is a row about nothing. The + * press carries no note of its own — Paul took all three out on 2026-09-11. */ - it('names the tool assembly it would make, where there is no feature', () => { + it('offers the one press where there is no feature', () => { const [first] = groupActions([stack()], [], false, 'assembly') expect(first?.kind).toBe('confirm') - expect(first?.note).toContain('tool assembly') + expect(first?.note).toBeUndefined() }) it('offers the way off the list once every stack of it is on there', () => { diff --git a/apps/catalog/app/shared/assembly-actions.ts b/apps/catalog/app/shared/assembly-actions.ts index 3c7cc13..d2af399 100644 --- a/apps/catalog/app/shared/assembly-actions.ts +++ b/apps/catalog/app/shared/assembly-actions.ts @@ -115,14 +115,6 @@ export type AssemblyActionKind = /** What one press would put on the list, where it is not on it yet. */ export type Subject = 'feature' | 'group' | 'assembly' -/** What the press says it is making as well as ordering. */ -const ALSO: Readonly> = { - feature: 'Adds the feature to the list as well — it is not on it yet.', - group: 'Adds the group to the list as well — it is not on it yet.', - // Named for what it is rather than for a feature it does not have. - assembly: 'Adds this tool assembly to the list as well — it is not on it yet.', -} - export interface AssemblyAction { readonly kind: AssemblyActionKind readonly label: string @@ -460,7 +452,6 @@ export const assemblyActions = ( { kind: 'confirm', label: 'Add to order list', - note: ALSO[subject], }, ] } @@ -574,7 +565,6 @@ export const groupActions = ( { kind: 'confirm', label: 'Add to order list', - note: ALSO[subject], }, ] } diff --git a/apps/catalog/app/shared/clearance-entry.test.ts b/apps/catalog/app/shared/clearance-entry.test.ts index 5a98739..6b1c2ab 100644 --- a/apps/catalog/app/shared/clearance-entry.test.ts +++ b/apps/catalog/app/shared/clearance-entry.test.ts @@ -1,19 +1,20 @@ import { describe, expect, it } from 'vitest' import type { Margins } from '@toolpath/catalog-data' -import { askFor, boxesFor, shownIn, type RequiredAt } from './clearance-entry' +import { askFor, boxesFor, lengthFor, shownIn, type RequiredAt } from './clearance-entry' /** The sheet's own figures, 0.020 in both ways, which is what the page opens on. */ const SHEET: Margins = { axial: 0.508, radial: 0.508 } /** - * A stand-in for `clearance().requiredStickout`: the holder has to stand one - * millimetre off the tip to touch nothing, plus whatever room is asked for. + * A stand-in for the solve: the holder stands one millimetre off the tip to + * touch nothing, plus whatever room is asked for. * - * Linear in both axes, which the real sweep is not, and that is the point — - * this module decides *what* to ask and what the answer means, and a fake that - * can be read at a glance is what keeps those two questions apart. + * Linear, which the real search is not, and that is the point — this module + * decides *what* to ask and what the answer means, and a fake that can be read + * at a glance is what keeps those two questions apart. `lengthFor` has its own + * tests below for the search itself. */ -const required: RequiredAt = (margins) => 1 + margins.axial + margins.radial +const required: RequiredAt = (_field, wanted) => 1 + wanted describe('askFor', () => { it('asks the stack for nothing while all three boxes are the app’s', () => { @@ -50,6 +51,16 @@ describe('askFor', () => { expect(ask.stickout).toBe(3) }) + /** The axis is named, so the search knows which gap it is closing on. */ + it('asks the search about the axis that was stated', () => { + const asked: Array = [] + askFor({ field: 'radial', value: 2 }, SHEET, (field) => { + asked.push(field) + return 1 + }) + expect(asked).toEqual(['radial']) + }) + /** * The drawing is told both limits, not the one being solved for: the overlay * draws a margin line and writes a verdict, and drawing them against a nought @@ -62,11 +73,61 @@ describe('askFor', () => { }) }) - it('leaves the length to the stack where the holder states no nose', () => { + it('leaves the length to the stack where nothing can be measured', () => { expect(askFor({ field: 'axial', value: 2 }, SHEET, () => null).stickout).toBeNull() }) }) +/** + * The search that replaced `clearance().requiredStickout` on 2026-09-11. + * + * The room a stack leaves rises with the length it is set out at, so the whole + * of it is a bisection — and the two ends are answers rather than failures, + * which is what tells a stack that cannot be set there from one that has not + * been. The reading that forced the change is in `RequiredAt`'s own note. + */ +describe('lengthFor', () => { + const BRACKET = { min: 10, max: 60 } + /** + * Room rises one for one with the length, then flattens where the shank + * binds — which is the shape the real stack has: `BT30-ER11-110DT` measured + * −1.540 in at half an inch out, 0.054 in at 2.094 in, and 0.315 in at every + * length past 2.362 in. + */ + const room = (stickout: number) => Math.min(stickout - 8, 8) + + it('finds the shortest length that leaves the room asked for', () => { + expect(lengthFor(4, BRACKET, room)).toBeCloseTo(12, 3) + }) + + /** + * The shortest the tool goes already leaves more than was asked, so no length + * leaves exactly that: the flutes have to clear the collet, and every length + * above that floor gives more room rather than less. + */ + it('gives the floor where the shortest setting already leaves more', () => { + expect(lengthFor(1, BRACKET, room)).toBe(10) + }) + + /** Past the point where the shank binds, standing out further stops helping. */ + it('gives the ceiling where no length leaves that much', () => { + expect(lengthFor(20, BRACKET, room)).toBe(60) + }) + + it('answers nothing where there is nothing to measure', () => { + expect(lengthFor(4, BRACKET, () => null)).toBeNull() + }) + + /** + * Close enough that `boxesFor` reads the entry as met — the reason `MET` is + * two orders over what the search leaves behind. + */ + it('lands near enough that the entry reads as met', () => { + const found = lengthFor(4, BRACKET, room) + expect(Math.abs(room(found ?? 0) - 4)).toBeLessThan(0.005) + }) +}) + describe('boxesFor', () => { const drawn = { stickout: 20, overLimit: false } diff --git a/apps/catalog/app/shared/clearance-entry.ts b/apps/catalog/app/shared/clearance-entry.ts index eadc0a4..b950bb4 100644 --- a/apps/catalog/app/shared/clearance-entry.ts +++ b/apps/catalog/app/shared/clearance-entry.ts @@ -63,6 +63,17 @@ export interface ClearanceEdit { */ const SLACK = 1e-6 +/** + * Near enough that an entry counts as met, in millimetres. + * + * Two ten-thousandths of an inch — under the third decimal a box shows, and + * comfortably over what {@link lengthFor} leaves behind when it stops halving. + * Compared against {@link SLACK} it is enormous, and it has to be: a warning + * that fires because a bisection landed a nanometre out is a warning nobody + * can act on, and after the first one nobody reads the rest either. + */ +const MET = 0.005 + /** * What to ask the stack for, given what was stated. * @@ -82,13 +93,80 @@ export interface ClearanceAsk { } /** - * The least length below the holder that leaves a given room. + * The least length below the holder that leaves a given room on one axis. + * + * **Not `clearance().requiredStickout`, and 2026-09-11 is the day that stopped + * being a detail.** That function sweeps the *parametric* holder — a nose, a + * body and a flange off the vendor's published table — and AGENTS.md § Vendor + * Tool Data says in as many words that a holder record carries no silhouette. + * `BT30-ER11-110DT` is the ordinary case rather than the awkward one: MariTool + * publishes a taper, a gauge length and a collet series, and `null` for all + * nine of the dimensions a sweep needs. Asked about that stack it answers + * + * requiredStickout: null checked: ["shank"] + * + * — the tool's own shank and nothing of what holds it. So an entered clearance + * moved nothing at all, on every one of the 378 holders whose silhouette is + * measured rather than stated (Paul, 2026-09-11: "I don't think it's reading + * out the messaging"). + * + * What answers is the profile that is *drawn*, through {@link lengthFor}. That + * is also what makes the three boxes agree: the length is solved on the same + * measurement the other two boxes are read from, so a met entry reads as met + * instead of as a warning about a stack nobody could see. + */ +export type RequiredAt = (field: 'axial' | 'radial', wanted: number) => number | null + +/** + * The shortest length below the holder that leaves `wanted` room, by halving. + * + * The room a stack leaves rises with the length it is set out at — lift the + * holder and everything it could foul lifts with it — so the answer is found by + * bisection on the one measurement the boxes already read. Two ends are answers + * in themselves rather than failures, and are what tells a stack that cannot be + * set there from one that simply has not been: * - * `clearance().requiredStickout` behind a name, so this module can be tested - * with a curve made of two numbers instead of a part report. Null where the - * holder states no nose and there is nothing to sweep. + * - the shortest the tool goes already leaves more than was asked, so there is + * no length that leaves exactly that much — the flutes have to clear the + * collet, and every length above that floor gives *more* room, not less; + * - the longest it goes still leaves less, because past some length the tool's + * own shank is what the wall is nearest and lifting the holder stops helping. + * + * Both return the end they hit, so the stack is drawn at the closest it can get + * and `ClearanceBox.held` says which way it missed. */ -export type RequiredAt = (margins: Margins) => number | null +export const lengthFor = ( + wanted: number, + bracket: { readonly min: number; readonly max: number }, + roomAt: (stickout: number) => number | null, +): number | null => { + const atMax = roomAt(bracket.max) + if (atMax === null) { + return null + } + if (atMax < wanted) { + return bracket.max + } + const atMin = roomAt(bracket.min) + if (atMin !== null && atMin >= wanted) { + return bracket.min + } + let low = bracket.min + let high = bracket.max + // 1e-4 mm is four microns: two orders under MET, so a solved length always + // reads as having met what was asked, and forty halvings reach it from any + // bracket a tool has. + while (high - low > 1e-4) { + const middle = (low + high) / 2 + const room = roomAt(middle) + if (room !== null && room >= wanted) { + high = middle + } else { + low = middle + } + } + return high +} export const askFor = ( edit: ClearanceEdit | null, @@ -104,7 +182,7 @@ export const askFor = ( const solve: Margins = edit.field === 'axial' ? { axial: edit.value, radial: 0 } : { axial: 0, radial: edit.value } return { - stickout: requiredAt(solve), + stickout: requiredAt(edit.field, edit.value), margins: { ...defaults, [edit.field]: edit.value }, solve, } @@ -184,7 +262,7 @@ const boxFor = ( const entered = edit?.field === field ? edit.value : null const asked = entered ?? defaults[field] const value = room[field] - const missed = entered !== null && value !== null && Math.abs(value - entered) > SLACK + const missed = entered !== null && value !== null && Math.abs(value - entered) > MET return { entered, value, @@ -215,7 +293,7 @@ export const boxesFor = ( entered, value: drawn.stickout, clamped: - entered !== null && drawn.stickout !== null && Math.abs(drawn.stickout - entered) > SLACK, + entered !== null && drawn.stickout !== null && Math.abs(drawn.stickout - entered) > MET, overLimit: drawn.overLimit, }, axial: boxFor('axial', edit, defaults, room), diff --git a/apps/catalog/app/shared/column-filters.test.ts b/apps/catalog/app/shared/column-filters.test.ts index 4490881..58178eb 100644 --- a/apps/catalog/app/shared/column-filters.test.ts +++ b/apps/catalog/app/shared/column-filters.test.ts @@ -46,10 +46,11 @@ describe('what a tool column asks', () => { }) /** - * **A tap list answers two questions and sorts on the rest** (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 a funnel over its numbers - * would be a control that changes nothing. + * **A tap list answers the questions the page can answer over the swept pool** + * (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 a + * funnel over its numbers would be a control that changes nothing — but the + * words on a row are the row's whatever swept it in. */ describe('what a tap column asks', () => { it('searches the catalog number and narrows the type', () => { @@ -57,6 +58,18 @@ describe('what a tap column asks', () => { expect(askOfTapColumn('type')).toEqual({ shape: 'terms', axis: 'type' }) }) + /** + * **The vendor and the family narrow a tap list too** (Paul, 2026-09-11: "I + * don't see the filter option for vendor when I am selecting a tap for a tool + * assembly. Why is that? I should … Same thing for family"). They carried no + * funnel because the tool query does not reach `makersFor`; the page narrows + * the swept pool on them itself — `routes/part.tsx` § `tapRows`. + */ + it('narrows the vendor and the family, which the pool can answer', () => { + expect(askOfTapColumn('brand')).toEqual({ shape: 'terms', axis: 'brand' }) + expect(askOfTapColumn('family')).toEqual({ shape: 'terms', axis: 'family' }) + }) + /** * **The two the sweep narrowed on** (Paul, 2026-09-09: "shouldn't thread * diameter and thread length be applied from the thread spec and model @@ -70,14 +83,14 @@ describe('what a tap column asks', () => { }) it('asks nothing of the columns the thread already decided', () => { - for (const code of ['brand', 'family', 'LBH', 'NOF', 'OAL', 'SFDM']) { + for (const code of ['LBH', 'NOF', 'OAL', 'SFDM']) { expect(askOfTapColumn(code)).toBeNull() } }) - /** The two it does ask are the tool list's own questions, not a second pair. */ + /** Every one it asks is the tool list's own question, not a second version. */ it('asks them exactly as the tool list does', () => { - for (const code of ['catalogNumber', 'type', 'DC', 'LCF']) { + for (const code of ['catalogNumber', 'type', 'brand', 'family', 'DC', 'LCF']) { expect(askOfTapColumn(code)).toEqual(askOfToolColumn(code)) } }) diff --git a/apps/catalog/app/shared/column-filters.ts b/apps/catalog/app/shared/column-filters.ts index 0159f29..8b5feee 100644 --- a/apps/catalog/app/shared/column-filters.ts +++ b/apps/catalog/app/shared/column-filters.ts @@ -192,17 +192,24 @@ export const sayBound = ( /** * What the header over a tap column asks. * - * **Two of them, and the rest sort** (Paul, 2026-09-09: "when I am in the TAPs - * row or table, it should be filtering to taps"). A tap list is swept out of - * the catalog by the thread — `makersFor` — rather than narrowed by the tool - * query, so a funnel on its Vendor or Flute length heading would be a control - * that changes nothing, which is why it carried none at all. The two that do - * change something: + * A tap list is swept out of the catalog by the thread — `makersFor` — rather + * than narrowed by the tool query, so what a column may ask is whatever the + * page can answer over the swept pool rather than whatever the tool query + * holds. Four of them ask, two state a number, and the rest sort: * * - the catalog number, which every list of tools searches; * - the type, because a threaded hole's `form` filter is the drill **and** the * taps and this list is the tap half of it — `hole-mode.ts` § * `formsAskingTaps` is what a tick there writes; + * - **the vendor and the family** (Paul, 2026-09-11: "I don't see the filter + * option for vendor when I am selecting a tap … I should, and if vendors + * don't have taps, it should simply show zero. Same thing for family"). They + * carried no funnel because the tool query does not reach the swept pool — + * but a pool is a list of tools like any other, and the page narrows it on + * these two axes itself (`routes/part.tsx` § `tapRows`). A vendor with no tap + * for this thread is then a nought behind the `…` row, which is what every + * other contextual list does with a value it is not holding, rather than a + * question the header refuses to ask; * - **the thread diameter and the thread length**, which the sweep already * narrowed on and nothing said so (Paul, 2026-09-09: "shouldn't thread * diameter and thread length be applied from the thread spec and model @@ -216,10 +223,11 @@ export const sayBound = ( */ const TAP_STATED = ['DC', 'LCF'] +/** The tap columns a funnel narrows on, all of them answered over the pool. */ +const TAP_ASKED = ['catalogNumber', 'type', 'brand', 'family'] + export const askOfTapColumn = (code: string): ColumnAsk | null => - code === 'catalogNumber' || code === 'type' || TAP_STATED.includes(code) - ? askOfToolColumn(code) - : null + TAP_ASKED.includes(code) || TAP_STATED.includes(code) ? askOfToolColumn(code) : null /** * The name each axis wears where no column carries it. diff --git a/apps/catalog/app/styles.css b/apps/catalog/app/styles.css index 0aa1be0..7b9f9c4 100644 --- a/apps/catalog/app/styles.css +++ b/apps/catalog/app/styles.css @@ -1,22 +1,51 @@ @import 'tailwindcss'; @import '@toolpath/ui/theme.css'; -/** - * The state colours, a shade quieter. - * - * Tailwind's own emerald, amber and red are pitched for a white page; against - * this ground they shouted next to `@toolpath/ui`'s palette, which is already - * soft (its danger is #ba5b4b, its info #6bb0b3). These are the same hues at - * lower chroma so a tick, a caution and a refusal still read apart from each - * other at a glance without pulling the eye off the part (Paul, 2026-08-31: - * "mute the colour palette just a touch"). - * - * Muted, not faint: the first pass took them far enough down that a caution - * at 12 px was a smudge, so each is a step brighter than it was and the three - * are further apart from each other (Paul, 2026-08-31: "the orange warning - * check is hard to read"). - */ @theme { + /** + * One corner radius, half what it was. + * + * The page used four steps of Tailwind's scale — `sm` through `xl` here, and + * `sm`/`md`/`lg` inside `@toolpath/ui` — so a card, a button and a menu all + * rounded off by a different amount and the difference read as noise rather + * than as hierarchy (Paul, 2026-09-11: "make them all the same and 1/2 of + * what they are now"). Every step is now the one value, 4 px: half of + * `rounded-lg`, which is the radius this application reached for most. + * + * Redefining the scale rather than rewriting five hundred class names, which + * is also what makes it hold for the kit: `@toolpath/ui`'s `@source './src'` + * compiles its utilities in this build, against these tokens. + * + * `rounded-full` is untouched on purpose — it is `calc(infinity * 1px)` + * rather than a step of the scale, and a pill or a dot is a shape, not a + * corner. + */ + --radius-xs: 0.25rem; + --radius-sm: 0.25rem; + --radius-md: 0.25rem; + --radius-lg: 0.25rem; + --radius-xl: 0.25rem; + --radius-2xl: 0.25rem; + --radius-3xl: 0.25rem; + --radius-4xl: 0.25rem; + /* Bare `rounded`, `rounded-t` and friends, which `@toolpath/ui` uses. */ + --radius: 0.25rem; + + /** + * The state colours, a shade quieter. + * + * Tailwind's own emerald, amber and red are pitched for a white page; against + * this ground they shouted next to `@toolpath/ui`'s palette, which is already + * soft (its danger is #ba5b4b, its info #6bb0b3). These are the same hues at + * lower chroma so a tick, a caution and a refusal still read apart from each + * other at a glance without pulling the eye off the part (Paul, 2026-08-31: + * "mute the colour palette just a touch"). + * + * Muted, not faint: the first pass took them far enough down that a caution + * at 12 px was a smudge, so each is a step brighter than it was and the three + * are further apart from each other (Paul, 2026-08-31: "the orange warning + * check is hard to read"). + */ --color-emerald-300: #96d0b3; --color-emerald-400: #7ec4a3; --color-emerald-500: #63a888; diff --git a/apps/catalog/tests/on-the-part.spec.ts b/apps/catalog/tests/on-the-part.spec.ts index 9f7f6b3..65fbf11 100644 --- a/apps/catalog/tests/on-the-part.spec.ts +++ b/apps/catalog/tests/on-the-part.spec.ts @@ -2629,10 +2629,10 @@ test.describe('the tool assembly tree', () => { await expect(tree.getByRole('button', { name: /^TOOL for / })).not.toContainText('—') await expect(list).toBeHidden() - // The button under the stack is where it becomes a feature, and it says - // that is what it will do. + // The button under the stack is where it becomes a feature. It says so by + // its name alone — the note under it came out on 2026-09-11. const add = tree.getByRole('button', { name: 'Add to order list' }) - await expect(tree.getByText(/Adds the feature to the list as well/)).toBeVisible() + await expect(tree.getByText(/to the list as well/)).toHaveCount(0) await add.click() /* diff --git a/apps/catalog/tests/threaded-hole.spec.ts b/apps/catalog/tests/threaded-hole.spec.ts index d46f6d9..43c3fc5 100644 --- a/apps/catalog/tests/threaded-hole.spec.ts +++ b/apps/catalog/tests/threaded-hole.spec.ts @@ -368,3 +368,67 @@ test('leaves the group at one hole when the offer is turned down', async ({ page await expect(chips).toHaveCount(1) await expect(page.getByRole('button', { name: /^Add all \d+ to the group$/ })).toHaveCount(0) }) + +/** + * **A tap list narrows on its vendor and its family like every other list** + * (Paul, 2026-09-11: "I don't see the filter option for vendor when I am + * selecting a tap for a tool assembly. Why is that? I should, and if vendors + * don't have taps, it should simply show zero. Same thing for family"). + * + * The taps are swept out of the catalog by the thread rather than narrowed by + * the tool query, and both headings carried no funnel at all because of it — + * a rule about where the rows come from, answering a question about what a row + * says. The pool is a list of tools, so the page narrows it on the two term + * axes itself; a vendor with no tap for this thread is a nought behind the `…` + * row rather than a question the header refuses to ask. + */ +test('narrows the taps by vendor, and offers the vendors with none at nought', async ({ page }) => { + await open(page, 'TAP') + await expect(table(page).getByText('VTSFT0250')).toBeVisible() + + await page.getByRole('button', { name: 'Filter by Vendor', exact: true }).click() + const picker = page.getByRole('group', { name: 'Vendor' }) + + // The one vendor holding a tap for this thread, counted. + await expect(picker.locator('[data-term-option="WIDIA"] [data-term-count]')).toHaveText('1') + + // And the vendor that holds none, behind the `…` row at nought — the catalog + // has it, this list does not, and pressing it is how the question widens. + await picker.locator('[data-expand-filter]').click() + await expect(picker.locator('[data-term-option="Kennametal"] [data-term-count]')).toHaveText('0') + + // The tick is the truth in both directions: the vendor with no tap empties + // the list, and the one with a tap brings it back. + await picker.getByRole('checkbox', { name: /^Kennametal/ }).click() + await expect(table(page).getByText('VTSFT0250')).toBeHidden() + await picker.getByRole('checkbox', { name: /^Kennametal/ }).click() + await expect(table(page).getByText('VTSFT0250')).toBeVisible() +}) + +/** + * **And on its family, which is the same question about the other column** + * (Paul, 2026-09-11: "Same thing for family"). Separate because the family is + * read out under the vendor's own title rather than as the stored id, so a + * funnel that offered the id would be a filter nobody could match to a row. + */ +test('narrows the taps by family, under the name the column shows', async ({ page }) => { + await open(page, 'TAP') + await expect(table(page).getByText('VTSFT0250')).toBeVisible() + + await page.getByRole('button', { name: 'Filter by Family', exact: true }).click() + const picker = page.getByRole('group', { name: 'Family' }) + /* + The family's **title**, not the `sample-inch-taps` id the row stores — a + funnel offering the id would be a filter nobody could match to a column. + */ + const family = 'Sample inch spiral-flute taps' + await expect(picker.locator('[data-term-option="sample-inch-taps"]')).toContainText(family) + + await picker.getByRole('checkbox', { name: family, exact: true }).click() + await expect(table(page).getByText('VTSFT0250')).toBeVisible() + // The press that clears says which filters it is clearing, this one included. + await expect(page.getByRole('button', { name: /^Clear \d+ filters?$/ })).toHaveAttribute( + 'title', + /Family/, + ) +}) diff --git a/docs/TOOL-CATALOG-PLAN.md b/docs/TOOL-CATALOG-PLAN.md index 1b74449..80bf4b2 100644 --- a/docs/TOOL-CATALOG-PLAN.md +++ b/docs/TOOL-CATALOG-PLAN.md @@ -722,7 +722,7 @@ things follow from that wording. unticking one phrase of a form narrows to the other rather than dropping the form the geometry asked for. -### The tap list asks two of them +### What the tap list asks The tap list was outside all of this and it was the sharpest case of the same defect (Paul, 2026-09-09: "when I change a hole to threaded — when I am in the @@ -731,14 +731,31 @@ the catalog by the thread — `makersFor` — rather than narrowed by the tool query, so it carried no funnels at all while the chrome over it counted three filters. -`askOfTapColumn` is what it answers, and it is two questions: the catalog -number, which every list of tools searches, and **Type**, because a threaded -hole's `form` filter is the drill _and_ the taps and this list is the tap half -of it. Everything else about a tap — its vendor, its thread length, its flutes — -is the thread's, and a funnel over one would be a control that changes nothing. -`ToolColumnFiltering.ask` is how a list says which rule it is under, and an -absent `onRange` says the same thing from the other side: a range column with -nowhere to send its answer asks nothing. +`askOfTapColumn` is what it answers, and it is four questions: the catalog +number, which every list of tools searches; **Type**, because a threaded hole's +`form` filter is the drill _and_ the taps and this list is the tap half of it; +and **Vendor** and **Family** (Paul, 2026-09-11: "I don't see the filter option +for vendor when I am selecting a tap for a tool assembly. Why is that? I should, +and if vendors don't have taps, it should simply show zero. Same thing for +family"). + +Those last two were the rule overshooting. Where the rows come from is a fact +about the sweep; what a row _says_ is a fact about the tool, and the swept pool +is a list of tools like any other — so the page narrows it on the two term axes +itself (`routes/part.tsx` § `tapPool`, `tapTerms`, `tapRows`), against the same +`query.terms` the drill list reads, and counts them with `countsByAxis` the way +the tool list counts its own facets. A vendor holding no tap for this thread is +then a nought behind the `…` row — the answer every other contextual list gives +a value it is not holding — rather than a question the header refuses to ask. +What is left is the thread's own numbers: a tap's thread length and its flutes +are not somebody's to type, and a funnel over one would be a control that +changes nothing. `ToolColumnFiltering.ask` is how a list says which rule it is +under, and an absent `onRange` says the same thing from the other side: a range +column with nowhere to send its answer asks nothing. + +`tests/threaded-hole.spec.ts` § _narrows the taps by vendor_ and § _by family_ +are the sensors, over the sample catalog's one tap vendor and the one that holds +none. A tick there writes `formsAskingTaps` (`shared/hole-mode.ts`) — **the tap half of the `form` axis moves and the drill half never does.** That is the whole @@ -801,7 +818,10 @@ Each list counts its own: the holder and collet racks their own queries, the tap list what `askOfTapColumn` asks and states, the tool list the whole query. Clearing follows the same split — the tap list's clear puts every kind of tap back and leaves the drill half alone, because clearing the list somebody is looking at -must not silently widen the one on the tab beside it. +must not silently widen the one on the tab beside it. It does clear the vendor +and the family, which narrow the taps as of 2026-09-11: a press that says it +clears the filters and leaves one vendor ticked is the same disagreement between +the button and the funnels from the other end. `tests/on-the-part.spec.ts` § _says in the headings what the feature narrowed the list by_ is the sensor: it counts the filled funnels on the page and From c641d3b57c90330e28b404ef722916e21b84868d Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Fri, 11 Sep 2026 15:14:30 -0400 Subject: [PATCH 04/19] table filtering fixes --- AGENTS.md | 1 + .../app/client/catalog-matcher.test.ts | 2 +- .../app/components/column-resizer.test.tsx | 74 ++++++++ .../catalog/app/components/column-resizer.tsx | 138 ++++++++++++++ apps/catalog/app/routes/part.tsx | 98 +++++++--- .../app/shared/assembly-narrowing.test.ts | 41 ++++ apps/catalog/app/shared/assembly-narrowing.ts | 43 ++++- .../app/shared/catalog-matcher.test.ts | 40 +++- apps/catalog/app/shared/catalog-matcher.ts | 51 ++--- apps/catalog/app/shared/column-width.test.ts | 104 ++++++++++ apps/catalog/app/shared/column-width.ts | 152 +++++++++++++++ apps/catalog/app/shared/component-columns.ts | 18 ++ apps/catalog/app/shared/filter.test.ts | 9 +- apps/catalog/app/shared/filter.ts | 30 +-- apps/catalog/app/shared/hole-mode.ts | 6 +- apps/catalog/app/shared/judge.test.ts | 9 +- apps/catalog/app/shared/rules.csv | 2 +- apps/catalog/app/shared/tool-fit.test.ts | 63 ++++++- apps/catalog/app/shared/tool-fit.ts | 71 +++++-- apps/catalog/app/shared/tool-marks.test.ts | 67 +++++++ apps/catalog/app/shared/tool-marks.ts | 26 ++- apps/catalog/tests/on-the-part.spec.ts | 178 +++++++++++++++++- apps/catalog/tsconfig.tsbuildinfo | 2 +- docs/CATALOG-SPEC.md | 23 ++- docs/FEATURE-LIST.md | 21 ++- docs/RULES.md | 13 +- 26 files changed, 1169 insertions(+), 113 deletions(-) create mode 100644 apps/catalog/app/components/column-resizer.test.tsx create mode 100644 apps/catalog/app/components/column-resizer.tsx create mode 100644 apps/catalog/app/shared/column-width.test.ts create mode 100644 apps/catalog/app/shared/column-width.ts diff --git a/AGENTS.md b/AGENTS.md index e9df555..e660a3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -275,6 +275,7 @@ application unless that application says otherwise. | 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 column is, and the handle on its edge | `app/shared/column-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` | 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/column-resizer.test.tsx b/apps/catalog/app/components/column-resizer.test.tsx new file mode 100644 index 0000000..850648b --- /dev/null +++ b/apps/catalog/app/components/column-resizer.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { ColumnResizer } from './column-resizer' +import { NARROWEST, OPENS_AT, OPENS_AT_FOR_A_GROUP } from 'shared/column-width' + +/** + * The handle on the right edge of the column over the part (Paul, 2026-09-11). + * + * What a width may *be* is `shared/column-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 column 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 column 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 — + * `widestColumn` answers a width rather than nothing for a room it cannot + * measure, which is what stops an arrow press snapping the column 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 column 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/column-resizer.tsx b/apps/catalog/app/components/column-resizer.tsx new file mode 100644 index 0000000..4568322 --- /dev/null +++ b/apps/catalog/app/components/column-resizer.tsx @@ -0,0 +1,138 @@ +import { useRef, useState } from 'react' +import { cn } from '@toolpath/ui' +import { clampColumn, widestColumn, NARROWEST } from 'shared/column-width' + +/** + * The right edge of the column 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 column carries all of them, so this is + * one handle, and `shared/column-width.ts` holds every number it clamps to. + * + * **It measures the viewer itself.** The clamp needs the room the column 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 column 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 ColumnResizerProps { + /** What the column is drawn at now, which is where a drag starts from. */ + readonly width: number + /** A width dragged to, already the column'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 column 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 ColumnResizer = ({ width, onResize, onReset }: ColumnResizerProps) => { + /** Where the drag started, and how wide the column 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(clampColumn(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 column, 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/routes/part.tsx b/apps/catalog/app/routes/part.tsx index f144e96..79edc4b 100644 --- a/apps/catalog/app/routes/part.tsx +++ b/apps/catalog/app/routes/part.tsx @@ -50,6 +50,7 @@ import { SelectionPanel } from 'components/selection-panel' import { PredrillChoice } from 'components/predrill-choice' import { FeatureListPanel } from 'components/feature-list-panel' import { AddBar } from 'components/add-bar' +import { ColumnResizer } from 'components/column-resizer' import { ComponentTally, KIND_LABEL, type ComponentTallyRow } from 'components/component-tally' import { NoColletToggle } from 'components/no-collet-toggle' import { ColletIcon, HolderIcon, ToolTypeIcon } from 'components/tool-icons' @@ -113,6 +114,7 @@ import { } from 'shared/catalog' import { columnFilterOpen, useEscape } from 'shared/use-escape' import { assemblyPressEnabled, pressesShown, rowsShown } from 'shared/part-chrome' +import { columnWidth, useColumnWidth } from 'shared/column-width' import { COMPONENT_LIST, TOOL_LIST, @@ -164,6 +166,7 @@ import { import { byShank, colletGapFor, + colletNotNeeded, holdersToOffer, narrowCollets, narrowTools, @@ -1573,9 +1576,9 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: * The bounds somebody set themselves, as against the ones this feature asked * for — `ownBounds` in `shared/filter.ts` is the rule. * - * Two things read it, and both are about a filter being the last word rather - * than a tolerance: which of the removed tools may stand in when nothing - * fits, and which columns still have an override to keep. + * One thing reads it: which columns still have an override to keep. What the + * near-miss stand-in may be outside is asked a tool at a time instead, and + * against the whole rail — `nearEnough` in `shared/tool-fit.ts`. */ const own = useMemo( () => ownBounds(query.ranges, suggestions.ranges), @@ -1891,7 +1894,11 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: margins, thresholds, overrides: forgiven, - ownRanges: own, + /* + What the geometry asked for, which the near-miss stand-in reads against + what the rail now holds: `nearEnough` in `shared/tool-fit.ts`. + */ + suggestedRanges: suggestions.ranges, }), [ report.features, @@ -1903,7 +1910,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: margins, thresholds, forgiven, - own, + suggestions, ], ) const tableDemand = useMemo(() => { @@ -2143,7 +2150,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: if (!asking || (tools.length > 0 && !shortOfDrills)) { return [] } - const admitted = closeCandidates(nearMisses, query, own) + const admitted = closeCandidates(nearMisses, query, suggestions.ranges) /** * **A tapped hole is drilled.** The nearest misses are drawn from what the * rules removed, and a mill that could interpolate the bore is a near miss @@ -2187,7 +2194,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: shortOfDrills, nearMisses, query, - own, + suggestions, holeChoice.mode, outOfReach, predrillForms, @@ -4521,6 +4528,20 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: /** Whether the three presses are drawn over the box — `pressesShown` is why. */ const addBarShown = pressesShown(box) + /** + * How wide the one column over the part is (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"). + * + * The presses, the box, the fold and the rows are one column, so there is one + * width and its right edge is the handle — `components/column-resizer.tsx`. + * `shared/column-width.ts` is every rule about it: the two ends it clamps to, + * the defaults a shop that has never dragged it gets, and why a stated width + * outlives the group editor's wider default. + */ + const { stated: statedColumn, state: stateColumn } = useColumnWidth() + const columnWide = columnWidth(statedColumn, draft?.kind === 'group') + /** * The holders on show: what can hold what is already in the stack, narrowed * by the crib's own filters and then by the table's. @@ -6362,13 +6383,28 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: part. */}
{/* **The three ways to add sit over the part, above @@ -6934,6 +6970,20 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId:
) : null} + {/* + **The right edge is a handle** (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 column carries all of + them, so one handle widens all of them at once, and + `shared/column-width.ts` holds every number it clamps + to. Double-click puts the defaults back. + */} + stateColumn(null)} + />
@@ -7206,17 +7256,21 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: // for that reason rather than for an empty crib. asking, /* - And either list can now be empty because of the - collet drawer rather than because of a choice: a - collet slot under a chuck nothing in the crib - closes on, and a holder rack with the press in - the chrome turned off. Both are more specific - than the two general answers, and both name - something to do. + And either list can now be empty for a reason + that is not a choice at all: a collet slot under + a holder that grips the shank itself, a collet + slot under a chuck nothing in the crib closes + on, and a holder rack with the press in the + chrome turned off. All three are more specific + than the two general answers, and the first says + the stack is finished rather than stuck. */ { ...(componentSlot === 'collet' && treeHolder !== null - ? { gap: holderGap(treeHolder) } + ? { + gap: holderGap(treeHolder), + noneNeeded: colletNotNeeded(treeHolder), + } : {}), ...(componentSlot === 'holder' && !noCollet ? { hidden: gappedHolders } diff --git a/apps/catalog/app/shared/assembly-narrowing.test.ts b/apps/catalog/app/shared/assembly-narrowing.test.ts index 3f5bc2b..0adae9c 100644 --- a/apps/catalog/app/shared/assembly-narrowing.test.ts +++ b/apps/catalog/app/shared/assembly-narrowing.test.ts @@ -4,6 +4,7 @@ import { NOTHING_CHOSEN, colletGap, colletGapFor, + colletNotNeeded, holderTakesCollet, holdersToOffer, narrowCollets, @@ -383,6 +384,46 @@ describe('why an offered holder cannot be built out of the crib', () => { ) }) + /** + * **A holder that grips the shank is not an empty list to fix** (Paul, + * 2026-09-11). Before this sentence, opening the collet slot under a shrink + * fit fell through to the choices and read "Nothing fits alongside + * BT30SF12060M. Clear one of them to widen the list" — a dead end, and an + * instruction to undo a holder that is working as intended. + */ + it('says a shrink fit holder needs no collet at all', () => { + const shrink = holder({ clamping: 'shrink', colletSeries: null, boreDiameter: 12 }) + expect(colletNotNeeded(shrink)).toBe('No collet required for a shrink fit holder.') + }) + + /** The kind is named the way the Type column names it, with its own article. */ + it('names the other holders that grip the shank', () => { + expect(colletNotNeeded(holder({ clamping: 'bore', colletSeries: null }))).toBe( + 'No collet required for an end mill holder.', + ) + expect(colletNotNeeded(holder({ clamping: 'hydraulic', colletSeries: null }))).toBe( + 'No collet required for a hydraulic chuck.', + ) + }) + + it('says nothing about a chuck that does take one', () => { + expect(colletNotNeeded(er16Chuck)).toBeNull() + }) + + /** + * Over the gap as well as over the choices: a holder taking no collet is not + * an emptiness to do something about, and the gap is null for one anyway. + */ + it('puts it over everything else when the collet list comes back empty', () => { + const shrink = holder({ clamping: 'shrink', colletSeries: null, boreDiameter: 12 }) + expect( + whyEmpty(0, { tool: small, holder: shrink, collet: null }, {}, true, { + gap: null, + noneNeeded: colletNotNeeded(shrink), + }), + ).toBe('No collet required for a shrink fit holder.') + }) + /** * **The narrower rack is the one the page opens on** (Paul, 2026-09-10: "by * default, the holders with no collet should be hidden"), which makes its diff --git a/apps/catalog/app/shared/assembly-narrowing.ts b/apps/catalog/app/shared/assembly-narrowing.ts index 091f487..b54f59a 100644 --- a/apps/catalog/app/shared/assembly-narrowing.ts +++ b/apps/catalog/app/shared/assembly-narrowing.ts @@ -11,6 +11,7 @@ import { type Holder, type HolderFilters, } from '@toolpath/catalog-data' +import { clampingPhrase } from './component-columns' /** * Every list in the tree narrowed by every other choice in it. @@ -271,6 +272,32 @@ export const colletGap = ( ? null : gapWords(holder, collets, false) +/** `a` or `an`, for a phrase only known at runtime. */ +const article = (phrase: string): string => (/^[aeiou]/i.test(phrase) ? 'an' : 'a') + +/** + * Why a collet list is empty because of what the holder **is**, or null where + * the holder takes a collet at all. + * + * **Not every empty list is a gap or a choice** (Paul, 2026-09-11). A shrink + * fit, a hydraulic chuck and an end mill holder grip the shank directly, so + * their collet list is empty by mechanics — there is nothing to stock and + * nothing to undo. Until this sentence existed the page fell through to the + * choices and said "Nothing fits alongside ``. Clear one of them to + * widen the list", which reads as a dead end and asks a shop to give up a + * holder that is working exactly as intended. + * + * The words for the kind are {@link clampingPhrase}'s, so the sentence names + * the holder the same way the Type column does. + */ +export const colletNotNeeded = (holder: Holder): string | null => { + if (holderNeedsCollet(holder)) { + return null + } + const phrase = clampingPhrase(holder) + return `No collet required for ${article(phrase)} ${phrase}.` +} + /** * The same question asked of the shanks a slot with no tool chosen is standing * in for. @@ -398,12 +425,26 @@ export const whyEmpty = ( * something was hidden read the same on screen and mean opposite things. */ readonly hidden?: number + /** + * The sentence for a holder that takes no collet at all — + * {@link colletNotNeeded}. + * + * First of the three, because it is not an emptiness at all: the other two + * are a drawer to buy from and a press to turn on, and this one is the + * stack being finished. Passed in rather than read off `holder` here, + * because the holder list is asked the same question and an end mill holder + * chosen in it says nothing about why *that* list came back empty. + */ + readonly noneNeeded?: string | null } = {}, ): string | null => { if (shown > 0) { return null } - const { gap = null, hidden = 0 } = because + const { gap = null, hidden = 0, noneNeeded = null } = because + if (noneNeeded !== null) { + return noneNeeded + } if (gap !== null) { return `No collet fits this holder: ${gap}. Order the holder on its own, or choose another.` } diff --git a/apps/catalog/app/shared/catalog-matcher.test.ts b/apps/catalog/app/shared/catalog-matcher.test.ts index cfceeef..b7a5121 100644 --- a/apps/catalog/app/shared/catalog-matcher.test.ts +++ b/apps/catalog/app/shared/catalog-matcher.test.ts @@ -55,7 +55,7 @@ const context = (features: ReadonlyArray): MatchContext => ({ margins: { radial: 0, axial: 0 }, thresholds: thresholdsFrom(), overrides: [], - ownRanges: {}, + suggestedRanges: {}, }) const catalog = { tools: [tool('SMALL', 6), tool('LARGE', 10)], holders: [], collets: [] } @@ -238,11 +238,15 @@ describe('catalog matcher protocol', () => { expect(prepared.considered.map((each) => each.guid)).toEqual(['SMALL', 'MID', 'WIDE']) expect(prepared.admitted.map((each) => each.guid)).toEqual(['MID']) - // The rules were run over all three, so the one the range hides is still a - // near miss the panel can offer rather than a tool nobody ever judged. + // The rules were run over all three, so the one the range hides is judged + // rather than unjudged: it is in the removed set with its reason, ready to + // stand in the moment the bound that hides it is not somebody's own. expect(result.fitting.map((each) => each.toolGuid).sort()).toEqual(['MID', 'SMALL']) expect(result.narrowedGuids).toEqual(['MID']) - expect(result.nearMisses.map((each) => each.toolGuid)).toEqual(['WIDE']) + expect(result.excludedCount).toBe(1) + // Nobody suggested this bound, so it is the question and WIDE is not close + // to it — `nearEnough` in `shared/tool-fit.ts`. + expect(result.nearMisses.map((each) => each.toolGuid)).toEqual([]) }) /** @@ -337,11 +341,11 @@ describe('catalog matcher protocol', () => { const result = detailedMatch(input, { demandKey: 'one', tags: [feature.featureTag] }, wide) // MID fits, so there is nothing being kept from the person by the rules — - // WIDE is outside their own range and is a near miss rather than an - // override. + // and WIDE is outside a range they typed themselves, which is neither an + // override nor a near miss. expect(result.overridable).toEqual([]) expect(result.overridableByCode).toEqual({}) - expect(result.nearMisses.map((each) => each.toolGuid)).toEqual(['WIDE']) + expect(result.nearMisses.map((each) => each.toolGuid)).toEqual([]) }) /** @@ -673,11 +677,31 @@ describe('the misses a typed bound leaves standing', () => { it('narrows to the bound before the nearest are taken', () => { const result = detailedMatch( - { ...context([feature]), ownRanges: { NOF: { max: 3 } } }, + { ...context([feature]), query: { ...EMPTY_QUERY, ranges: { NOF: { max: 3 } } } }, demand, crib, ) expect(result.nearMisses.map((each) => each.toolGuid)).toEqual(['three']) }) + + /** + * **The geometry's own bound is narrowed on too, everywhere but the column + * the tool missed on** (Paul, 2026-09-11). Every one of these misses the + * pocket on its *diameter*, so the diameter bound the pocket wrote is the one + * they may be outside — and the flute count the shop's sheet wrote is not. + */ + it("forgives the geometry's bound only on the column that removed the tool", () => { + const bounded = { + ...context([feature]), + query: { ...EMPTY_QUERY, ranges: { DC: { max: 10 }, NOF: { max: 3 } } }, + suggestedRanges: { DC: { max: 10 }, NOF: { max: 3 } }, + } + + const result = detailedMatch(bounded, demand, crib) + + // Wider than the bound and forgiven for it; four flutes against a bound of + // three, which nothing removed them for, and gone. + expect(result.nearMisses.map((each) => each.toolGuid)).toEqual(['three']) + }) }) diff --git a/apps/catalog/app/shared/catalog-matcher.ts b/apps/catalog/app/shared/catalog-matcher.ts index 0909a1e..7351f80 100644 --- a/apps/catalog/app/shared/catalog-matcher.ts +++ b/apps/catalog/app/shared/catalog-matcher.ts @@ -14,7 +14,6 @@ import { facetsNarrowing, filterTools, FACET_AXES, - withinRanges, withoutFacets, withoutTerm, type ToolQuery, @@ -26,6 +25,7 @@ import { closestMisses, closestPerForm, type Format, type Reason, type Verdict } import { sectionOf } from './section-of' import { fittingTools, + nearEnough, overridableCount, overridableTally, overridableTools, @@ -70,18 +70,18 @@ export interface MatchContext { */ readonly overrides: ReadonlyArray /** - * The range bounds somebody set themselves, as against the ones this feature - * asked for — `ownBounds` in `shared/filter.ts`, computed where the - * suggestions are. + * The range bounds **this feature asked for**, as against the ones in + * `query.ranges`, which are whatever the rail now holds. * * It narrows one thing: which of the removed tools may stand in when nothing - * fits. A near miss is a tool a little outside the *geometry's* bounds, and - * never one outside a bound somebody typed (Paul, 2026-09-10). It is in the - * context rather than derived from `query` because only the page knows what - * the feature suggested, and it has to be in the key that owns the answer for - * the same reason `overrides` is. + * fits. A near miss is a tool a little outside the bound it missed on, and + * inside every other bound on screen — `nearEnough` in `shared/tool-fit.ts` + * is the rule and says what each half of it cost. It is in the context rather + * than derived from `query` because only the page knows what the feature + * suggested, and it has to be in the key that owns the answer for the same + * reason `overrides` is. */ - readonly ownRanges: ToolQuery['ranges'] + readonly suggestedRanges: ToolQuery['ranges'] } /** One question in a table request or a recommendation batch. */ @@ -358,7 +358,7 @@ export const matchKey = ( // move the removed set: a one-each pick is never drawn from it. context: { ...(kind === 'recommendations' - ? { ...context, unit: 'millimeters', overrides: [], ownRanges: {} } + ? { ...context, unit: 'millimeters', overrides: [], suggestedRanges: {} } : { ...context, overrides: [...context.overrides].sort() }), features: featuresKey(context.features), }, @@ -626,22 +626,24 @@ const OVERRIDABLE = 2000 * One form, or none, is the whole removed set ranked once, which is what it * always was. * - * **The bounds somebody typed narrow the set before it is ranked, not after** - * (Paul, 2026-09-10). Fifty nearest misses to a rule are fifty tools chosen - * without ever asking the flute count, so narrowing them on the far side of - * the boundary can only empty the list: the one three-flute tool that misses - * by a little was never among the fifty. The whole removed set is here, which - * is the only place the question can be asked without a cap over the answer. + * **The bounds on screen narrow the set before it is ranked, not after** (Paul, + * 2026-09-10). Fifty nearest misses to a rule are fifty tools chosen without + * ever asking the flute count, so narrowing them on the far side of the + * boundary can only empty the list: the one three-flute tool that misses by a + * little was never among the fifty. The whole removed set is here, which is the + * only place the question can be asked without a cap over the answer. Which + * bound holds for which tool is `nearEnough` in `shared/tool-fit.ts`. */ const nearestFew = ( excluded: ReadonlyArray, forms: ReadonlyArray, - own: ToolQuery['ranges'], + ranges: ToolQuery['ranges'], + suggested: ToolQuery['ranges'], ): Array => { const asked = - Object.keys(own).length === 0 + Object.keys(ranges).length === 0 ? excluded - : excluded.filter((verdict) => withinRanges(verdict.tool, own)) + : excluded.filter((verdict) => nearEnough(verdict, ranges, suggested)) const overall = closestMisses(asked, NEAR_MISSES) if (forms.length < 2) { return overall @@ -809,9 +811,12 @@ export const detailedMatch = ( return { demandKey: demand.demandKey, fitting: matched.fitting.map(compact), - nearMisses: nearestFew(matched.excluded, context.query.terms.form ?? [], context.ownRanges).map( - compact, - ), + nearMisses: nearestFew( + matched.excluded, + context.query.terms.form ?? [], + context.query.ranges, + context.suggestedRanges, + ).map(compact), overridable: overridableTools( matched.excluded, prepared.admittedGuids, diff --git a/apps/catalog/app/shared/column-width.test.ts b/apps/catalog/app/shared/column-width.test.ts new file mode 100644 index 0000000..c4098e9 --- /dev/null +++ b/apps/catalog/app/shared/column-width.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { + clampColumn, + columnWidth, + NARROWEST, + OPENS_AT, + OPENS_AT_FOR_A_GROUP, + readColumnWidth, + widestColumn, + WIDEST_SHARE, + writeColumnWidth, +} from './column-width' + +/** A `localStorage` that is only a map, which is all these rules ask of one. */ +const storage = () => { + const held = new Map() + return { + getItem: (key: string) => held.get(key) ?? null, + setItem: (key: string, value: string) => void held.set(key, value), + removeItem: (key: string) => void held.delete(key), + get size() { + return held.size + }, + } +} + +describe('what the column opens at', () => { + it('opens a reading at one width and a group at a wider one', () => { + expect(columnWidth(null, false)).toBe(OPENS_AT) + expect(columnWidth(null, true)).toBe(OPENS_AT_FOR_A_GROUP) + }) + + /** + * The rule the defaults are only a default *of*: a width somebody dragged to + * is the width, and opening the group editor does not walk on it. A column + * that jumped 96px every time a group opened would be undoing that drag. + */ + it('keeps a stated width when a group opens', () => { + expect(columnWidth(288, true)).toBe(288) + expect(columnWidth(288, false)).toBe(288) + }) + + it('never opens narrower than a row can be read at', () => { + expect(columnWidth(40, false)).toBe(NARROWEST) + }) +}) + +describe('how far the edge may be dragged', () => { + it('holds a drag between the narrowest and a share of the viewer', () => { + expect(clampColumn(80, 1200)).toBe(NARROWEST) + expect(clampColumn(500, 1200)).toBe(500) + expect(clampColumn(2000, 1200)).toBe(1200 * WIDEST_SHARE) + }) + + /** + * A viewer not laid out yet is not a viewer of zero width: clamping a drag + * against it would snap the column to the floor and then snap it back when the + * measurement turned up. + */ + it('falls back to a width rather than to nothing when the room is unknown', () => { + expect(widestColumn(0)).toBe(OPENS_AT_FOR_A_GROUP) + expect(widestColumn(Number.NaN)).toBe(OPENS_AT_FOR_A_GROUP) + expect(clampColumn(2000, 0)).toBe(OPENS_AT_FOR_A_GROUP) + }) + + /** A viewer too narrow for both still gives the column a column to be. */ + it('never lets the ceiling fall under the floor', () => { + expect(widestColumn(200)).toBe(NARROWEST) + }) + + it('answers a width for a drag that is not a number', () => { + expect(clampColumn(Number.NaN, 1200)).toBe(OPENS_AT) + }) +}) + +describe('what the browser remembers', () => { + it('says nothing for a shop that has never dragged it', () => { + expect(readColumnWidth(storage())).toBeNull() + expect(readColumnWidth(null)).toBeNull() + }) + + it('reads back what was stated, rounded', () => { + const held = storage() + writeColumnWidth(held, 412.6) + expect(readColumnWidth(held)).toBe(413) + }) + + /** Forgetting it is what puts the two defaults back, so it is a removal. */ + it('forgets it rather than storing a default', () => { + const held = storage() + writeColumnWidth(held, 500) + writeColumnWidth(held, null) + expect(held.size).toBe(0) + expect(columnWidth(readColumnWidth(held), true)).toBe(OPENS_AT_FOR_A_GROUP) + }) + + it('ignores anything stored that is not a width', () => { + const held = storage() + held.setItem('tool-catalog.column-width', 'wide') + expect(readColumnWidth(held)).toBeNull() + held.setItem('tool-catalog.column-width', '-40') + expect(readColumnWidth(held)).toBeNull() + }) +}) diff --git a/apps/catalog/app/shared/column-width.ts b/apps/catalog/app/shared/column-width.ts new file mode 100644 index 0000000..fa6a23b --- /dev/null +++ b/apps/catalog/app/shared/column-width.ts @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useState } from 'react' + +/** + * How wide the column over the part is, and what a shop may drag it to. + * + * **The edge is a handle** (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 column carries all four of those — the three + * presses, the box a press opens, the fold, and the rows — so there is one width + * to state, and stating it is dragging its right edge. + * + * It is a rule here rather than a number in `routes/part.tsx` for the reason + * `part-chrome.ts` and `frame-inset.ts` are: what a width may be is a clamp with + * two ends and a default, and a clamp written inline in a pointer handler is + * where a column ends up wider than the window it stands in. + * + * **The stated width outlives the box.** A group opens wider than a reading does + * — the editor asks for more room — but that is a *default*, not a rule about + * groups: once a shop has said how wide this column is, that is how wide it is + * whatever is in it. A column that jumped 96px every time a group opened would + * be undoing the drag that set it. + */ + +/** Where the width is remembered: the shop's, in this browser, not the part's. */ +const KEY = 'tool-catalog.column-width' + +/** + * The narrowest the column may be dragged. + * + * A row of the order list is a name and its answer, and the tree beside an open + * box is three slots with a component in each. Under this they wrap to the point + * where the column is taller than the viewer and says less, which is a worse + * screen than the part being covered — and there is a fold button for that. + */ +export const NARROWEST = 256 + +/** What the column opens at, never having been dragged: today's `w-80`. */ +export const OPENS_AT = 320 + +/** + * And what it opens at while a group is being built: today's `w-[26rem]`. + * + * The group editor holds the features being grouped, the reading they share and + * the thread, which is more than a reading has to say. + */ +export const OPENS_AT_FOR_A_GROUP = 416 + +/** + * The most of the viewer the column may take. + * + * Past `MOST_OF_IT` in `frame-inset.ts` — two fifths — the camera stops moving + * the part aside, so between there and here the rows sit over the geometry the + * way they did before 2026-09-10. That is a fair thing to ask for while reading + * a long order list, and the rows are translucent; what is not fair is dragging + * the part off screen entirely, which is what the rest of this share is keeping. + * + * **It is a ceiling on the drag, not on the column.** The handle measures the + * viewer at the press and clamps against that, and the route draws the width it + * is given. Saying it a second time in CSS is what it looks like it wants and is + * a circle: the overlay the column stands in is shrink-to-fit, so a percentage + * `max-width` resolves against the column's own width and squeezes it to a share + * of itself — `routes/part.tsx` carries the note. A window narrowed after a drag + * therefore keeps the width it was given; the way back is the handle, or the + * double-click that forgets it. + */ +export const WIDEST_SHARE = 0.7 + +/** + * The widest the column may be dragged, given the viewer it stands over. + * + * A room that has not been measured yet — a zero from a layout not settled, or + * anything that is not a number — gets the default back rather than a guess, + * because a drag against an unknown room is a drag that snaps somewhere + * arbitrary when the room turns up. + */ +export const widestColumn = (room: number): number => { + if (!Number.isFinite(room) || room <= 0) { + return OPENS_AT_FOR_A_GROUP + } + return Math.max(NARROWEST, room * WIDEST_SHARE) +} + +/** A width somebody dragged to, held between the two ends above. */ +export const clampColumn = (wanted: number, room: number): number => { + if (!Number.isFinite(wanted)) { + return OPENS_AT + } + return Math.min(Math.max(wanted, NARROWEST), widestColumn(room)) +} + +/** What the column is drawn at: what the shop stated, or what the box opens at. */ +export const columnWidth = (stated: number | null, buildingAGroup: boolean): number => { + if (stated === null) { + return buildingAGroup ? OPENS_AT_FOR_A_GROUP : OPENS_AT + } + return Math.max(stated, NARROWEST) +} + +/** + * The width this browser has been told, or `null` for a shop that has not said. + * + * `null` rather than `OPENS_AT`, because "not said" is what lets a group open + * wider than a reading — a stored 320 means somebody chose 320 and a group must + * not walk on it. + */ +export const readColumnWidth = (storage: Pick | null): number | null => { + const raw = storage?.getItem(KEY) + if (raw === null || raw === undefined) { + return null + } + const width = Number(raw) + if (!Number.isFinite(width) || width <= 0) { + return null + } + return Math.max(width, NARROWEST) +} + +/** States it, or forgets it — which is what puts the defaults back. */ +export const writeColumnWidth = ( + storage: Pick | null, + width: number | null, +): void => { + if (width === null) { + storage?.removeItem(KEY) + return + } + storage?.setItem(KEY, String(Math.round(width))) +} + +/** + * The stated width, and the two ways a drag ends: at a number, or back at the + * defaults. + * + * Read on mount rather than during render, the way every other preference in + * this application is: the server renders this page too, and a width read out of + * `localStorage` while it does is a hydration mismatch over the whole column. + */ +export const useColumnWidth = () => { + const [stated, setStated] = useState(null) + + useEffect(() => { + setStated(readColumnWidth(globalThis.localStorage ?? null)) + }, []) + + const state = useCallback((width: number | null) => { + const kept = width === null ? null : Math.round(Math.max(width, NARROWEST)) + setStated(kept) + writeColumnWidth(globalThis.localStorage ?? null, kept) + }, []) + + return { stated, state } +} diff --git a/apps/catalog/app/shared/component-columns.ts b/apps/catalog/app/shared/component-columns.ts index 11c627a..65d9ed5 100644 --- a/apps/catalog/app/shared/component-columns.ts +++ b/apps/catalog/app/shared/component-columns.ts @@ -126,6 +126,24 @@ const CLAMPING_NOUN: Readonly> = { hydraulic: 'hydraulic chuck', } +/** + * The same kinds as a noun a sentence can take. + * + * `shrink fit` is the whole answer in a cell headed Type and an adjective in a + * sentence — "no collet required for a shrink fit" names nothing — so it is the + * one kind that needs the word attaching. Derived from {@link CLAMPING_NOUN} + * rather than written out again, so a clamping mode added to one is never + * missing from the other. + */ +const CLAMPING_PHRASE: Readonly> = { + ...CLAMPING_NOUN, + shrink: 'shrink fit holder', +} + +/** How a holder grips, as something a sentence can name: `a shrink fit holder`. */ +export const clampingPhrase = (holder: Pick): string => + CLAMPING_PHRASE[holder.clamping] ?? 'holder' + /** What a holder is, in the words a shop uses for it: `BT30 collet chuck`. */ export const holderTypeLabel = (holder: Holder): string => [ diff --git a/apps/catalog/app/shared/filter.test.ts b/apps/catalog/app/shared/filter.test.ts index b411af9..9b71b84 100644 --- a/apps/catalog/app/shared/filter.test.ts +++ b/apps/catalog/app/shared/filter.test.ts @@ -6,9 +6,9 @@ import { countsByAxis, cycleTerm, filterTools, - ownBounds, prioritise, queryFromSearch, + ownBounds, releasedBounds, searchFromQuery, searchWithQuery, @@ -622,10 +622,9 @@ describe('the type and the family, as one axis each', () => { }) /** - * **A filter is somebody's answer the moment it is not the geometry's.** The - * near-miss stand-in forgives the bounds the feature wrote — they are the rules - * it is measuring "close" against — and obeys every other one (Paul, - * 2026-09-10: at most three flutes, and four-flute tools on the list). + * **A filter is somebody's answer the moment it is not the geometry's**, which + * is how long an override lasts: putting the geometry's own number back is the + * way out of one. */ describe('the bounds somebody set themselves', () => { it('leaves out a bound still reading what the feature suggested', () => { diff --git a/apps/catalog/app/shared/filter.ts b/apps/catalog/app/shared/filter.ts index bf228e8..99106d0 100644 --- a/apps/catalog/app/shared/filter.ts +++ b/apps/catalog/app/shared/filter.ts @@ -160,9 +160,12 @@ const sameEnd = (a: number | undefined, b: number | undefined): boolean => /** * Whether two bounds ask the same thing, allowing for a float's last digit. * - * Here rather than beside the control that draws one because `ownBounds` below - * is the same question asked of the matcher, and `shared/` may not import - * `components/`. + * **A bound is somebody's answer the moment it is not the geometry's** — the + * rule `overrideOffered` states in `components/column-filter.tsx`, the one + * `applySuggestions` tells a stale suggestion from an answer by, and the one + * `nearEnough` in `shared/tool-fit.ts` decides which bound a near miss may be + * outside by. Here rather than beside the control that draws one because + * `shared/` may not import `components/`. */ export const sameBound = ( a: { readonly min?: number; readonly max?: number } | undefined, @@ -179,19 +182,16 @@ export const sameBound = ( * * **A filter is somebody's answer the moment it is not the geometry's** — the * rule `overrideOffered` states in `components/column-filter.tsx`, and the one - * `applySuggestions` tells a stale suggestion from an answer by. A bound still - * reading exactly what this feature suggested is the rules speaking through a - * control; anything else is a shop's own word about what it wants to see. + * `applySuggestions` tells a stale suggestion from an answer by. + * + * What reads it is the way **out** of an override: forgiving a column's rules + * and widening its bound are one decision (`part.tsx` § `overrideFor`), so an + * override lasts exactly as long as the number that asked for it. Put the + * geometry's own number back and the rules come back with it. * - * **What reads it is the near-miss stand-in** (Paul, 2026-09-10: at most three - * flutes, then Kennametal, and four-flute tools on the list — "they should not - * be … we should see 'no tools meet these filters'"). Nothing fits, so the - * closest misses stand in, and they are drawn without the ranges because a tool - * a little outside one is exactly what *closest* means. That is right for the - * bound the geometry wrote and wrong for the bound somebody typed: the second - * is the question, not the tolerance on it. `closeCandidates` and - * `nearestFew` both narrow by this, so a filter is obeyed on the list and in - * the worker that ranks what reaches it. + * Which bound a near miss may be outside is a different question, asked one + * tool at a time rather than one column at a time: `nearEnough` in + * `shared/tool-fit.ts`. * * A bound holding neither end narrows nothing and is nobody's answer. */ diff --git a/apps/catalog/app/shared/hole-mode.ts b/apps/catalog/app/shared/hole-mode.ts index f1dd317..f60eca0 100644 --- a/apps/catalog/app/shared/hole-mode.ts +++ b/apps/catalog/app/shared/hole-mode.ts @@ -153,9 +153,9 @@ export const THREADED_FORMS: ReadonlyArray = [ * **A predrill is a hole, and a hole can be interpolated** (Paul, 2026-09-02: * "I need to be able to use an end mill on a threaded hole in place of a * drill"). The rules sheet already says so — `*Hole` ranks `drill; flat end - * mill; bull nose end mill; ball end mill` and caps a mill at the largest end - * mill diameter, which is short of the bore because it has to helix down - * inside it. What kept them off a threaded hole's list was this application: + * mill; bull nose end mill; ball end mill` and cautions a mill inside the + * largest end mill diameter, which is short of the bore because it has to + * helix down inside it. What kept them off a threaded hole's list was this application: * choosing a thread writes {@link THREADED_FORMS} into the filters, and the * list is drills-only besides. * diff --git a/apps/catalog/app/shared/judge.test.ts b/apps/catalog/app/shared/judge.test.ts index 2d1e734..b9cbf57 100644 --- a/apps/catalog/app/shared/judge.test.ts +++ b/apps/catalog/app/shared/judge.test.ts @@ -296,7 +296,14 @@ describe('closest of each form', () => { tool(name, 'flat end mill', { DC, RE: 0, LCF: 20, LD: 4, LBH: 40, 'shoulder-diameter': DC }) const near = drilling('DRILL-NEAR', 4.3) const far = drilling('DRILL-FAR', 5) - const mill = milling('MILL-NEAR', 4.5) + /* + Wider than the bore, because that is now the only thing that refuses a mill + in a hole: the helix room is a `should` since 2026-09-11, so a mill's + distance is measured from the ⌀4 bore rather than from the ⌀3.6 ramp limit. + The case needs mills that miss by more than the drills do, which is what it + has always been about and what a real catalog does. + */ + const mill = milling('MILL-NEAR', 5.2) const wide = milling('MILL-FAR', 8) const asked = ['drill', 'flat end mill'] const verdicts = judgeTools([near, far, mill, wide], hole, [hole], { asked }) diff --git a/apps/catalog/app/shared/rules.csv b/apps/catalog/app/shared/rules.csv index 28560c1..d22aeb5 100644 --- a/apps/catalog/app/shared/rules.csv +++ b/apps/catalog/app/shared/rules.csv @@ -14,7 +14,7 @@ Wall,no floor,*,,flute length past the corner >= feature depth + through overcut Profile,no floor,*,,flute length past the corner >= feature depth + through overcut,must,Same as a wall *Hole,,drill,,diameter <= hole diameter + drill oversize,must,The API's largest drill diameter is this with the engine's 0.001 in *Hole,,drill,,diameter >= hole diameter - drill undersize,must,A smaller drill is a pilot not the hole -*Hole,,*end mill,,diameter <= largest end mill diameter,must,10/11 of the hole: room to helix — the drill takes the whole bore, so this limit is the end mill's alone +*Hole,,*end mill,,diameter <= largest end mill diameter,should,10/11 of the hole: the engine's helix room — a tenth of the bore for the mill to ramp down in. A caution rather than a refusal (Paul 2026-09-11) — a ⌀0.125 in mill in a ⌀0.136 in hole has 0.006 in a side and a shop plunges or bores it. The bore itself is the refusal and row 2 already holds it BlindHole,pointed bottom,drill,,tip angle <= tip angle + drill angle tolerance shallower,should,A 140° drill in a 118° hole leaves a shallower cone BlindHole,pointed bottom,drill,,tip angle >= tip angle - drill angle tolerance sharper,should,A sharper drill leaves a step at the bottom BlindHole,flat bottom,drill,,tip angle = 180,must,A counterbore stays flat: no pointed drill ever diff --git a/apps/catalog/app/shared/tool-fit.test.ts b/apps/catalog/app/shared/tool-fit.test.ts index 8886805..89117fb 100644 --- a/apps/catalog/app/shared/tool-fit.test.ts +++ b/apps/catalog/app/shared/tool-fit.test.ts @@ -264,10 +264,11 @@ describe('how many questions a group actually asks', () => { * fits, so the closest misses stand in, and they were drawn without the ranges * at all: a bound somebody typed was a bound the fill ignored. * - * The bounds the geometry wrote are still forgiven, and have to be — they are - * the same `must` rows that removed these tools, so obeying them would leave - * the fill empty in exactly the case it exists for. `ownBounds` in - * `shared/filter.ts` is what tells the two apart. + * The one bound a near miss may be outside is **the bound it missed on**, and + * only while that bound still reads what the geometry asked for. Forgiving + * every suggested bound instead put ⌀0.750 in cutters on a group capped at + * ⌀0.286 in (Paul, 2026-09-11): those tools were removed for reach, so the + * diameter question was never put to them at all. `nearEnough` is the rule. */ describe('the misses that may stand in when nothing fits', () => { const miss = (guid: string, brand: string, geometry: Readonly>): Verdict => @@ -293,21 +294,67 @@ describe('the misses that may stand in when nothing fits', () => { }) it('obeys a bound somebody set themselves', () => { - const kept = closeCandidates(excluded, asking({ NOF: { max: 3 } }), { NOF: { max: 3 } }) + const kept = closeCandidates(excluded, asking({ NOF: { max: 3 } }), {}) expect(kept.map((each) => each.tool.guid)).toEqual(['THREE']) }) - /** The bound the rules wrote is what "close" is measured against, not a filter. */ - it('forgives the bound the geometry asked for', () => { - const kept = closeCandidates(excluded, asking({ LCF: { min: 50 } }), {}) + /** + * The bound the rules wrote on the column these tools missed on is what + * "close" is measured against, not a filter: every one of them is a flute + * length short, which is the whole of what the list is standing in to say. + */ + it("forgives the geometry's bound on the column the tool missed on", () => { + const kept = closeCandidates(excluded, asking({ LCF: { min: 50 } }), { LCF: { min: 50 } }) expect(kept.map((each) => each.tool.guid)).toEqual(['THREE', 'FOUR']) }) + /** + * **And obeys it everywhere else** (Paul, 2026-09-11). A tool removed before + * anything asked about its flutes is not close to a question it was never + * put. + */ + it("obeys the geometry's bound on a column the tool did not miss on", () => { + const kept = closeCandidates(excluded, asking({ NOF: { max: 3 } }), { NOF: { max: 3 } }) + + expect(kept.map((each) => each.tool.guid)).toEqual(['THREE']) + }) + + /** + * A widened bound is somebody's own answer, on the missed column like any + * other — the override beside it is what says how far the rules bend, and the + * number is the last word on what is listed. + */ + it('obeys a bound widened past what the geometry asked for', () => { + const kept = closeCandidates(excluded, asking({ LCF: { min: 20 } }), { LCF: { min: 50 } }) + + expect(kept.map((each) => each.tool.guid)).toEqual([]) + }) + it('still leaves out what the discrete filters do not admit', () => { const kept = closeCandidates(excluded, asking({}), {}) expect(kept.map((each) => each.tool.guid)).not.toContain('OTHER') }) + + /** + * **The reach rules are no column's question at all** (Paul, 2026-09-11), so + * nothing about a tool they removed is forgiven: it is as far outside the + * feature's diameter as it ever was, and `furthest below holder` is + * deliberately on no column — `suggest-filters.ts` § `CODES` says why. + */ + it('forgives nothing for a tool removed by a rule no column asks', () => { + const reach = [ + miss('SMALL', 'Kennametal', { NOF: 3, LCF: 10, DC: 6 }), + miss('WIDE', 'Kennametal', { NOF: 3, LCF: 10, DC: 19 }), + ].map((verdict) => ({ + ...verdict, + removed: [{ rule: bound('furthest below holder'), text: 'too short', shortfall: 1 }], + })) as unknown as Array + + const kept = closeCandidates(reach, asking({ DC: { max: 7.264 } }), { DC: { max: 7.264 } }) + + expect(kept.map((each) => each.tool.guid)).toEqual(['SMALL']) + }) }) diff --git a/apps/catalog/app/shared/tool-fit.ts b/apps/catalog/app/shared/tool-fit.ts index 31d4760..73f3ca8 100644 --- a/apps/catalog/app/shared/tool-fit.ts +++ b/apps/catalog/app/shared/tool-fit.ts @@ -4,7 +4,7 @@ import { allTools } from './catalog.js' import { foldOnto, judgeTools, orderVerdicts, type Format, type Verdict } from './judge' import type { Knob } from './rules' import { sheetOf } from './feature-defaults' -import { filterTools, type ToolQuery } from './filter' +import { filterTools, sameBound, withinRanges, type ToolQuery } from './filter' import { holdableTools, splitHolding } from './holding' import { columnOfRule } from './tool-marks' @@ -133,30 +133,79 @@ export const fittingTools = ( } /** - * The removed tools still inside the person's discrete choices — brand, type, - * shank, the crib — so the list's fill never shows a tool they filtered out. + * Whether a removed tool is near enough to stand in, **under the bounds on + * screen**. + * + * A near miss is a tool a little outside the bound it *missed on*, and nothing + * more. Every other bound in the filter rail holds, whoever wrote it: + * + * - **A bound somebody typed is absolute** (Paul, 2026-09-10: at most three + * flutes, then Kennametal, and four-flute tools on the list — "they should + * not be … we should see 'no tools meet these filters'"). It is the question, + * not a tolerance on one, so it is never set aside — not even on the column + * the tool missed on, which is what a widened bound and its override already + * say in full. + * - **The geometry's own bound holds on every column but the one that removed + * the tool** (Paul, 2026-09-11: a group capped at ⌀0.286 in listing ⌀0.438 in + * to ⌀0.750 in cutters). Those tools were removed for reach, so nobody ever + * asked them the diameter question — `fittingTools` stops judging a tool at + * the first feature that removes it — and they came back as "the closest" + * under a Diameter filter no part of them was near. A cutter twice the width + * of the pocket is not close to cutting it. + * + * Which leaves the case the stand-in exists for intact: nothing reaches the + * bottom of a 50.8 mm face, so every tool misses on flute length, and the + * geometry's own flute-length bound is the one bound each of them may be + * outside. The list still answers "nothing fits, and here is the nearest". * - * The **geometry's** bounds are left aside: they are the rules' own numbers - * written into a control, and "close" is exactly a tool a little outside them. - * A bound somebody typed is not one of those, and is obeyed — `ownBounds` in - * `shared/filter.ts` is the rule and says what it cost not to have it. + * `suggested` is what the geometry asked for. A bound that no longer reads + * exactly what it suggested is somebody's own answer, which is the same rule + * `overrideOffered` in `components/column-filter.tsx` draws the dialog by. + */ +export const nearEnough = ( + verdict: Verdict, + ranges: ToolQuery['ranges'], + suggested: ToolQuery['ranges'], +): boolean => { + const missed = new Set( + verdict.removed + .map((reason) => columnOfRule(reason.rule)) + .filter((code): code is string => code !== null), + ) + const held: Record = {} + for (const [code, bound] of Object.entries(ranges)) { + if (missed.has(code) && sameBound(bound, suggested[code])) { + continue + } + held[code] = bound + } + return withinRanges(verdict.tool, held) +} + +/** + * The removed tools still inside the person's discrete choices — brand, type, + * shank, the crib — so the list's fill never shows a tool they filtered out, + * and inside every bound {@link nearEnough} says holds. */ export const closeCandidates = ( excluded: ReadonlyArray, query: ToolQuery, - own: ToolQuery['ranges'], + suggested: ToolQuery['ranges'], ): Array => { const { tools: toolQuery, holding } = splitHolding(query) + const inside = excluded.filter((verdict) => nearEnough(verdict, toolQuery.ranges, suggested)) const kept = new Set( holdableTools( filterTools( - excluded.map((verdict) => verdict.tool), - { ...toolQuery, ranges: own }, + inside.map((verdict) => verdict.tool), + // The bounds are {@link nearEnough}'s, one verdict at a time; this pass + // is the discrete half of the same query. + { ...toolQuery, ranges: {} }, ), holding, ).map((each) => each.guid), ) - return excluded.filter((verdict) => kept.has(verdict.tool.guid)) + return inside.filter((verdict) => kept.has(verdict.tool.guid)) } /** diff --git a/apps/catalog/app/shared/tool-marks.test.ts b/apps/catalog/app/shared/tool-marks.test.ts index 14f8d64..332b150 100644 --- a/apps/catalog/app/shared/tool-marks.test.ts +++ b/apps/catalog/app/shared/tool-marks.test.ts @@ -331,6 +331,73 @@ describe('a drill against the hole it is for', () => { }) }) +/** + * **An end mill close to the size of the hole is a caution, not a refusal** + * (Paul, 2026-09-11: a ⌀0.125 in mill under a ⌀0.136 in hole went red — "it + * should just allow end mills up to 10 % smaller than the hole diameter and + * warn with the i instead of go red"). + * + * The engine's helix room is a tenth of the bore, which is what `largest end + * mill diameter` states, and a mill inside it can still plunge or bore the + * hole out. So the sheet cautions on that limit and refuses on the bore — and + * the caution says the room that is left rather than leaving somebody to + * subtract two diameters. + */ +describe('an end mill in the hole it has to helix down', () => { + /** A ⌀12 hole, and the engine's own ramp limit at ten elevenths of it. */ + const hole: PartFeature = { + featureTag: 'hole-2', + featureType: 'ThroughHole', + machiningDirection: { x: 0, y: 0, z: 1 }, + regionIdxs: [3], + datasheet: { + zMin: -20, + zMax: 0, + extendedZMax: 0, + facts: { kind: 'Hole', diameter: 12, maxEndmillDiameter: 10.909 }, + }, + } as unknown as PartFeature + + const milling = (DC: number) => tool(`M${String(DC)}`, { DC, LCF: 40, LD: 4, RE: 0 }) + const markOn = (DC: number) => { + const verdict = judgeTools([milling(DC)], hole, [hole])[0]! + return { + removed: verdict.removed.length > 0, + mark: marksFor(verdict, testedCodes(hole, [hole]), { + format: (value) => `${value.toFixed(2)} mm`, + holeDiameter: 12, + }).DC, + } + } + + it('cautions a mill inside the helix room, and says the room it leaves', () => { + const { removed, mark } = markOn(11) + + expect(removed).toBe(false) + expect(mark).toEqual({ + ok: false, + level: 'should', + why: '0.50 mm a side to helix in', + detail: expect.any(String), + }) + }) + + it('leaves a mill with the room ticked', () => { + const { removed, mark } = markOn(10) + + expect(removed).toBe(false) + expect(mark).toEqual({ ok: true }) + }) + + /** The bore is the refusal: a mill wider than the hole cannot enter it. */ + it('still refuses a mill wider than the bore', () => { + const { removed, mark } = markOn(12.5) + + expect(removed).toBe(true) + expect(mark?.ok === false ? mark.level : 'ok').toBe('must') + }) +}) + describe('a filleted floor', () => { /** A pocket the model draws with a 1 mm radius in the floor. */ const filleted: PartFeature = { diff --git a/apps/catalog/app/shared/tool-marks.ts b/apps/catalog/app/shared/tool-marks.ts index 0822be6..02f75fc 100644 --- a/apps/catalog/app/shared/tool-marks.ts +++ b/apps/catalog/app/shared/tool-marks.ts @@ -188,12 +188,30 @@ const cautionSays = ( readonly leaves: number | null readonly point: number | null readonly bottom: number | null + readonly bore: number | null + readonly across: number | null }, format: Format, ): string | null => { if (rule?.test.kind !== 'bound') { return null } + /** + * **An end mill close to the size of the hole is a caution, not a refusal** + * (Paul, 2026-09-11: a ⌀0.125 in mill under a ⌀0.136 in hole "should just + * allow end mills up to 10 % smaller than the hole diameter and warn with + * the i instead of go red"). + * + * The engine's helix room is a tenth of the bore — the ramp a mill needs to + * take itself down — and a mill inside that can still plunge or bore the + * hole out, so the sheet cautions on it and the bore itself refuses. What is + * worth saying is the room that is actually left: a tenth of a ⌀0.136 in + * hole is 0.006 in a side and nobody reads that off two diameters. + */ + if (rule.test.field === 'diameter' && said.bore !== null && said.across !== null) { + const room = (said.bore - said.across) / 2 + return room <= 0 ? null : `${format(room, 'mm')} a side to helix in` + } if (rule.test.field === 'corner radius' && said.leaves !== null && said.leaves > 0) { return `leaves ${format(said.leaves, 'mm')} floor radius` } @@ -400,7 +418,13 @@ export const marksFor = ( const code = field === null ? undefined : CODES[field] const says = cautionSays( reason.rule, - { leaves: leaves ?? null, point: verdict.tool.geometry.SIG ?? null, bottom: tipAngle }, + { + leaves: leaves ?? null, + point: verdict.tool.geometry.SIG ?? null, + bottom: tipAngle, + bore: holeDiameter, + across: verdict.tool.geometry.DC ?? null, + }, format, ) if (code === undefined || says === null) { diff --git a/apps/catalog/tests/on-the-part.spec.ts b/apps/catalog/tests/on-the-part.spec.ts index 65fbf11..7a2615d 100644 --- a/apps/catalog/tests/on-the-part.spec.ts +++ b/apps/catalog/tests/on-the-part.spec.ts @@ -1906,6 +1906,150 @@ test.describe('at a laptop width', () => { }) }) +/** + * **The edge of the column is a handle** (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"). + * + * What a width may be is `shared/column-width.test.ts` and what the handle + * announces is `components/column-resizer.test.tsx`; both are cheap. What only + * this file can reach is the drag itself — a pointer capture over the canvas, + * against a viewer measured at the press — and the thing that drag must not do, + * which is start an orbit of the part underneath it. + */ +test.describe('widening the column over the part', () => { + const edge = (page: Page) => page.getByRole('separator', { name: 'Drag to widen the column' }) + + /** The column, measured through the overlay it is the only child of. */ + const wide = async (page: Page) => { + const box = await page.locator('[data-questions]').boundingBox() + if (box === null) { + throw new Error('the column over the part is on screen') + } + return box.width + } + + /** + * The canvas, waited for the way {@link at} waits for it and then some: the + * viewer is mounted, unmounted and mounted again while the mesh is on its way, + * so a single reading catches a canvas that is visible and has no box yet. + * + * **And a laid-out one, not the element's default.** A `` nothing has + * sized yet is 300×150 — a real box, so waiting for "not null" happily returns + * it, and a ceiling measured as a share of 300 is 210 (2026-09-11, under a + * loaded runner). The viewer is most of a 1680-wide window, so anything under + * half of it is the default rather than the part. + */ + const partBox = async (page: Page) => { + const canvas = page.locator('canvas') + await expect(canvas).toBeVisible() + let box = await canvas.boundingBox() + await expect(async () => { + box = await canvas.boundingBox() + expect(box?.width ?? 0).toBeGreaterThan(600) + }).toPass({ timeout: 10_000 }) + return box! + } + + const dragBy = async (page: Page, by: number) => { + const handle = await edge(page).boundingBox() + if (handle === null) { + throw new Error('the column has an edge to drag') + } + const y = handle.y + handle.height / 2 + await page.mouse.move(handle.x + handle.width / 2, y) + await page.mouse.down() + await page.mouse.move(handle.x + handle.width / 2 + by, y, { steps: 8 }) + await page.mouse.up() + await drawn(page) + } + + test('follows the pointer, and holds the width it was let go at', async ({ page }) => { + const before = await wide(page) + + await dragBy(page, 160) + + const after = await wide(page) + // The edge goes where the pointer went, within the pixel the handle is + // grabbed off-centre by. + expect(after).toBeGreaterThan(before + 150) + expect(after).toBeLessThan(before + 170) + + // And it is the column's width now, not the drag's: the box a click opens + // is the same width, rather than reverting to what a reading opens at. + await ready(page) + await expect(page.locator('[data-assembly-tree]')).toBeVisible() + expect(await wide(page)).toBeGreaterThan(before + 150) + }) + + /** Double-click is the way back, so a drag is never a one-way door. */ + test('gives the default width back on a double-click', async ({ page }) => { + const before = await wide(page) + await dragBy(page, 200) + expect(await wide(page)).toBeGreaterThan(before + 150) + + await edge(page).dblclick() + await drawn(page) + + expect(await wide(page)).toBe(before) + }) + + /** + * The ceiling — `WIDEST_SHARE`. Past it the part is a sliver behind a panel, + * and the fold button is what a long list is for. + */ + test('stops short of taking the viewer', async ({ page }) => { + const canvas = await partBox(page) + + await dragBy(page, canvas.width) + + expect(await wide(page)).toBeLessThanOrEqual(canvas.width * 0.7 + 1) + }) + + /** + * **The edge takes the press, and the canvas keeps everything beside it.** + * + * This is what stops a drag on the edge from orbiting the part underneath it: + * the handle is what the pointer *finds* there, so the press never reaches the + * canvas and the controls never see it. It is also the failure this cannot + * afford in the other direction — a handle wide enough to be comfortable is + * the curtain over the geometry that "at a laptop width" above exists for, so + * both points are measured: the edge, and a dozen pixels off it. + */ + test('takes the press at the edge and nowhere else', async ({ page }) => { + await partBox(page) + const handle = await edge(page).boundingBox() + if (handle === null) { + throw new Error('the column has an edge to grab') + } + + /* The handle's own hairline is what a point in the middle of it lands on, + so what is asked is which control the point is *in*, not what it is. */ + const finds = async (x: number) => + await page.evaluate( + (at) => { + const found = document.elementFromPoint(at.x, at.y) + if (found === null) { + return 'none' + } + return found.closest('[role="separator"]') === null + ? found.tagName.toLowerCase() + : 'separator' + }, + { x, y: handle.y + handle.height / 2 }, + ) + + expect(await finds(handle.x + handle.width / 2)).toBe('separator') + /* And it gives the pointer straight back: what is a dozen pixels off the + edge is whatever was there before — the part, or a box of its own — and + never the handle. Asked as "not the handle" rather than by naming the + canvas, because what is under that point depends on how far down the + column the drag was grabbed. */ + expect(await finds(handle.x + handle.width + 12)).not.toBe('separator') + expect(handle.width).toBeLessThanOrEqual(8) + }) +}) + /** * The tool assembly tree — the shape this page has (Paul, 2026-09-07), behind * a flag until 2026-09-08 and the only shape since. @@ -2561,6 +2705,32 @@ test.describe('the tool assembly tree', () => { expect(await count(/^Collets/)).toBe(String(await collets.getByRole('row').count())) }) + /** + * **A holder that grips the shank has no collet to choose** (Paul, + * 2026-09-11: "when a shrink fit holder is selected, the collets page should + * say 'no collet required for shrink fit holder'"). The list under one is + * empty by mechanics rather than by anything the crib is missing, and the + * page used to fall through to its answer about choices — "Nothing fits + * alongside BT30SF0600M. Clear one of them to widen the list" — which reads + * as a dead end and asks for a correct choice to be undone. + */ + test('says a shrink fit holder needs no collet at all', async ({ page }) => { + await ready(page) + const tree = await keepFeature(page) + + await tree.getByRole('button', { name: /^HOLDER for / }).click() + await showNoCollet(page) + const holders = page.locator('[data-component-table="holder"]').getByRole('grid') + // The fixture's one shrink fit, by its catalog number: the Type column can + // be turned off, and the number is the column every list opens with. + const shrink = holders.getByRole('row').filter({ hasText: 'BT30SF0600M' }) + await expect(shrink).toHaveCount(1) + await shrink.evaluate((element) => element.click()) + + await tree.getByRole('button', { name: /^COLLET for / }).click() + await expect(page.getByText('No collet required for a shrink fit holder.')).toBeVisible() + }) + /** * **Before the row exists, not after it.** The tools for a draft were already * listed at the bottom of the page while the column beside them stayed empty @@ -3935,10 +4105,10 @@ test.describe('Enter with a column filter open', () => { * The vendor was not the cause and could not have been: narrowing to it simply * emptied a list that had been answering, and an empty list is filled with the * closest misses to the rules — which were drawn without the ranges at all, so - * a bound somebody typed was a bound the fill ignored. `ownBounds` in - * `shared/filter.ts` is the rule that tells that bound from the one the - * geometry wrote, and this is the only place both halves can be seen at once: - * the fill still stands in, and it stands in with tools the filter admits. + * a bound somebody typed was a bound the fill ignored. `nearEnough` in + * `shared/tool-fit.ts` is the rule that says which bound a near miss may be + * outside, and this is the only place both halves can be seen at once: the fill + * still stands in, and it stands in with tools the filter admits. * * The cube's nine tools are what make the second half sharp. Its end mills have * four and five flutes and its drills state none, so at most three admits diff --git a/apps/catalog/tsconfig.tsbuildinfo b/apps/catalog/tsconfig.tsbuildinfo index 55d45a6..c667dc6 100644 --- a/apps/catalog/tsconfig.tsbuildinfo +++ b/apps/catalog/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./app/catalog-dataset.d.ts","./app/catalog-profiles.d.ts","./app/entry.client.tsx","./app/kit-usage.test.ts","./app/root.test.tsx","./app/root.tsx","./app/routes.ts","./app/type-scale.test.ts","./app/client/catalog-matcher.test.ts","./app/client/catalog-matcher.ts","./app/client/catalog-matcher.worker.ts","./app/client/use-part-upload.test.tsx","./app/client/use-part-upload.ts","./app/components/add-bar.test.tsx","./app/components/add-bar.tsx","./app/components/app-header.test.tsx","./app/components/app-header.tsx","./app/components/assembly-panel.tsx","./app/components/assembly-tree-panel.test.tsx","./app/components/assembly-tree-panel.tsx","./app/components/catalog-combobox-button.tsx","./app/components/catalog-drawing.test.tsx","./app/components/catalog-drawing.tsx","./app/components/chip.tsx","./app/components/clamping-length.test.tsx","./app/components/clamping-length.tsx","./app/components/column-filter.test.tsx","./app/components/column-filter.tsx","./app/components/column-heading.test.tsx","./app/components/column-heading.tsx","./app/components/component-table.test.tsx","./app/components/component-table.tsx","./app/components/component-tally.test.tsx","./app/components/component-tally.tsx","./app/components/drill-deviation.tsx","./app/components/feature-details.test.tsx","./app/components/feature-details.tsx","./app/components/feature-icons.tsx","./app/components/feature-list-panel.test.tsx","./app/components/feature-list-panel.tsx","./app/components/filter-axes.test.ts","./app/components/filter-panel.test.tsx","./app/components/filter-panel.tsx","./app/components/filter-rail.tsx","./app/components/floor-allowance.tsx","./app/components/frame-inset.tsx","./app/components/fusion-export-dialog.test.tsx","./app/components/fusion-export-dialog.tsx","./app/components/group-editor.test.tsx","./app/components/group-editor.tsx","./app/components/length-box.test.tsx","./app/components/length-box.tsx","./app/components/name-field.tsx","./app/components/no-collet-toggle.test.tsx","./app/components/no-collet-toggle.tsx","./app/components/order-dialog.tsx","./app/components/part-tool-table.test.tsx","./app/components/part-tool-table.tsx","./app/components/part-upload-overlay.test.tsx","./app/components/part-upload-overlay.tsx","./app/components/part-viewer.test.tsx","./app/components/part-viewer.tsx","./app/components/predrill-choice.test.tsx","./app/components/predrill-choice.tsx","./app/components/router-link.tsx","./app/components/selection-panel.test.tsx","./app/components/selection-panel.tsx","./app/components/thread-picker.test.tsx","./app/components/thread-picker.tsx","./app/components/tool-columns.test.ts","./app/components/tool-details.test.tsx","./app/components/tool-details.tsx","./app/components/tool-icons.test.tsx","./app/components/tool-icons.tsx","./app/components/toolpath-logo.tsx","./app/routes/order-list.tsx","./app/routes/part.tsx","./app/routes/parts.tsx","./app/shared/arrow-target.test.ts","./app/shared/arrow-target.ts","./app/shared/assemblies.test.ts","./app/shared/assemblies.ts","./app/shared/assembly-actions.test.ts","./app/shared/assembly-actions.ts","./app/shared/assembly-narrowing.test.ts","./app/shared/assembly-narrowing.ts","./app/shared/assembly-tree.test.ts","./app/shared/assembly-tree.ts","./app/shared/auto-columns.test.ts","./app/shared/auto-columns.ts","./app/shared/catalog-matcher.test.ts","./app/shared/catalog-matcher.ts","./app/shared/catalog.test.ts","./app/shared/catalog.ts","./app/shared/clamping-length.test.ts","./app/shared/clamping-length.ts","./app/shared/column-filters.test.ts","./app/shared/column-filters.ts","./app/shared/column-order.test.ts","./app/shared/column-order.ts","./app/shared/component-columns.test.ts","./app/shared/component-columns.ts","./app/shared/component-query.test.ts","./app/shared/component-query.ts","./app/shared/component-usage.test.ts","./app/shared/component-usage.ts","./app/shared/describe.ts","./app/shared/drawable-forms.test.ts","./app/shared/drawn-assembly.test.ts","./app/shared/drawn-assembly.ts","./app/shared/feature-defaults.test.ts","./app/shared/feature-defaults.ts","./app/shared/feature-list.test.ts","./app/shared/feature-list.ts","./app/shared/feature-rows.test.ts","./app/shared/feature-rows.ts","./app/shared/filter.test.ts","./app/shared/filter.ts","./app/shared/frame-inset.test.ts","./app/shared/frame-inset.ts","./app/shared/fusion-library.test.ts","./app/shared/fusion-library.ts","./app/shared/geometry.test.ts","./app/shared/geometry.ts","./app/shared/group-geometry.test.ts","./app/shared/group-geometry.ts","./app/shared/group-offer.test.ts","./app/shared/group-offer.ts","./app/shared/holder-choice.test.ts","./app/shared/holder-choice.ts","./app/shared/holder-drawable.test.ts","./app/shared/holding.test.ts","./app/shared/holding.ts","./app/shared/hole-mode.test.ts","./app/shared/hole-mode.ts","./app/shared/judge.test.ts","./app/shared/judge.ts","./app/shared/matching.test.ts","./app/shared/order-list.test.ts","./app/shared/order-list.ts","./app/shared/part-chrome.test.ts","./app/shared/part-chrome.ts","./app/shared/part-interaction.test.ts","./app/shared/part-interaction.ts","./app/shared/part-selection.test.ts","./app/shared/part-selection.ts","./app/shared/part-session.test.ts","./app/shared/part-session.ts","./app/shared/pretool-presets.ts","./app/shared/reading-colors.ts","./app/shared/recommendations.test.ts","./app/shared/recommendations.ts","./app/shared/rules.test.ts","./app/shared/rules.ts","./app/shared/save-file.test.ts","./app/shared/save-file.ts","./app/shared/section-of.test.ts","./app/shared/section-of.ts","./app/shared/setup-sheet.test.ts","./app/shared/setup-sheet.ts","./app/shared/stickout-floor.test.ts","./app/shared/stickout-floor.ts","./app/shared/suggest-filters.test.ts","./app/shared/suggest-filters.ts","./app/shared/thread-panes.test.ts","./app/shared/thread-panes.ts","./app/shared/threads.test.ts","./app/shared/threads.ts","./app/shared/tool-actions.test.ts","./app/shared/tool-actions.ts","./app/shared/tool-drawing-input.test.ts","./app/shared/tool-drawing-input.ts","./app/shared/tool-fit.test.ts","./app/shared/tool-fit.ts","./app/shared/tool-marks.test.ts","./app/shared/tool-marks.ts","./app/shared/tool-order.test.ts","./app/shared/tool-order.ts","./app/shared/tool-type.test.ts","./app/shared/tool-type.ts","./app/shared/type.ts","./app/shared/use-escape.test.tsx","./app/shared/use-escape.ts","./app/shared/use-preferences.ts","./app/shared/use-theme.test.tsx","./app/shared/use-theme.ts","./app/shared/use-unit.test.ts","./app/shared/use-unit.ts","./server/index.ts","./server/prod.ts","./vite.config.ts","./vitest.config.ts","./playwright.config.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./app/catalog-dataset.d.ts","./app/catalog-profiles.d.ts","./app/entry.client.tsx","./app/kit-usage.test.ts","./app/root.test.tsx","./app/root.tsx","./app/routes.ts","./app/type-scale.test.ts","./app/client/catalog-matcher.test.ts","./app/client/catalog-matcher.ts","./app/client/catalog-matcher.worker.ts","./app/client/use-part-upload.test.tsx","./app/client/use-part-upload.ts","./app/components/add-bar.test.tsx","./app/components/add-bar.tsx","./app/components/app-header.test.tsx","./app/components/app-header.tsx","./app/components/assembly-panel.test.tsx","./app/components/assembly-panel.tsx","./app/components/assembly-tree-panel.test.tsx","./app/components/assembly-tree-panel.tsx","./app/components/catalog-combobox-button.tsx","./app/components/catalog-drawing.test.tsx","./app/components/catalog-drawing.tsx","./app/components/chip.tsx","./app/components/clamping-length.test.tsx","./app/components/clamping-length.tsx","./app/components/clearance-entry.tsx","./app/components/column-filter.test.tsx","./app/components/column-filter.tsx","./app/components/column-heading.test.tsx","./app/components/column-heading.tsx","./app/components/column-resizer.test.tsx","./app/components/column-resizer.tsx","./app/components/component-table.test.tsx","./app/components/component-table.tsx","./app/components/component-tally.test.tsx","./app/components/component-tally.tsx","./app/components/drill-deviation.tsx","./app/components/feature-details.test.tsx","./app/components/feature-details.tsx","./app/components/feature-icons.tsx","./app/components/feature-list-panel.test.tsx","./app/components/feature-list-panel.tsx","./app/components/filter-axes.test.ts","./app/components/filter-panel.test.tsx","./app/components/filter-panel.tsx","./app/components/filter-rail.tsx","./app/components/floor-allowance.tsx","./app/components/frame-inset.tsx","./app/components/fusion-export-dialog.test.tsx","./app/components/fusion-export-dialog.tsx","./app/components/group-editor.test.tsx","./app/components/group-editor.tsx","./app/components/length-box.test.tsx","./app/components/length-box.tsx","./app/components/name-field.tsx","./app/components/no-collet-toggle.test.tsx","./app/components/no-collet-toggle.tsx","./app/components/order-dialog.tsx","./app/components/part-tool-table.test.tsx","./app/components/part-tool-table.tsx","./app/components/part-upload-overlay.test.tsx","./app/components/part-upload-overlay.tsx","./app/components/part-viewer.test.tsx","./app/components/part-viewer.tsx","./app/components/predrill-choice.test.tsx","./app/components/predrill-choice.tsx","./app/components/router-link.tsx","./app/components/selection-panel.test.tsx","./app/components/selection-panel.tsx","./app/components/thread-picker.test.tsx","./app/components/thread-picker.tsx","./app/components/tool-columns.test.ts","./app/components/tool-details.test.tsx","./app/components/tool-details.tsx","./app/components/tool-icons.test.tsx","./app/components/tool-icons.tsx","./app/components/toolpath-logo.tsx","./app/routes/order-list.tsx","./app/routes/part.tsx","./app/routes/parts.tsx","./app/shared/arrow-target.test.ts","./app/shared/arrow-target.ts","./app/shared/assemblies.test.ts","./app/shared/assemblies.ts","./app/shared/assembly-actions.test.ts","./app/shared/assembly-actions.ts","./app/shared/assembly-gaps.ts","./app/shared/assembly-narrowing.test.ts","./app/shared/assembly-narrowing.ts","./app/shared/assembly-tree.test.ts","./app/shared/assembly-tree.ts","./app/shared/auto-columns.test.ts","./app/shared/auto-columns.ts","./app/shared/catalog-matcher.test.ts","./app/shared/catalog-matcher.ts","./app/shared/catalog.test.ts","./app/shared/catalog.ts","./app/shared/clamping-length.test.ts","./app/shared/clamping-length.ts","./app/shared/clearance-entry.test.ts","./app/shared/clearance-entry.ts","./app/shared/column-filters.test.ts","./app/shared/column-filters.ts","./app/shared/column-order.test.ts","./app/shared/column-order.ts","./app/shared/column-width.test.ts","./app/shared/column-width.ts","./app/shared/component-columns.test.ts","./app/shared/component-columns.ts","./app/shared/component-query.test.ts","./app/shared/component-query.ts","./app/shared/component-usage.test.ts","./app/shared/component-usage.ts","./app/shared/describe.ts","./app/shared/drawable-forms.test.ts","./app/shared/drawn-assembly.test.ts","./app/shared/drawn-assembly.ts","./app/shared/feature-defaults.test.ts","./app/shared/feature-defaults.ts","./app/shared/feature-list.test.ts","./app/shared/feature-list.ts","./app/shared/feature-rows.test.ts","./app/shared/feature-rows.ts","./app/shared/filter.test.ts","./app/shared/filter.ts","./app/shared/frame-inset.test.ts","./app/shared/frame-inset.ts","./app/shared/fusion-library.test.ts","./app/shared/fusion-library.ts","./app/shared/geometry.test.ts","./app/shared/geometry.ts","./app/shared/group-geometry.test.ts","./app/shared/group-geometry.ts","./app/shared/group-offer.test.ts","./app/shared/group-offer.ts","./app/shared/holder-choice.test.ts","./app/shared/holder-choice.ts","./app/shared/holder-drawable.test.ts","./app/shared/holding.test.ts","./app/shared/holding.ts","./app/shared/hole-mode.test.ts","./app/shared/hole-mode.ts","./app/shared/judge.test.ts","./app/shared/judge.ts","./app/shared/matching.test.ts","./app/shared/order-list.test.ts","./app/shared/order-list.ts","./app/shared/part-chrome.test.ts","./app/shared/part-chrome.ts","./app/shared/part-interaction.test.ts","./app/shared/part-interaction.ts","./app/shared/part-selection.test.ts","./app/shared/part-selection.ts","./app/shared/part-session.test.ts","./app/shared/part-session.ts","./app/shared/pretool-presets.ts","./app/shared/range-entry.test.ts","./app/shared/range-entry.ts","./app/shared/reading-colors.ts","./app/shared/recommendations.test.ts","./app/shared/recommendations.ts","./app/shared/rules.test.ts","./app/shared/rules.ts","./app/shared/save-file.test.ts","./app/shared/save-file.ts","./app/shared/section-of.test.ts","./app/shared/section-of.ts","./app/shared/setup-sheet.test.ts","./app/shared/setup-sheet.ts","./app/shared/stickout-floor.test.ts","./app/shared/stickout-floor.ts","./app/shared/suggest-filters.test.ts","./app/shared/suggest-filters.ts","./app/shared/thread-panes.test.ts","./app/shared/thread-panes.ts","./app/shared/threads.test.ts","./app/shared/threads.ts","./app/shared/tool-actions.test.ts","./app/shared/tool-actions.ts","./app/shared/tool-drawing-input.test.ts","./app/shared/tool-drawing-input.ts","./app/shared/tool-fit.test.ts","./app/shared/tool-fit.ts","./app/shared/tool-marks.test.ts","./app/shared/tool-marks.ts","./app/shared/tool-order.test.ts","./app/shared/tool-order.ts","./app/shared/tool-type.test.ts","./app/shared/tool-type.ts","./app/shared/type.ts","./app/shared/use-escape.test.tsx","./app/shared/use-escape.ts","./app/shared/use-preferences.ts","./app/shared/use-theme.test.tsx","./app/shared/use-theme.ts","./app/shared/use-unit.test.ts","./app/shared/use-unit.ts","./server/index.ts","./server/prod.ts","./vite.config.ts","./vitest.config.ts","./playwright.config.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/docs/CATALOG-SPEC.md b/docs/CATALOG-SPEC.md index 99cc8d9..bf7cd1a 100644 --- a/docs/CATALOG-SPEC.md +++ b/docs/CATALOG-SPEC.md @@ -459,12 +459,23 @@ the order the rules rank them, with a mark on every number the rules read. - **Nothing fits is never an empty table.** The closest misses are shown instead, each with the number that stopped it painted red — and never a tool - a filter turned down. What "close" is measured against is the bound the - _geometry_ wrote into a column; a bound somebody typed themselves is the - question rather than a tolerance on it, so at most three flutes is at most - three flutes and the table says so when nothing answers it (Paul, - 2026-09-10). `ownBounds` in `app/shared/filter.ts` is the rule that tells the - two apart. + a filter turned down. A near miss may be outside **the one bound it missed + on**, and no other: + - A bound somebody typed themselves is the question rather than a tolerance on + it, so at most three flutes is at most three flutes and the table says so + when nothing answers it (Paul, 2026-09-10). + - The bound the _geometry_ wrote holds on every column but the one that + removed the tool. A group capped at ⌀0.286 in listed ⌀0.438 in to ⌀0.750 in + cutters (Paul, 2026-09-11): they were removed for reach, so the diameter + question was never put to them — judging stops at the first feature that + rules a tool out — and they came back as "the closest" under a filter no + part of them was near. + - So a face nothing reaches still lists the deepest cutters in the crib: they + miss on flute length, which is the bound they are allowed to be outside. + + `nearEnough` in `app/shared/tool-fit.ts` is the rule, applied a tool at a time + in the worker before the fifty nearest are taken. + - **The first row is always highlighted** and the panel is already assembling that tool, so confirming takes it without a second click. - **Notes beside the heading** say what the rules removed, what the filters hid, diff --git a/docs/FEATURE-LIST.md b/docs/FEATURE-LIST.md index f97ac3a..a1607ca 100644 --- a/docs/FEATURE-LIST.md +++ b/docs/FEATURE-LIST.md @@ -737,8 +737,25 @@ differently: It was a row of two — the presses and rows in one column, the card in a second beside it — which is two columns of chrome over a part on any screen a laptop's width or under, and the card is the answer to a press three pixels - above it. The column is `w-80`, and `w-[26rem]` while the group editor is + above it. The column opens at 320px, and at 416 while the group editor is open, because the card is what asks for the width now that it is inside it. +- **Its right edge is a handle** (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 column carries all four of those, so + one drag widens all of them at once, and a width a shop has dragged to is the + width whatever the column is holding — the two numbers above are what a shop + that has never dragged it gets, not a rule about groups. Double-clicking the + edge forgets the stated width and puts them back; the arrow keys move it by + 16px, because a drag target only a mouse can reach is one half the room cannot + use. It is held between 256px and seven tenths of the viewer, measured at the + press — past two fifths the camera stops moving the part aside + (`frame-inset.ts` § `MOST_OF_IT`) and the rows sit over the geometry, which is + a fair thing to ask for while reading a long list and not a reason to let the + part be dragged off screen. `app/shared/column-width.ts` is every number, + `app/components/column-resizer.tsx` the handle, and + `tests/on-the-part.spec.ts` § "widening the column over the part" the drag + itself — including that the edge takes the press so it never orbits the part + underneath, and that a dozen pixels off it the canvas still does. - **An open editor folds the rows away**, and one button under it brings them back (Paul, 2026-09-10: "when a dialog is active, fold up the order list. Provide a button to be able to expand it underneath the open feature, group, @@ -992,6 +1009,8 @@ true of the work the worker does: | whether the rows are folded under the box | `rowsShown`, `app/shared/part-chrome.ts` | | whether the three presses are on screen | `pressesShown`, same file | | where the part is framed, beside the column | `app/shared/frame-inset.ts` | +| how wide that column is, and its two ends | `app/shared/column-width.ts` | +| the handle on its right edge | `app/components/column-resizer.tsx` | | how tall the tool list opens | `TABLE_OPENS_AT`, `components/part-tool-table.tsx` | | whether _+ Tool Assembly_ can be pressed | `assemblyPressEnabled`, same file | | the row a reading already has, if any | `rowFor`, `app/shared/feature-list.ts` | diff --git a/docs/RULES.md b/docs/RULES.md index 1ccd0c8..87db576 100644 --- a/docs/RULES.md +++ b/docs/RULES.md @@ -70,7 +70,9 @@ knob could never widen the band. It is now two rows: A pair like that also changes what the **filter panel** suggests. A suggested range may only say what is true of every form the feature considers, so it is worked out one form at a time and the loosest of them wins: on a hole that is -the drill's bore-plus-oversize, not the end mill's tighter helix limit. +the drill's bore-plus-oversize, not the end mill's cap at the bore. So the +Diameter filter on a ⌀0.136 in hole opens at 0.140 in — the widest tool of any +kind the hole admits, which is a drill four thousandths over. ## The three shapes of rule @@ -156,6 +158,15 @@ separately. A rule that caps every tool type wants the first, and only the end mill's own row wants the second. Reading the second as the first threw the right-sized drill out of every hole (2026-08-31). +The end mill's own row is a **`should`** since 2026-09-11, which is the one +place in the sheet where that distinction is the whole point. The helix room is +a tenth of the bore, and a mill inside it can still plunge or bore the hole out +— so a ⌀0.125 in mill under a ⌀0.136 in hole is listed with a caution saying +the room it leaves (`0.006 in a side to helix in`) rather than refused in red +(Paul: "it should just allow end mills up to 10 % smaller than the hole +diameter and warn with the i instead of go red"). The refusal is the bore +itself, which row 2 already holds. + **Tool fields** (numbers off the catalog): `diameter`, `flute length`, `flute length past the corner`, `length below holder`, `furthest below holder`, `overall length`, `L/D`, `corner radius`, `flutes`, `tip angle`, From f37da5924cd6fc011b7918208069a8423a54735d Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Fri, 11 Sep 2026 15:34:58 -0400 Subject: [PATCH 05/19] ui fixes --- apps/catalog/app/components/app-header.tsx | 2 +- .../app/components/filter-panel.test.tsx | 68 ++++++++ apps/catalog/app/components/filter-panel.tsx | 147 ++++++++++++------ apps/catalog/tests/on-the-part.spec.ts | 37 +++++ 4 files changed, 204 insertions(+), 50 deletions(-) diff --git a/apps/catalog/app/components/app-header.tsx b/apps/catalog/app/components/app-header.tsx index b66b579..61a61dd 100644 --- a/apps/catalog/app/components/app-header.tsx +++ b/apps/catalog/app/components/app-header.tsx @@ -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 diff --git a/apps/catalog/app/components/filter-panel.test.tsx b/apps/catalog/app/components/filter-panel.test.tsx index 0d013bf..022ebda 100644 --- a/apps/catalog/app/components/filter-panel.test.tsx +++ b/apps/catalog/app/components/filter-panel.test.tsx @@ -293,6 +293,74 @@ 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() + expect(menu).toHaveClass('fixed', 'z-50') + expect(document.querySelector('[data-filter-toolbar]')?.contains(menu ?? null)).toBe(false) + expect(within(menu as HTMLElement).getByRole('group', { name: 'Part material' })).toBeVisible() + }) + + /** + * 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..c9c6e67 100644 --- a/apps/catalog/app/components/filter-panel.tsx +++ b/apps/catalog/app/components/filter-panel.tsx @@ -1,5 +1,6 @@ import { Button, cn, Input } from '@toolpath/ui' import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' import { BookmarksSimpleIcon, CaretDownIcon, @@ -432,52 +433,84 @@ const ToolbarFilterBody = ({ }) => { const menu = useRef(null) const press = useRef(null) - const [menuOffset, setMenuOffset] = useState(0) /** - * Which way it opens, and the most it may be. + * Where the box stands, in the window's own coordinates. * - * **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. + * **It is drawn over the page, not inside the bar** (Paul, 2026-09-11: "the + * part material filters go behind the table! They need to go up front!"). + * These buttons float along the bottom of the viewer, and the viewer clips + * what it holds — so an `absolute` box opening downwards was cut off at the + * seam and what was left of it read as a menu hiding behind the tool list. + * Raising `z-index` cannot fix a clip. This is the same answer the column + * funnels reached (`column-filter.tsx` § `FilterMenu`): a portal, placed + * against the button it opened from and kept inside the window. + * + * `null` until it has been measured, and hidden while it is: the width is + * read off the box itself to line its right edge up with the button's. */ - const [room, setRoom] = useState<{ readonly upwards: boolean; readonly height: number }>({ - upwards: false, - height: 0, - }) + const [at, setAt] = useState<{ + readonly top: number | null + readonly bottom: number | null + readonly left: number + readonly height: number + } | null>(null) useLayoutEffect(() => { - if (!open || menu.current === null) { - setMenuOffset(0) + if (!open) { + setAt(null) return } const place = () => { - const element = menu.current - if (element === null) { + const anchor = press.current + if (anchor === 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)) - } + const button = anchor.getBoundingClientRect() + const width = menu.current?.getBoundingClientRect().width ?? 0 + /* + The chrome is the bar these buttons stand in, and the list under it: + a menu is kept inside the width of the thing it is narrowing rather + than only inside the window, so a filter at the right-hand end opens + over the table instead of off the side of it. + */ + const chrome = anchor.closest('[data-list-chrome]')?.getBoundingClientRect() + const edgeLeft = Math.max(chrome?.left ?? 0, 8) + 8 + const edgeRight = Math.min(chrome?.right ?? window.innerWidth, window.innerWidth) - 8 + const left = Math.max(edgeLeft, Math.min(button.right - width, edgeRight - width)) + /* + `menuRoom` is the rule the column funnels and the column picker follow — + take the room the screen leaves, and turn over where there is none. The + room below is the window's, not the viewer's: the box stands over the + table now rather than being cut off at its edge. + */ + 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, + }) } place() window.addEventListener('resize', place) - return () => window.removeEventListener('resize', place) + /* + Capturing, so a scroll under an open menu — the tool list, or the column + of questions beside the part — moves it with the button it belongs to + rather than leaving it behind over the rows. + */ + const onScroll = (event: Event) => { + if (event.target instanceof Node && menu.current?.contains(event.target) === true) { + return + } + place() + } + window.addEventListener('scroll', onScroll, true) + return () => { + window.removeEventListener('resize', place) + window.removeEventListener('scroll', onScroll, true) + } }, [open]) return ( @@ -501,22 +534,27 @@ const ToolbarFilterBody = ({ - {open ? ( -
- {children} -
- ) : null} + {open + ? createPortal( +
+ {children} +
, + document.body, + ) + : null}
) } @@ -626,9 +664,20 @@ const useCloseOnOutside = (open: boolean, close: () => void) => { return } const onDown = (event: PointerEvent) => { - if (!box.current?.contains(event.target as Node)) { - close() + const target = event.target instanceof Element ? event.target : null + if (box.current?.contains(target) === true) { + return + } + /* + **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/tests/on-the-part.spec.ts b/apps/catalog/tests/on-the-part.spec.ts index 7a2615d..bcb0201 100644 --- a/apps/catalog/tests/on-the-part.spec.ts +++ b/apps/catalog/tests/on-the-part.spec.ts @@ -442,6 +442,43 @@ test('Escape closes the filters and leaves the reading alone', async ({ page }) await expect(field(page)).toBeVisible() }) +/** + * **"The part material filters go behind the table! They need to go up + * front!"** (Paul, 2026-09-11). + * + * The button row floats along the bottom of the viewer, and the viewer clips + * what it holds — so the menu it opened downwards was cut off at the seam and + * what showed of it sat under the tool list. Nothing about that is visible to + * `toBeVisible`: a clipped box still has a box. What is asked here is what the + * eye asks — is the menu the thing on top where it is drawn — which is why the + * point is read back out of the document rather than measured. + */ +test('an open filter menu stands over the tool list, not under it', async ({ page }) => { + await page.getByRole('button', { name: 'Part material' }).click() + const menu = page.locator('[data-tool-filter-menu]') + await expect(menu).toBeVisible() + + const box = await menu.boundingBox() + expect(box).not.toBeNull() + const { x, y, width, height } = box ?? { x: 0, y: 0, width: 0, height: 0 } + + // The whole of it is inside the window, rather than running off the bottom. + const viewport = page.viewportSize() + expect(y).toBeGreaterThanOrEqual(0) + expect(y + height).toBeLessThanOrEqual((viewport?.height ?? 0) + 1) + + // And the lowest part of it — the part that overlaps the list — takes the + // pointer, which is the thing a clip and a z-index both take away. + const owned = await page.evaluate( + ([px, py]) => { + const at = document.elementFromPoint(px as number, py as number) + return at !== null && at.closest('[data-tool-filter-menu]') !== null + }, + [x + width / 2, y + height - 2], + ) + expect(owned).toBe(true) +}) + test('part material narrows the tool table', async ({ page }) => { const before = await page.locator('[data-row-index]').count() await page.getByRole('button', { name: 'Part material' }).click() From 8643004795e4ad3b057e644aa35832edcb89a1a3 Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Fri, 11 Sep 2026 15:43:06 -0400 Subject: [PATCH 06/19] Pin the cutting radius a vendor never published MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard #6 put on the drawing, as a sensor on the module that now owns it. `!== undefined` was true of both shapes a silent vendor leaves — a `DC` of `null` and a `DC` of `0` — so the wall was measured from `r = 0` and drawn through the tool. Reverting cuttingRadiusOf to `(DC ?? 0) / 2` fails both cases; nothing else in the suite notices. --- apps/catalog/app/shared/assembly-gaps.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 apps/catalog/app/shared/assembly-gaps.test.ts diff --git a/apps/catalog/app/shared/assembly-gaps.test.ts b/apps/catalog/app/shared/assembly-gaps.test.ts new file mode 100644 index 0000000..d908464 --- /dev/null +++ b/apps/catalog/app/shared/assembly-gaps.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import type { Margins } from '@toolpath/catalog-data' +import type { ReachCurve } from '@toolpath/part-contracts' +import type { ViewerAssembly } from '@toolpath/tool-drawing/geometry' +import { cuttingRadiusOf, gapsFor } from './assembly-gaps' + +const tool = (geometry: Record): { geometry: Record } => ({ + geometry: geometry as Record, +}) + +const MARGINS: Margins = { axial: 0.508, radial: 0.508 } + +/** + * **A tool with no flank is not a tool cutting on the centreline.** + * + * `(DC ?? 0) / 2` read a missing cutting diameter as a radius of nought, which + * put the material's inner face on the axis: the wall was measured, and drawn, + * through the tool it was supposed to stand clear of (Paul, 2026-09-11). It was + * fixed in `catalog-drawing.tsx` on main while this branch had already moved + * the expression here, so the merge resolved it in a file nothing reads any + * more. These are the two shapes the old guard let through — `!== undefined` + * is true of both — and they are pinned here rather than on the drawing because + * the three clearance boxes read this same radius. + */ +describe('cuttingRadiusOf', () => { + it('halves a stated cutting diameter', () => { + expect(cuttingRadiusOf(tool({ DC: 6 }))).toBe(3) + }) + + it('states no radius where the vendor published no diameter', () => { + // The runtime shape: the type says `number`, a vendor that published + // nothing carries `null`, and the catalog is built from the vendor. + expect(cuttingRadiusOf(tool({ DC: null }))).toBeNull() + expect(cuttingRadiusOf(tool({}))).toBeNull() + }) + + it('states no radius for a diameter of nought, which is the same silence', () => { + expect(cuttingRadiusOf(tool({ DC: 0 }))).toBeNull() + }) +}) + +describe('gapsFor', () => { + /** Neither is reached: the radius is refused before the outline is taken. */ + const curve = { points: [] } as unknown as ReachCurve + const viewer = {} as unknown as ViewerAssembly + + it('measures nothing without a cutting radius to measure past', () => { + expect(gapsFor(viewer, curve, null, MARGINS)).toBeNull() + }) + + it('measures nothing without a feature', () => { + expect(gapsFor(viewer, null, 3, MARGINS)).toBeNull() + }) +}) From 7d993ad2a30916170e9b4ead0212c83824b3949d Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Fri, 11 Sep 2026 15:59:27 -0400 Subject: [PATCH 07/19] stickout bug fix --- .../app/components/clearance-entry.test.tsx | 91 +++++++++++++++++++ .../app/components/clearance-entry.tsx | 35 ++++++- apps/catalog/app/components/tool-details.tsx | 29 +++++- .../app/shared/clearance-entry.test.ts | 47 +++++++++- apps/catalog/app/shared/clearance-entry.ts | 47 ++++++++++ 5 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 apps/catalog/app/components/clearance-entry.test.tsx diff --git a/apps/catalog/app/components/clearance-entry.test.tsx b/apps/catalog/app/components/clearance-entry.test.tsx new file mode 100644 index 0000000..e3fdc13 --- /dev/null +++ b/apps/catalog/app/components/clearance-entry.test.tsx @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import type { ClearanceBoxes } from 'shared/clearance-entry' +import { ClearanceEntry } from './clearance-entry' + +/** + * The one line the row has to say, and the defect it was written for. + * + * A stickout typed too short read **"under the 0.02 in wanted"** — the sheet's + * own figure, read back off a box nobody had touched, to somebody who had just + * been told the stack fouls the part (Paul, 2026-09-11). What the length box + * knows is the length that clears, so that is what it says. + */ +const boxesWith = ( + below: Partial, + 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 index 6cd100c..82c60be 100644 --- a/apps/catalog/app/components/clearance-entry.tsx +++ b/apps/catalog/app/components/clearance-entry.tsx @@ -47,6 +47,14 @@ 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 } @@ -108,6 +116,7 @@ interface Mark { const markForBelow = ( boxes: ClearanceBoxes, edit: ClearanceEdit | null, + clearsAt: number | null, say: (millimetres: number | null) => string, ): Mark => { const { below } = boxes @@ -121,6 +130,28 @@ const markForBelow = ( 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 } } @@ -163,7 +194,7 @@ const markFor = (box: ClearanceBox, say: (millimetres: number | null) => string) return { icon: RulerIcon, said: 'measured at this length', amiss: false } } -export const ClearanceEntry = ({ boxes, unit, edit, onEdit }: ClearanceEntryProps) => { +export const ClearanceEntry = ({ boxes, unit, edit, clearsAt, onEdit }: ClearanceEntryProps) => { /** * The box being typed in, and nothing else. * @@ -242,7 +273,7 @@ export const ClearanceEntry = ({ boxes, unit, edit, onEdit }: ClearanceEntryProp const [open, setOpen] = useState(false) const marks = { - below: markForBelow(boxes, edit, say), + below: markForBelow(boxes, edit, clearsAt, say), axial: markFor(boxes.axial, say), radial: markFor(boxes.radial, say), } diff --git a/apps/catalog/app/components/tool-details.tsx b/apps/catalog/app/components/tool-details.tsx index a328355..d85c03d 100644 --- a/apps/catalog/app/components/tool-details.tsx +++ b/apps/catalog/app/components/tool-details.tsx @@ -16,7 +16,13 @@ import { formatGeometry } from 'shared/geometry' import { getFamily } from 'shared/catalog' import { drawnAssembly } from 'shared/drawn-assembly' import { roomAt } from 'shared/assembly-gaps' -import { askFor, boxesFor, lengthFor, type ClearanceEdit } from 'shared/clearance-entry' +import { + askFor, + boxesFor, + clearingLength, + lengthFor, + type ClearanceEdit, +} from 'shared/clearance-entry' import { thresholdsFrom } from 'shared/holder-choice' import { ToolTypeIcon, formLabel } from './tool-icons' import { MeasurementIcon } from './feature-icons' @@ -340,6 +346,26 @@ export const ToolDetails = ({ room, ) + /** + * The length to type instead, where the one that was typed fouls the part. + * + * Worked out only when that has happened: it is two bisections of a few dozen + * segments each, and every other state of this panel has nothing to say with + * it. Both axes rather than the one being solved for — a stated length is + * being held to everything the sheet asks, which is exactly why it came up + * short. + */ + const collides = edit?.field === 'below' && (boxes.axial.short || boxes.radial.short) + const clearsAt = useMemo( + () => + collides && holderChosen !== undefined && curve !== null && bracket !== null + ? clearingLength(margins, bracket, (stickout) => + roomAt({ tool, holder: holderChosen }, stickout, curve, margins), + ) + : null, + [collides, tool, holderChosen, curve, margins, bracket], + ) + return ( /* **The panel is the sheet the tool is drawn on** (Paul, 2026-09-11). It @@ -550,6 +576,7 @@ export const ToolDetails = ({ boxes={boxes} unit={unit} edit={edit} + clearsAt={clearsAt} onEdit={(next) => setStated({ key: stackKey, edit: next })} /> ) : null} diff --git a/apps/catalog/app/shared/clearance-entry.test.ts b/apps/catalog/app/shared/clearance-entry.test.ts index 6b1c2ab..f7042d1 100644 --- a/apps/catalog/app/shared/clearance-entry.test.ts +++ b/apps/catalog/app/shared/clearance-entry.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest' import type { Margins } from '@toolpath/catalog-data' -import { askFor, boxesFor, lengthFor, shownIn, type RequiredAt } from './clearance-entry' +import { + askFor, + boxesFor, + clearingLength, + lengthFor, + shownIn, + type RequiredAt, +} from './clearance-entry' /** The sheet's own figures, 0.020 in both ways, which is what the page opens on. */ const SHEET: Margins = { axial: 0.508, radial: 0.508 } @@ -265,3 +272,41 @@ describe('boxesFor', () => { ).toBe(true) }) }) + +/** + * The number the line under the row names when a stated length collides. + * + * The defect it answers is a message rather than a calculation (Paul, + * 2026-09-11): a stickout typed too short read "under 0.02 in wanted" off a box + * nobody had touched, which restates the sheet instead of saying what to set + * the tool to. + */ +describe('clearingLength', () => { + const BRACKET = { min: 10, max: 60 } + /** Axial room rises one for one; radial trails it by two and flattens early. */ + const room = (stickout: number) => ({ + axial: stickout - 12, + radial: Math.min(stickout - 14, 6), + }) + + it('gives the longer of the two axes, because both are being asked', () => { + // Axial clears 0.508 at 12.508, radial only at 14.508. + expect(clearingLength(SHEET, BRACKET, room)).toBeCloseTo(14.508, 2) + }) + + /** A length that still fouls is not a length to tell anybody to set. */ + it('offers nothing where no length in the bracket clears', () => { + expect(clearingLength({ axial: 0.508, radial: 20 }, BRACKET, room)).toBeNull() + }) + + it('says nothing where there is nothing to measure', () => { + expect(clearingLength(SHEET, BRACKET, () => ({ axial: null, radial: null }))).toBeNull() + }) + + /** An axis nothing stands on is no bar: the other one decides alone. */ + it('reads past an axis with nothing on it', () => { + expect( + clearingLength(SHEET, BRACKET, (stickout) => ({ axial: stickout - 12, radial: null })), + ).toBeCloseTo(12.508, 2) + }) +}) diff --git a/apps/catalog/app/shared/clearance-entry.ts b/apps/catalog/app/shared/clearance-entry.ts index b950bb4..be52676 100644 --- a/apps/catalog/app/shared/clearance-entry.ts +++ b/apps/catalog/app/shared/clearance-entry.ts @@ -300,3 +300,50 @@ export const boxesFor = ( radial: boxFor('radial', edit, defaults, room), } } + +/** + * The shortest length below the holder that clears on **both** axes, or null + * where no length this tool can be set to does. + * + * A stated length that collides is the one case where the page knows the answer + * and used to say only that something was wrong with the question: the axial + * box read "under the 0.02 in wanted", which is a restatement of the sheet + * rather than the number to type (Paul, 2026-09-11). This is that number. + * + * Both axes, unlike {@link askFor} — a stated *clearance* is solved alone, + * because the box a shop just typed into has to drive; a stated *length* is + * being held to everything the sheet asks, so the length that answers it is the + * longer of the two, each axis rising with the length independently. + * + * The candidate is measured before it is offered. {@link lengthFor} answers the + * far end of the bracket where nothing in it reaches — a deliberate "as close + * as this stack gets" for the drawing, and a lie in a sentence telling somebody + * to set a tool there — so a length that still does not clear comes back as + * nothing to suggest at all. + */ +export const clearingLength = ( + asked: Margins, + bracket: { readonly min: number; readonly max: number }, + roomAt: (stickout: number) => MeasuredRoom, +): number | null => { + const axes = ['axial', 'radial'] as const + /* + An axis with nothing on it is no bar rather than a failure: `radial` is null + on every feature with no wall standing taller than the cut, and its box says + "nothing stands taller" instead of a number. Reading that as "no length + clears" would leave the ordinary hole with nothing to suggest. + */ + const lengths = axes + .map((field) => lengthFor(asked[field], bracket, (at) => roomAt(at)[field])) + .filter((length): length is number => length !== null) + if (lengths.length === 0) { + return null + } + const length = Math.max(...lengths) + const room = roomAt(length) + const clears = axes.every((field) => { + const value = room[field] + return value === null || value >= asked[field] - MET + }) + return clears ? length : null +} From 902bf8d998a3cd8b4bb061657e6e44cdb84cc890 Mon Sep 17 00:00:00 2001 From: Brad Estey Date: Fri, 11 Sep 2026 16:28:58 -0400 Subject: [PATCH 08/19] Fix table width issues. --- AGENTS.md | 117 +++++----- .../app/components/column-filter.test.tsx | 53 +++-- apps/catalog/app/components/column-filter.tsx | 209 ++++++------------ .../app/components/component-table.tsx | 12 +- apps/catalog/app/components/filter-panel.tsx | 177 ++++++++------- .../app/components/part-tool-table.test.tsx | 30 ++- .../app/components/part-tool-table.tsx | 39 +++- apps/catalog/app/shared/column-width.test.ts | 36 +++ apps/catalog/app/shared/column-width.ts | 50 +++++ apps/catalog/app/shared/menu-place.test.ts | 65 ++++++ apps/catalog/app/shared/menu-place.ts | 100 +++++++++ apps/catalog/app/shared/use-fitted-columns.ts | 59 +++++ apps/catalog/app/styles.css | 33 +-- apps/catalog/tests/on-the-part.spec.ts | 151 ++++++++++++- 14 files changed, 789 insertions(+), 342 deletions(-) create mode 100644 apps/catalog/app/shared/column-width.test.ts create mode 100644 apps/catalog/app/shared/column-width.ts create mode 100644 apps/catalog/app/shared/menu-place.test.ts create mode 100644 apps/catalog/app/shared/menu-place.ts create mode 100644 apps/catalog/app/shared/use-fitted-columns.ts diff --git a/AGENTS.md b/AGENTS.md index 7912a49..0022fde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -264,63 +264,66 @@ 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` | -| how far below the holder a stack has to stand | `belowHolder`, `app/shared/drawn-assembly.ts` | -| which slots were filled against the rules | `overrides`, `app/shared/assembly-tree.ts` | -| what a stack offers, and its button's words | `app/shared/assembly-actions.ts` | -| what a shop calls an assembly, and its field | `renameItem` / `renameAssembly`, `name-field.tsx` | -| reading and filtering a holder or a collet | `app/shared/component-columns.ts` | -| which column header asks which filter | `app/shared/column-filters.ts` | -| a tool in one phrase, with its shank or neck | `app/shared/tool-type.ts` | -| which ticks a filter the page set puts on Type | `typesAsking`, `app/shared/tool-type.ts` | -| which of its columns a tap list narrows on | `askOfTapColumn`, `app/shared/column-filters.ts` | -| what a tick on the tap list's Type column asks | `formsAskingTaps`, `app/shared/hole-mode.ts` | -| what is narrowing a list, named for its button | `narrowingNames`, `app/shared/column-filters.ts` | -| the numbers a thread and a depth put on a tap | `tapBounds`, `app/shared/hole-mode.ts` | -| the form behind a Type phrase, and what it asks | `app/shared/tool-type.ts` | -| the list, its answers and its right-click | `app/components/feature-list-panel.tsx` | -| building a group | `app/components/group-editor.tsx` | -| the reading, its numbers and its thread | `app/components/selection-panel.tsx` | -| the tool table and its marks | `app/components/part-tool-table.tsx` | +| Question | Module | +| ----------------------------------------------------- | ---------------------------------------------------- | +| what the list holds, its names, ids, storage | `app/shared/feature-list.ts` | +| which key a row's lines reach the bill under | `sheetKeysOf`, same file | +| which of a row's lines one stack of it wrote | `lineId`, `app/shared/setup-sheet.ts` | +| what is on the order list, for both pages | `app/shared/order-list.ts` | +| whether a row has anything ordered, and what to buy | `isIncomplete` / `componentTotals`, same file | +| which of four things the page is being asked | `asked()`, same file | +| the three presses over the part that add a row | `app/components/add-bar.tsx` | +| whether the presses and the rows are drawn at all | `app/shared/part-chrome.ts` | +| where the part is framed, beside the questions | `app/shared/frame-inset.ts` | +| the heading face and the small-capitals label | `app/shared/type.ts` | +| how tall the tool list opens | `TABLE_OPENS_AT`, `components/part-tool-table.tsx` | +| the columns a list opens with, and their order | `TOOL_COLUMNS`, `components/part-tool-table.tsx` | +| the two columns the list turns on for itself | `app/shared/auto-columns.ts` | +| how wide a column is, as a share of the panel | `app/shared/column-width.ts` | +| refitting the tracks a drag froze onto the table | `app/shared/use-fitted-columns.ts` | +| a row's answer, and what it opens to | `app/shared/recommendations.ts` | +| what a press on a row of the order list opens | `pressRow`, `app/routes/part.tsx` | +| whose stacks the tree beside an open box shows | `editedItem` / `treeKey`, same file | +| what the panel offers for the tool it shows | `app/shared/tool-actions.ts` | +| what fills the tool table, and the cache | `app/shared/catalog-matcher.ts` | +| a stored guid turned back into a record | `getTool`/`getHolder`/`getCollet`, `catalog.ts` | +| what overruling the rules offers, per column | `overridableTools`, `app/shared/tool-fit.ts` | +| the note and press a changed filter raises | `OverrideNotice`, `app/components/column-filter.tsx` | +| whether a value is inside a filter's bound | `withinRange`, `app/shared/filter.ts` | +| what a number box takes besides a number | `app/shared/range-entry.ts` | +| which bounds are somebody's own, not the geometry's | `ownBounds`, `app/shared/filter.ts` | +| which columns' rules an emptied box releases | `releasedBounds`, `app/shared/filter.ts` | +| what a narrowed axis says an unticked value brings | `facetCounts`, `app/shared/catalog-matcher.ts` | +| the same work, off the UI thread | `app/client/catalog-matcher.worker.ts` | +| what a click on the part means | `app/shared/part-interaction.ts` | +| which layer one press of Escape or Enter reaches | `app/shared/use-escape.ts` | +| which list the arrows move through, and the focus | `app/shared/arrow-target.ts` | +| where a menu opened off a button stands, and how tall | `app/shared/menu-place.ts` | +| keeping an open menu against its button | `app/shared/use-anchored-menu.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` | +| how far below the holder a stack has to stand | `belowHolder`, `app/shared/drawn-assembly.ts` | +| which slots were filled against the rules | `overrides`, `app/shared/assembly-tree.ts` | +| what a stack offers, and its button's words | `app/shared/assembly-actions.ts` | +| what a shop calls an assembly, and its field | `renameItem` / `renameAssembly`, `name-field.tsx` | +| reading and filtering a holder or a collet | `app/shared/component-columns.ts` | +| which column header asks which filter | `app/shared/column-filters.ts` | +| a tool in one phrase, with its shank or neck | `app/shared/tool-type.ts` | +| which ticks a filter the page set puts on Type | `typesAsking`, `app/shared/tool-type.ts` | +| which of its columns a tap list narrows on | `askOfTapColumn`, `app/shared/column-filters.ts` | +| what a tick on the tap list's Type column asks | `formsAskingTaps`, `app/shared/hole-mode.ts` | +| what is narrowing a list, named for its button | `narrowingNames`, `app/shared/column-filters.ts` | +| the numbers a thread and a depth put on a tap | `tapBounds`, `app/shared/hole-mode.ts` | +| the form behind a Type phrase, and what it asks | `app/shared/tool-type.ts` | +| the list, its answers and its right-click | `app/components/feature-list-panel.tsx` | +| building a group | `app/components/group-editor.tsx` | +| the reading, its numbers and its thread | `app/components/selection-panel.tsx` | +| the tool table and its marks | `app/components/part-tool-table.tsx` | - `docs/` holds planning documents that outlive a single change. `docs/CATALOG-SPEC.md` is the tool catalog specified end to end — how a shop diff --git a/apps/catalog/app/components/column-filter.test.tsx b/apps/catalog/app/components/column-filter.test.tsx index 39f0922..5d70fdd 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,28 +229,6 @@ 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 }) - }) - - 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 }) - }) - - /** 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 }) - }) -}) - describe('the column picker', () => { it('keeps the pencil at the table header touch target size', () => { render( @@ -268,6 +245,12 @@ describe('the column picker', () => { /** * 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 +265,29 @@ 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) }) }) diff --git a/apps/catalog/app/components/column-filter.tsx b/apps/catalog/app/components/column-filter.tsx index c9bb990..7c7b2bb 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 { @@ -16,6 +16,7 @@ import { decimalsFor, } from '@toolpath/tool-support' import { 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' @@ -406,51 +407,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 +519,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 }, + ), + ) } /** @@ -1104,9 +1053,6 @@ 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 }) const order = columns.map((column) => column.code) const move = (code: string, index: number) => { @@ -1116,84 +1062,69 @@ export const ColumnPicker = ({ } } - 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" + + {/* + The pencil *is* the trigger. `Menu.Trigger` renders a `role="button"` + div of its own by default, and a button inside that is two controls + with one name — `filter-panel.tsx` says the same thing at more length. + */} + + + + } + /> + {/* + **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) => (
))}
- ) : null} -
+
+
) } diff --git a/apps/catalog/app/components/component-table.tsx b/apps/catalog/app/components/component-table.tsx index dde339f..8b22c73 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 } from 'shared/column-width' +import { useFittedColumns } from 'shared/use-fitted-columns' import { colletTypeLabel, familyLabel, @@ -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,8 @@ 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) + // The columns divide the panel, the same rule the tool list keeps. + useFittedColumns(inside, shown.map((column) => column.code).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 @@ -339,7 +344,7 @@ export const ComponentTable = ({ return String(a).localeCompare(String(b), 'en', { numeric: true }) }) } - width={flexible(WIDTH[column.code] ?? '6rem')} + width={flexible(WIDTH[column.code] ?? DEFAULT_COLUMN_WIDTH)} > {heading(column.code, column.label)} @@ -354,8 +359,8 @@ export const ComponentTable = ({ className={cn(TABLE_FACE, TABLE_INK, 'flex min-h-0 min-w-0 flex-1 flex-col')} >
+ {/* No stored layout and no `min-w-max`: `PartToolTable` says why. */} 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,7 +611,21 @@ 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() } } diff --git a/apps/catalog/app/components/part-tool-table.test.tsx b/apps/catalog/app/components/part-tool-table.test.tsx index f0a7bbf..80f7bc6 100644 --- a/apps/catalog/app/components/part-tool-table.test.tsx +++ b/apps/catalog/app/components/part-tool-table.test.tsx @@ -181,18 +181,40 @@ 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]')).not.toHaveClass('min-w-max') + }) + + /** + * **Nothing about a column's width is remembered between visits.** The kit + * stores a dragged layout under the `id` it is given and hands it back on the + * next mount, which is a saved answer to a question a column being shown or + * hidden has already changed. + */ + it('gives the kit no id to store a column layout under', () => { show({ columns: TOOL_COLUMNS, hiddenColumns: [], columnOrder: TOOL_COLUMNS.map((column) => column.code), }) - expect(document.querySelector('[data-table-library_table]')).toHaveClass('min-w-max') + expect(Object.keys(localStorage).filter((key) => key.startsWith('table-'))).toHaveLength(0) }) - it('uses flexible tracks for initial column widths', () => { - expect(flexibleColumnWidth('10rem')).toBe('minmax(10rem, 1fr)') + it('asks for tracks that divide the panel rather than floors under it', () => { + expect(flexibleColumnWidth('10rem')).toBe('minmax(0, 10fr)') }) }) diff --git a/apps/catalog/app/components/part-tool-table.tsx b/apps/catalog/app/components/part-tool-table.tsx index d70f724..bc7dbf8 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 } from 'shared/column-width' +import { useFittedColumns } from 'shared/use-fitted-columns' import { ToolTypeIcon } from './tool-icons' import { ColumnFilterMenu, @@ -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,9 @@ 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) + // The columns divide the panel; anything the kit's resizer froze onto it goes + // when the panel or the column set changes. + useFittedColumns(inside, shown.map((column) => column.code).join(' ')) const selectionCameFromTable = useRef(false) const setSelection = useCallback((next: SetStateAction) => { setSelectedRows((current) => { @@ -583,7 +594,7 @@ export const PartToolTable = ({ : String(a).localeCompare(String(b), 'en', { numeric: true }) }) } - width={flexibleColumnWidth(WIDTH[column.code] ?? '6rem')} + width={flexibleColumnWidth(WIDTH[column.code] ?? DEFAULT_COLUMN_WIDTH)} > {heading(column.code, column.label)} @@ -598,8 +609,15 @@ export const PartToolTable = ({ className={cn(TABLE_FACE, TABLE_INK, 'flex min-h-0 min-w-0 flex-1 flex-col')} >
+ {/* + **No `id`, and no `min-w-max`** (Paul, 2026-09-11). The kit stores a + dragged layout under its `id` and hands it back on the next visit, + which is a saved answer to a question — how wide is a column — that a + column being shown or hidden has already changed. And `min-w-max` was + half of what made the list open wider than its panel; + `shared/column-width` is the whole story. + */}
{ + it('asks for a share of the box rather than a floor under it', () => { + expect(fillingWidth('10rem')).toBe('minmax(0, 10fr)') + expect(fillingWidth('6rem')).toBe('minmax(0, 6fr)') + }) + + it('reads the parts a column asks for out of its rem', () => { + expect(columnWeight('12rem')).toBe(12) + expect(columnWeight('7.5rem')).toBe(7.5) + }) + + /** + * A width map is read here and nowhere else, so anything that is not a plain + * rem weighs what an unstated column does rather than becoming a second, + * silent sizing rule. + */ + it('weighs anything that is not a rem as an unstated column', () => { + expect(columnWeight('160px')).toBe(DEFAULT_WEIGHT) + expect(columnWeight('20%')).toBe(DEFAULT_WEIGHT) + expect(columnWeight('0rem')).toBe(DEFAULT_WEIGHT) + expect(columnWeight('')).toBe(DEFAULT_WEIGHT) + }) +}) diff --git a/apps/catalog/app/shared/column-width.ts b/apps/catalog/app/shared/column-width.ts new file mode 100644 index 0000000..cf59cd7 --- /dev/null +++ b/apps/catalog/app/shared/column-width.ts @@ -0,0 +1,50 @@ +/** + * How wide a column is, and why the list always ends at the edge of its box. + * + * **The columns divide the room they have; they do not ask for room and + * overflow it** (Paul, 2026-09-11). What a list used to be handed was + * `minmax(10rem, 1fr)` — a floor in rem and an equal share of whatever was + * left — under a table forced to `min-width: max-content`. Under max-content + * sizing every `1fr` track resolves to the *widest* floor it was handed, so the + * thirteen-column tool list opened 2120 px wide inside a 1169 px panel, with + * every column 192 px whatever the map beside it said. Measured on 2026-09-11. + * The list then snapped to fit the moment somebody touched a resize handle, + * because the kit's resizer rewrites the tracks as percentages of the box — + * which is the layout it should have opened at. + * + * So the rem in a width map is read as a **weight** rather than as a floor: + * `minmax(0, 10fr)` next to `minmax(0, 6fr)` is a catalogue number ten parts + * wide beside a flute count of six, out of whatever the panel has. Two things + * follow from the zero floor, and both are wanted: + * + * - the tracks always sum to the width of the box, at any size, with no + * horizontal scrollbar and no gutter reserved for one; and + * - the proportions in the map finally do something, where before only the + * largest entry in it did. + * + * A cell that runs out of room truncates — the kit's cells are `overflow: + * hidden` with an ellipsis, and the words a column can lose are on rows that + * carry a `title`. + */ + +/** The parts a column with nothing said about it asks for. */ +export const DEFAULT_WEIGHT = 6 + +/** What a column with nothing said about it asks for. */ +export const DEFAULT_COLUMN_WIDTH = `${DEFAULT_WEIGHT}rem` + +/** + * The parts of the box a column asks for. + * + * Anything that is not a plain rem length weighs what an unstated column does: + * a width map is read by this module alone, so a `px` or a `%` in one would be + * a silent third sizing rule rather than something to honour. + */ +export const columnWeight = (width: string): number => { + const rem = /^\s*([\d.]+)rem\s*$/.exec(width) + const stated = rem === null ? Number.NaN : Number(rem[1]) + return Number.isFinite(stated) && stated > 0 ? stated : DEFAULT_WEIGHT +} + +/** The grid track a column asks for: its share of the box, never more than it. */ +export const fillingWidth = (width: string): string => `minmax(0, ${columnWeight(width)}fr)` diff --git a/apps/catalog/app/shared/menu-place.test.ts b/apps/catalog/app/shared/menu-place.test.ts new file mode 100644 index 0000000..19d7c8e --- /dev/null +++ b/apps/catalog/app/shared/menu-place.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { menuRoom, placeMenu } from './menu-place' + +/** + * How tall a box opened off a button is, and which way it opens. + * + * One rule for the filter menus, the column picker and the quick filters: 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 }) + }) + + 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 }) + }) + + /** 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 }) + }) +}) + +/** + * Where the box stands, in viewport pixels. + * + * These are the numbers a `position: fixed` portal is given, which is why the + * menus that used to be drawn inside the strip that opened them are no longer + * cut off by the viewer card (Paul, 2026-09-11). + */ +describe('placing a menu against its button', () => { + const viewport = { width: 1000, height: 900 } + const button = { top: 100, bottom: 130, left: 400, right: 500 } + + it('hangs a downward menu off the bottom of the button', () => { + expect(placeMenu(button, 160, 'right', viewport)).toEqual({ + top: 134, + bottom: null, + left: 340, + height: 758, + }) + }) + + /** Anchored by its bottom, so a box flips without a frame in the wrong place. */ + it('stands an upward menu on the top of the button', () => { + expect(placeMenu({ ...button, top: 700, bottom: 730 }, 160, 'right', viewport)).toEqual({ + top: null, + bottom: 204, + left: 340, + height: 688, + }) + }) + + it('lines a left-aligned menu up with the near edge instead', () => { + expect(placeMenu(button, 160, 'left', viewport).left).toBe(400) + }) + + /** A menu on the last column opens leftwards rather than off the screen. */ + it('keeps the box inside the window at either edge', () => { + expect(placeMenu({ ...button, left: 960, right: 990 }, 160, 'left', viewport).left).toBe(832) + expect(placeMenu({ ...button, left: 4, right: 30 }, 160, 'right', viewport).left).toBe(8) + }) +}) diff --git a/apps/catalog/app/shared/menu-place.ts b/apps/catalog/app/shared/menu-place.ts new file mode 100644 index 0000000..aea6a98 --- /dev/null +++ b/apps/catalog/app/shared/menu-place.ts @@ -0,0 +1,100 @@ +/** + * Where a box opened off a button stands, and how tall it may be. + * + * **One rule, for every menu on the part screen.** The column funnels, the + * column picker and the quick filters over the bottom of the viewer all open a + * box against a button that can be anywhere on the page, and each had grown its + * own half of the answer — which is how the picker and the quick filters ended + * up drawn *inside* the card that clips them (Paul, 2026-09-11: "the 'which + * columns to show' menu is now hidden behind the table when opened. Same with + * the 'part material' menu"). A menu belongs to the page, not to the thing it + * was opened from — which is why the picker and the quick filters are the kit's + * `Menu` now, and why the column funnels, which cannot be (they re-find an + * anchor the virtualized table rebuilds under them), are placed by this. + */ + +/** Room kept between the menu and the edge of the screen. */ +export 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. + */ +export const MENU_LEAST = 220 + +/** The gap between the button and the box it opened. */ +export const MENU_GAP = 4 + +/** + * 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. + * + * **The screen is what runs out, and only the screen.** That is true because a + * menu placed by {@link placeMenu} is drawn in a portal on the page: a box + * inside the viewer card would be cut off at the card's edge long before it ran + * out of window, and measuring against the window would be a lie about it. + */ +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. */ +export interface 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 whole placement of a menu, in viewport pixels — a `position: fixed` box. + * + * Where {@link menuRoom} says upwards, the box 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. + * + * @param button the box the menu opened from. + * @param width what the menu has measured, or 0 before it has been drawn once. + * @param align which edge of the button the menu lines up with, so a menu on + * the last column opens leftwards instead of off the screen. + */ +export const placeMenu = ( + button: { + readonly top: number + readonly bottom: number + readonly left: number + readonly right: number + }, + width: number, + align: 'left' | 'right', + viewport: { readonly width: number; readonly height: number }, +): Placed => { + const wanted = align === 'right' ? button.right - width : button.left + const room = menuRoom(button, viewport.height) + return { + top: room.upwards ? null : button.bottom + MENU_GAP, + bottom: room.upwards ? viewport.height - button.top + MENU_GAP : null, + left: Math.max(8, Math.min(wanted, viewport.width - width - 8)), + height: room.height, + } +} diff --git a/apps/catalog/app/shared/use-fitted-columns.ts b/apps/catalog/app/shared/use-fitted-columns.ts new file mode 100644 index 0000000..79d181f --- /dev/null +++ b/apps/catalog/app/shared/use-fitted-columns.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, type RefObject } from 'react' + +/** + * What the kit's resize handle leaves behind, and when it has to go. + * + * Dragging a column edge does not go through React at all: `@toolpath/ui`'s + * table is `@table-library/react-table-library` underneath, and its resizer + * writes the tracks straight onto the table element as an inline custom + * property — percentages of the box, measured at the width the box had while + * the mouse was down. An inline property beats the class `shared/column-width` + * hands its tracks to, so from the first drag onwards the list is laid out by + * that frozen string and nothing else. + * + * Two things then make it wrong rather than merely stale: + * + * - **a column is shown or hidden**, and the string still names the old + * columns — eleven percentages over twelve tracks, so every column after the + * change is the width of its neighbour and the last of them is unclaimed; and + * - **the box changes size**, where the percentages hold but the widths + * somebody dragged were chosen against a panel that is no longer that size. + * + * Both are answered the same way: drop the inline property and let the tracks + * the list asked for take over, which is the layout it opens at. A drag is + * therefore kept until one of those two happens and not after — that is the + * trade, and it is the right way round, because a list that fits its box is + * what every column in it is read from. + * + * A `ResizeObserver` rather than a window listener: the panel is resized by the + * order list folding away and by the tool drawing beside it as much as by the + * window, and none of those raise a `resize` event. + * + * @param inside the element the list is drawn in — the table is found under it. + * @param columns what the shown columns are, in order, as one string. Any + * change to it refits, so it has to name the columns rather than count them. + */ +export const useFittedColumns = (inside: RefObject, columns: string): void => { + const refit = useCallback(() => { + const table = inside.current?.querySelector('[data-table-library_table]') + if (!(table instanceof HTMLElement)) { + return + } + table.style.removeProperty('--data-table-library_grid-template-columns') + }, [inside]) + + useEffect(() => { + refit() + }, [refit, columns]) + + useEffect(() => { + const element = inside.current + // jsdom has no ResizeObserver, and a component test has nothing to observe. + if (element === null || typeof ResizeObserver === 'undefined') { + return + } + const observer = new ResizeObserver(() => refit()) + observer.observe(element) + return () => observer.disconnect() + }, [inside, refit]) +} diff --git a/apps/catalog/app/styles.css b/apps/catalog/app/styles.css index d2e1152..d3bd44a 100644 --- a/apps/catalog/app/styles.css +++ b/apps/catalog/app/styles.css @@ -150,20 +150,27 @@ html:not(.dark) body { .filter-off { background-color: var(--color-zinc-950); } +} - /* The UI table hides its scrollbar by default; the part table needs its full width reachable. */ - [data-part-tool-table] .hide-scrollbar { - overflow-x: auto !important; - scrollbar-gutter: stable; - scrollbar-width: auto; - } - [data-part-tool-table] .hide-scrollbar::-webkit-scrollbar { - display: block; - height: 0.75rem; +@layer utilities { + /** + * `hide-scrollbar`, which the kit asks for and nothing defined. + * + * `@toolpath/ui`'s table draws its scroll box as `overflow-x-scroll + * hide-scrollbar` and leaves the class to the application; with no rule + * behind it Chromium reserved a classic scrollbar anyway, which is the 16px + * of dead ground down the right of the tool list (Paul, 2026-09-11: "a + * padding on the container element that is the size of a scrollbar"). + * + * Defining it rather than fighting it: the columns divide the panel now — + * `shared/column-width` — so a list has nothing to scroll sideways to, and + * this application's own overrides that widened the table past its box and + * reserved a gutter for the overflow came out with it. + */ + .hide-scrollbar { + scrollbar-width: none; } - - /* The UI table's fixed tracks must be wider than the viewport, not clipped to it. */ - [data-part-tool-table] [data-table-library_table] { - min-width: max-content; + .hide-scrollbar::-webkit-scrollbar { + display: none; } } diff --git a/apps/catalog/tests/on-the-part.spec.ts b/apps/catalog/tests/on-the-part.spec.ts index 2d845c2..5208306 100644 --- a/apps/catalog/tests/on-the-part.spec.ts +++ b/apps/catalog/tests/on-the-part.spec.ts @@ -732,10 +732,12 @@ test('filters open from the bar floating over the bottom of the part', async ({ expect(toolbarBox!.y + toolbarBox!.height).toBeLessThanOrEqual(rowsBox!.y) }).toPass() + // And the rows under it end where the panel ends — see "the columns divide + // the panel" below, which is where that rule is pinned. const tableScroll = page.locator('[data-part-tool-table] .hide-scrollbar').first() await expect(tableScroll).toBeVisible() expect(await tableScroll.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe( - true, + false, ) // What no column shows is on the toolbar, answerable without a press first. @@ -761,6 +763,153 @@ test('filters open from the bar floating over the bottom of the part', async ({ await expect(types.getByRole('checkbox', { name: 'Circle segment taper' })).toHaveCount(0) }) +/** + * The tracks the list is laid out on, and whether they fit the box it is in. + * + * Read off the scroll container rather than off the header cells, because what + * went wrong was the *sum*: every column came out at 192px whatever it asked + * for, and the thirteen of them added up to 2120px inside an 1169px panel. + */ +const tableFit = (page: Page) => + page.evaluate(() => { + const holder = document.querySelector('[data-part-tool-table]') + const scroller = holder?.querySelector('.hide-scrollbar') + const table = holder?.querySelector('[data-table-library_table]') + if ( + !(scroller instanceof HTMLElement) || + !(table instanceof HTMLElement) || + !(holder instanceof HTMLElement) + ) { + throw new Error('the tool list is on screen') + } + return { + // What the columns add up to, against the room they have. + table: table.offsetWidth, + room: scroller.clientWidth, + // The gutter a scrollbar reserves, which should be none of it. + gutter: scroller.offsetWidth - scroller.clientWidth, + columns: holder.querySelectorAll('[role="columnheader"]').length, + } + }) + +/** + * **The columns divide the panel; they do not overflow it** (Paul, 2026-09-11: + * "on load, the table extends outside of the bounds of the container. On + * clicking to resize a column all of the columns then snap to fit"). + * + * Both halves of that were true and neither was a coincidence. The list asked + * for `minmax(10rem, 1fr)` tracks under a table pinned to `min-width: + * max-content`, and under max-content sizing every `1fr` track resolves to the + * *widest* floor it was handed — so thirteen columns opened at 192px each, + * 2120px of them inside an 1169px panel, with only the largest entry in the + * width map doing anything at all. Touching a resize handle then rewrote the + * tracks as percentages of the box, which is the layout it should have opened + * at: the fix is to open at it. `app/shared/column-width.ts` is the rule. + * + * Three moments, because the layout is settled in three different ways: by CSS + * on load, by CSS again when the window changes, and by + * `shared/use-fitted-columns` after a drag has frozen a layout onto the table + * that the column set has since outgrown. A list that fits on load and breaks + * on the first column somebody hides is the defect this is here for. + */ +test('the columns divide the panel, at every width and column set', async ({ page }) => { + await ready(page) + await keepFeature(page) + await expect(page.getByRole('grid').first().getByRole('row').nth(1)).toBeVisible() + + const opened = await tableFit(page) + expect(opened.table).toBe(opened.room) + // No scrollbar, and so no strip of dead ground reserved for one. + expect(opened.gutter).toBe(0) + + await page.setViewportSize({ width: 1200, height: 1000 }) + await expect(async () => { + const resized = await tableFit(page) + expect(resized.room).toBeLessThan(opened.room) + expect(resized.table).toBe(resized.room) + }).toPass() + + /* + A drag first, because dragging is what freezes a layout onto the table: + the kit's resizer writes the tracks inline, outside React, and they name + the columns that were there when the mouse went down. + */ + const handle = page.locator('[data-part-tool-table] .resizer-area').first() + const grip = await handle.boundingBox() + expect(grip).not.toBeNull() + await page.mouse.move(grip!.x + grip!.width / 2, grip!.y + grip!.height / 2) + await page.mouse.down() + await page.mouse.move(grip!.x + grip!.width / 2 + 40, grip!.y + grip!.height / 2, { steps: 5 }) + await page.mouse.up() + + const dragged = await tableFit(page) + expect(dragged.table).toBe(dragged.room) + + await page.getByRole('button', { name: 'Which columns to show' }).first().click() + const columns = page.getByRole('group', { name: 'Columns' }).first() + await expect(columns).toBeVisible() + await columns.getByRole('checkbox', { name: 'Flutes' }).click() + await page.keyboard.press('Escape') + + await expect(async () => { + const fewer = await tableFit(page) + expect(fewer.columns).toBe(dragged.columns - 1) + expect(fewer.table).toBe(fewer.room) + }).toPass() +}) + +/** + * **A menu belongs to the page, not to the card it was opened from** (Paul, + * 2026-09-11: "the 'which columns to show' menu is now hidden behind the table + * when opened. Same with the 'part material' menu"). + * + * The strip carrying both of these buttons floats over the bottom of the + * viewer, and the viewer is a card with `overflow: hidden` — so each box was + * cut off at the card's bottom edge, with the tool list showing through where + * the rest of it should have been. Opening them upwards would have hidden that + * rather than fixed it: a menu with half the window under it belongs under its + * button. Both are portals placed by `shared/menu-place` now, the arrangement + * the column funnels already needed. + * + * Asked by hit-testing rather than by reading a `z-index`, because what was + * wrong was a clip and not a stacking order, and neither is visible in a style. + */ +test('the menus over the part are drawn over the table, not clipped by the card', async ({ + page, +}) => { + await ready(page) + await keepFeature(page) + await expect(page.getByRole('grid').first().getByRole('row').nth(1)).toBeVisible() + + /** What is actually painted at the middle of the box, and at its bottom edge. */ + const reaches = (menu: Locator) => + menu.evaluate((box) => { + const rect = box.getBoundingClientRect() + const hits = (y: number) => document.elementFromPoint(rect.x + rect.width / 2, y) + return { + middle: box.contains(hits(rect.y + rect.height / 2)), + // One pixel inside the bottom edge — where a clip takes the box away. + bottom: box.contains(hits(rect.bottom - 1)), + height: rect.height, + } + }) + + await page.getByRole('button', { name: 'Which columns to show' }).first().click() + const columns = page.getByRole('group', { name: 'Columns' }).first() + await expect(columns).toBeVisible() + expect(await reaches(columns)).toMatchObject({ middle: true, bottom: true }) + await page.keyboard.press('Escape') + + await page.getByRole('button', { name: 'Part material' }).first().click() + const material = page.locator('[data-tool-filter-menu]').first() + await expect(material).toBeVisible() + expect(await reaches(material)).toMatchObject({ middle: true, bottom: true }) + + // And it is still a menu: a press inside it answers rather than closing it. + await material.getByRole('button', { name: /Steel/ }).click() + await expect(material).toBeVisible() +}) + /** * **One grouping, one column** (Paul, 2026-09-08: "product line and family are * the same and need to be rolled into one Family field. This should be a From 58f4900093be45038e9e8a68b4aa669a4ae0b3a8 Mon Sep 17 00:00:00 2001 From: Brad Estey Date: Fri, 11 Sep 2026 16:32:18 -0400 Subject: [PATCH 09/19] Track column visibility selections in local storage. --- apps/catalog/app/routes/part.tsx | 96 +++----- apps/catalog/app/shared/column-layout.test.ts | 95 ++++++++ apps/catalog/app/shared/column-layout.ts | 217 ++++++++++++++++++ 3 files changed, 346 insertions(+), 62 deletions(-) create mode 100644 apps/catalog/app/shared/column-layout.test.ts create mode 100644 apps/catalog/app/shared/column-layout.ts diff --git a/apps/catalog/app/routes/part.tsx b/apps/catalog/app/routes/part.tsx index 2287418..0f888f8 100644 --- a/apps/catalog/app/routes/part.tsx +++ b/apps/catalog/app/routes/part.tsx @@ -98,6 +98,7 @@ import { import { ColumnPicker } from 'components/column-filter' import { BUTTON_FILTERS } from 'components/filter-panel' import { orderedCodes } from 'shared/column-order' +import { COLUMN_KEY, useColumnLayout } from 'shared/column-layout' import { hiddenAfterAuto } from 'shared/auto-columns' import { capRows, firstBy, keptFirst, oneEach } from 'shared/tool-order' import { @@ -530,9 +531,25 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: at: DOMRect featureTag?: string } | null>(null) - const [hiddenColumns, setHiddenColumns] = useState>( - hiddenByDefault(TOOL_COLUMNS), - ) + /** + * The tool list's columns — which are shown, in what order, remembered. + * + * `shared/column-layout.ts` owns the storing and, more to the point, what a + * stored answer means once the catalog's columns have moved under it. + */ + const toolLayout = useColumnLayout(COLUMN_KEY.tools, TOOL_COLUMNS) + const { + hidden: hiddenColumns, + order: columnOrder, + /** + * The columns somebody has decided for themselves. + * + * Tip angle and corner radius follow the list — `shared/auto-columns.ts` + * is the rule — and a code in here is one the list stops deciding about. + */ + touched: touchedColumns, + setHidden: setHiddenColumns, + } = toolLayout /** * The tap list's columns, kept apart from the tool list's. * @@ -540,25 +557,10 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: * point angle — so they cannot share one hidden set: a code hidden in one * would mean nothing in the other, and the picker in the corner edits * whichever list is open (Paul, 2026-09-02: "allow me to use those columns - * if I edit the tap table"). - */ - const [hiddenTapColumns, setHiddenTapColumns] = useState>( - hiddenByDefault(TAP_COLUMNS), - ) - const [tapColumnOrder, setTapColumnOrder] = useState>(() => - TAP_COLUMNS.map((column) => column.code), - ) - /** - * The columns somebody has decided for themselves. - * - * Tip angle and corner radius follow the list — `shared/auto-columns.ts` is - * the rule — and a code in here is one the list stops deciding about. + * if I edit the tap table"). A separate key, for the same reason. */ - const touchedColumns = useRef(new Set()) - /** The order the columns are drawn in, dragged in the column picker. */ - const [columnOrder, setColumnOrder] = useState>(() => - TOOL_COLUMNS.map((column) => column.code), - ) + const tapLayout = useColumnLayout(COLUMN_KEY.taps, TAP_COLUMNS) + const { hidden: hiddenTapColumns, order: tapColumnOrder } = tapLayout /** Narrowing the list by catalog number, as typed into the first column. */ const [numberSearch, setNumberSearch] = useState('') @@ -1716,18 +1718,11 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: * pressed it does not want it undone by clearing a taper. */ const [noCollet, setNoCollet] = useState(false) - const [hiddenHolderColumns, setHiddenHolderColumns] = useState>(() => - hiddenComponentColumns(HOLDER_COLUMNS), - ) - const [holderColumnOrder, setHolderColumnOrder] = useState>(() => - HOLDER_COLUMNS.map((column) => column.code), - ) - const [hiddenColletColumns, setHiddenColletColumns] = useState>(() => - hiddenComponentColumns(COLLET_COLUMNS), - ) - const [colletColumnOrder, setColletColumnOrder] = useState>(() => - COLLET_COLUMNS.map((column) => column.code), - ) + /** The holder and collet lists' columns, each remembered under its own key. */ + const holderLayout = useColumnLayout(COLUMN_KEY.holders, HOLDER_COLUMNS) + const { hidden: hiddenHolderColumns, order: holderColumnOrder } = holderLayout + const colletLayout = useColumnLayout(COLUMN_KEY.collets, COLLET_COLUMNS) + const { hidden: hiddenColletColumns, order: colletColumnOrder } = colletLayout /** * Whose tree is on screen. @@ -2368,8 +2363,8 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: */ const listedForms = useMemo(() => listed.map((each) => each.form), [listed]) useEffect(() => { - setHiddenColumns((current) => hiddenAfterAuto(current, listedForms, touchedColumns.current)) - }, [listedForms]) + setHiddenColumns((current) => hiddenAfterAuto(current, listedForms, new Set(touchedColumns))) + }, [listedForms, touchedColumns, setHiddenColumns]) /** * The list narrowed by what was typed into the catalog number column. * @@ -5923,16 +5918,8 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: ).includes(column.code), ) .map((column) => column.code)} - onToggle={(code) => { - const set = - componentSlot === 'holder' ? setHiddenHolderColumns : setHiddenColletColumns - set((current) => - current.includes(code) - ? current.filter((each) => each !== code) - : [...current, code], - ) - }} - onReorder={componentSlot === 'holder' ? setHolderColumnOrder : setColletColumnOrder} + onToggle={componentSlot === 'holder' ? holderLayout.toggle : colletLayout.toggle} + onReorder={componentSlot === 'holder' ? holderLayout.reorder : colletLayout.reorder} /> ) : ( column.code)} - onToggle={(code) => { - if (tappingNow) { - setHiddenTapColumns((current) => - current.includes(code) - ? current.filter((each) => each !== code) - : [...current, code], - ) - return - } - touchedColumns.current.add(code) - setHiddenColumns((current) => - current.includes(code) - ? current.filter((each) => each !== code) - : [...current, code], - ) - }} - onReorder={tappingNow ? setTapColumnOrder : setColumnOrder} + onToggle={tappingNow ? tapLayout.toggle : toolLayout.toggle} + onReorder={tappingNow ? tapLayout.reorder : toolLayout.reorder} /> ) } diff --git a/apps/catalog/app/shared/column-layout.test.ts b/apps/catalog/app/shared/column-layout.test.ts new file mode 100644 index 0000000..dc31baf --- /dev/null +++ b/apps/catalog/app/shared/column-layout.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { defaultLayout, readLayout, reconciled, written, type LayoutColumn } from './column-layout' + +/** + * Which columns a list shows, and in what order, across a reload. + * + * The whole risk in storing this is the *second* visit: a stored answer is + * about the columns that existed when it was written, and this catalog's column + * sets move. Column widths were taken out of storage on 2026-09-11 for exactly + * that reason — the kit stores them positionally, so hiding one re-applies + * every width to the wrong column. Codes survive that, but only if what comes + * back is reconciled rather than trusted, which is what this pins. + */ +const COLUMNS: ReadonlyArray = [ + { code: 'catalogNumber', default: true }, + { code: 'brand', default: true }, + { code: 'DC', default: true }, + { code: 'RE', default: false }, +] + +describe('the columns a list shows', () => { + it('starts with the columns that open by default, in the declared order', () => { + expect(defaultLayout(COLUMNS)).toEqual({ + hidden: ['RE'], + order: ['catalogNumber', 'brand', 'DC', 'RE'], + touched: [], + }) + }) + + it('gives a browser that has stored nothing the defaults', () => { + expect(readLayout(null, COLUMNS)).toEqual(defaultLayout(COLUMNS)) + }) + + /** A half-written or hand-edited key is not worth a blank screen. */ + it('falls back to the defaults on anything it cannot read', () => { + expect(readLayout('{"hidden":', COLUMNS)).toEqual(defaultLayout(COLUMNS)) + expect(readLayout('"a string"', COLUMNS)).toEqual(defaultLayout(COLUMNS)) + expect(readLayout('{"hidden":{"DC":true},"order":7}', COLUMNS)).toEqual(defaultLayout(COLUMNS)) + }) + + it('gives back exactly what was stored where nothing has changed', () => { + const kept = { + hidden: ['brand'], + order: ['DC', 'catalogNumber', 'brand', 'RE'], + touched: ['brand', 'RE'], + } + + expect(readLayout(written(kept, COLUMNS), COLUMNS)).toEqual(kept) + }) + + /** A code nothing draws is an answer about nothing. */ + it('drops a column the catalog no longer has', () => { + const stored = { + hidden: ['brand', 'LCF'], + order: ['LCF', 'DC', 'catalogNumber', 'brand', 'RE'], + touched: ['LCF'], + known: ['catalogNumber', 'brand', 'DC', 'RE', 'LCF'], + } + + expect(reconciled(stored, COLUMNS)).toEqual({ + hidden: ['brand'], + order: ['DC', 'catalogNumber', 'brand', 'RE'], + touched: [], + }) + }) + + /** + * **`known` is what tells a new column from one somebody left showing.** + * Without it every browser that had ever opened the picker would get the next + * off-by-default column turned on, and keep it on. + */ + it('gives a column added since the layout was saved its own default', () => { + const later: ReadonlyArray = [ + ...COLUMNS, + { code: 'SIG', default: false }, + { code: 'NOF', default: true }, + ] + const stored = written({ hidden: ['RE'], order: ['DC', 'catalogNumber'], touched: [] }, COLUMNS) + + expect(readLayout(stored, later)).toEqual({ + hidden: ['RE', 'SIG'], + // Appended, which neither drops it nor pretends somebody placed it. + order: ['DC', 'catalogNumber', 'brand', 'RE', 'SIG', 'NOF'], + touched: [], + }) + }) + + /** A column somebody unhid stays unhidden when a later build adds others. */ + it('keeps a column the shop turned on when the column set grows', () => { + const later: ReadonlyArray = [...COLUMNS, { code: 'SIG', default: false }] + const stored = written({ hidden: [], order: [], touched: ['RE'] }, COLUMNS) + + expect(readLayout(stored, later)).toMatchObject({ hidden: ['SIG'], touched: ['RE'] }) + }) +}) diff --git a/apps/catalog/app/shared/column-layout.ts b/apps/catalog/app/shared/column-layout.ts new file mode 100644 index 0000000..24527f2 --- /dev/null +++ b/apps/catalog/app/shared/column-layout.ts @@ -0,0 +1,217 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { orderedCodes } from './column-order' + +/** + * Which columns a list shows, and in what order — remembered per browser. + * + * **A shop sets its columns once** (Paul, 2026-09-11: "save column order and + * visibility in local storage"). Cutting a thirteen-column list down to the + * five somebody compares on, and dragging them into the order they read them + * in, is a decision about how this shop works rather than about this part, and + * it was thrown away on every reload. + * + * **This is the opposite call to the one made about column *widths* on the same + * day, and the difference is what a stored answer is worth when the columns + * change.** A width is stored as a track list positional in the columns that + * existed when it was dragged, so hiding one silently re-applies every width to + * the wrong column — the kit's own storage did exactly that, which is why this + * application gives its tables no `id` to store under. What is stored here is + * *codes*, so every stored answer still names the column it was about however + * the catalog's column set moves under it. + * + * That is reconciliation, not luck, and {@link reconciled} is where it happens. + */ + +/** A column, as far as this module needs to know one. */ +export interface LayoutColumn { + readonly code: string + readonly default: boolean +} + +export interface ColumnLayout { + /** The codes not drawn. */ + readonly hidden: ReadonlyArray + /** Every code, in the order the table draws them. */ + readonly order: ReadonlyArray + /** + * The codes somebody has decided for themselves. + * + * Tip angle and corner radius otherwise follow what is on the list — + * `shared/auto-columns.ts` is that rule — and this is what stops the list + * deciding about a column after somebody has. Stored with the rest, because + * a hand-toggled tip angle that comes back off on the next reload is the + * visibility this exists to keep. + */ + readonly touched: ReadonlyArray +} + +/** + * What is written down: the layout, plus the columns it was made about. + * + * `known` is the load-bearing field. Without it a column added to the catalog + * after somebody saved a layout cannot be told apart from one they deliberately + * left showing — so a new column that is meant to be off by default would come + * on for everybody who had ever opened the picker, and stay on. + */ +interface Stored extends ColumnLayout { + readonly known: ReadonlyArray +} + +const codes = (columns: ReadonlyArray): Array => + columns.map((column) => column.code) + +/** The layout a browser that has never been here makes. */ +export const defaultLayout = (columns: ReadonlyArray): ColumnLayout => ({ + hidden: columns.filter((column) => !column.default).map((column) => column.code), + order: codes(columns), + touched: [], +}) + +const strings = (value: unknown): Array => + Array.isArray(value) ? value.filter((each): each is string => typeof each === 'string') : [] + +/** + * A stored layout, answered against the columns this build actually has. + * + * Three things can have changed between the write and the read, and each has + * one honest answer: + * + * - **a column is gone** — drop it from all three lists, since a code nothing + * draws is an answer about nothing; + * - **a column is new** — it takes its own default, which is what `known` is + * for, and it goes on the end of the order (`orderedCodes`, which neither + * drops it nor pretends somebody put it there); and + * - **nothing changed** — the layout comes back exactly as it was left. + */ +export const reconciled = ( + stored: Partial, + columns: ReadonlyArray, +): ColumnLayout => { + const here = new Set(codes(columns)) + const known = new Set(strings(stored.known)) + const kept = strings(stored.hidden).filter((code) => here.has(code)) + const fresh = columns + .filter((column) => !known.has(column.code) && !column.default && !kept.includes(column.code)) + .map((column) => column.code) + return { + hidden: [...kept, ...fresh], + order: orderedCodes(codes(columns), strings(stored.order)), + touched: strings(stored.touched).filter((code) => here.has(code)), + } +} + +/** What a key holds, or the columns' own defaults where it holds nothing usable. */ +export const readLayout = ( + raw: string | null, + columns: ReadonlyArray, +): ColumnLayout => { + if (raw === null || raw === '') { + return defaultLayout(columns) + } + try { + const parsed: unknown = JSON.parse(raw) + if (typeof parsed !== 'object' || parsed === null) { + return defaultLayout(columns) + } + return reconciled(parsed as Partial, columns) + } catch { + return defaultLayout(columns) + } +} + +/** The record to write: the layout, and the columns it was made about. */ +export const written = (layout: ColumnLayout, columns: ReadonlyArray): string => + JSON.stringify({ ...layout, known: codes(columns) } satisfies Stored) + +/** Where each list's layout is kept. One key per list, because one list's columns are not another's. */ +export const COLUMN_KEY = { + tools: 'tool-catalog.columns.tools', + taps: 'tool-catalog.columns.taps', + holders: 'tool-catalog.columns.holders', + collets: 'tool-catalog.columns.collets', +} as const + +/** + * One list's columns, remembered. + * + * **Read in an effect rather than in the initial state**, which is what the + * rest of this application's stored preferences do: the catalog is built with + * `ssr: false` but React Router still renders the shell once at build time, and + * state that differs between that render and the browser's first one is a + * hydration mismatch. The cost is that the defaults are drawn for one frame. + * + * @param key one of {@link COLUMN_KEY}. + * @param columns every column this list can draw, in the order they are + * declared — the order a browser that has never been here gets. + */ +export const useColumnLayout = (key: string, columns: ReadonlyArray) => { + const [layout, setLayout] = useState(() => defaultLayout(columns)) + /* + Nothing is written until something has been read. Otherwise the first + change of any kind — including the automatic one the tool list makes for + tip angle and corner radius, which runs on the first list — would write the + defaults over a real stored layout before the effect below had read it. + */ + const loaded = useRef(false) + + useEffect(() => { + setLayout(readLayout(globalThis.localStorage?.getItem(key) ?? null, columns)) + loaded.current = true + // The columns of a given list are a module constant; the key is what says + // which list this is. + }, [key]) + + const keep = useCallback( + (next: (current: ColumnLayout) => ColumnLayout) => { + setLayout((current) => { + const settled = next(current) + if (settled === current) { + return current + } + if (loaded.current) { + globalThis.localStorage?.setItem(key, written(settled, columns)) + } + return settled + }) + }, + [key, columns], + ) + + /** A column shown or hidden, and marked as somebody's own decision. */ + const toggle = useCallback( + (code: string) => { + keep((current) => ({ + ...current, + hidden: current.hidden.includes(code) + ? current.hidden.filter((each) => each !== code) + : [...current.hidden, code], + touched: current.touched.includes(code) ? current.touched : [...current.touched, code], + })) + }, + [keep], + ) + + const reorder = useCallback( + (order: ReadonlyArray) => { + keep((current) => ({ ...current, order })) + }, + [keep], + ) + + /** + * The hidden set rewritten by a rule rather than by a press — + * `hiddenAfterAuto`. Returning the same array leaves the stored layout + * alone, which is what keeps a rule that decided nothing out of storage. + */ + const setHidden = useCallback( + (next: (hidden: ReadonlyArray) => ReadonlyArray) => { + keep((current) => { + const hidden = next(current.hidden) + return hidden === current.hidden ? current : { ...current, hidden } + }) + }, + [keep], + ) + + return { ...layout, toggle, reorder, setHidden } +} From 2d490bb4501279978a637597a65a8dca1912077d Mon Sep 17 00:00:00 2001 From: Brad Estey Date: Fri, 11 Sep 2026 16:32:35 -0400 Subject: [PATCH 10/19] Another test. --- apps/catalog/tests/on-the-part.spec.ts | 57 ++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/apps/catalog/tests/on-the-part.spec.ts b/apps/catalog/tests/on-the-part.spec.ts index 5208306..81d5168 100644 --- a/apps/catalog/tests/on-the-part.spec.ts +++ b/apps/catalog/tests/on-the-part.spec.ts @@ -858,6 +858,63 @@ test('the columns divide the panel, at every width and column set', async ({ pag }).toPass() }) +/** + * **A shop sets its columns once** (Paul, 2026-09-11: "save column order and + * visibility in local storage"). + * + * Cutting a thirteen-column list down to what somebody compares on, and + * dragging those into the order they read them in, is a decision about how the + * shop works rather than about this part — and it was thrown away on every + * reload. `app/shared/column-layout.ts` is the rule, and its own tests cover + * what a stored answer means once the catalog's columns have moved under it; + * this is the half only a real browser can answer: that the write happens, that + * the read happens, and that the table drawn afterwards is the stored one. + * + * **Deliberately not what happens to a column's *width*.** That is not stored, + * because the kit stores widths positionally and hiding one column re-applies + * every width to the wrong column — see "the columns divide the panel" above. + */ +test('remembers which columns are shown, and their order, across a reload', async ({ page }) => { + await ready(page) + await keepFeature(page) + await expect(page.getByRole('grid').first().getByRole('row').nth(1)).toBeVisible() + + const headings = () => + page.locator('[data-part-tool-table]').first().getByRole('columnheader').allInnerTexts() + + const opened = await headings() + expect(opened.some((heading) => heading.includes('Flutes'))).toBe(true) + expect(opened.some((heading) => heading.includes('Shank'))).toBe(false) + + await page.getByRole('button', { name: 'Which columns to show' }).first().click() + const columns = page.getByRole('group', { name: 'Columns' }).first() + await expect(columns).toBeVisible() + // One off and one on, so both halves of "which columns" are being asked. + await columns.getByRole('checkbox', { name: 'Flutes' }).click() + await columns.getByRole('checkbox', { name: 'Shank' }).click() + // Up two places, by the keyboard: the drag is the same rule and nothing a + // test can do honestly — `movedBy` in `shared/column-order.ts`. + await columns.getByRole('button', { name: 'Move shank' }).click() + await page.keyboard.press('ArrowUp') + await page.keyboard.press('ArrowUp') + await page.keyboard.press('Escape') + + const chosen = await headings() + expect(chosen.some((heading) => heading.includes('Flutes'))).toBe(false) + expect(chosen.some((heading) => heading.includes('Shank'))).toBe(true) + + await page.reload() + await ready(page) + await expect(page.getByRole('grid').first().getByRole('row').nth(1)).toBeVisible() + + // The same columns, in the same order, and still filling the panel. + await expect(async () => { + expect(await headings()).toEqual(chosen) + }).toPass() + const fit = await tableFit(page) + expect(fit.table).toBe(fit.room) +}) + /** * **A menu belongs to the page, not to the card it was opened from** (Paul, * 2026-09-11: "the 'which columns to show' menu is now hidden behind the table From fcf9337b984e9ad88f365a311185ef8bbdbea591 Mon Sep 17 00:00:00 2001 From: Brad Estey Date: Fri, 11 Sep 2026 16:38:06 -0400 Subject: [PATCH 11/19] Update agents.md. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 0022fde..cb25893 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,6 +281,7 @@ application unless that application says otherwise. | the two columns the list turns on for itself | `app/shared/auto-columns.ts` | | how wide a column is, as a share of the panel | `app/shared/column-width.ts` | | refitting the tracks a drag froze onto the table | `app/shared/use-fitted-columns.ts` | +| which columns a list shows, in what order, remembered | `app/shared/column-layout.ts` | | a row's answer, and what it opens to | `app/shared/recommendations.ts` | | what a press on a row of the order list opens | `pressRow`, `app/routes/part.tsx` | | whose stacks the tree beside an open box shows | `editedItem` / `treeKey`, same file | From 846bc864b933938cc6b67001cb5a50737b8b72d4 Mon Sep 17 00:00:00 2001 From: Brad Estey Date: Fri, 11 Sep 2026 16:58:59 -0400 Subject: [PATCH 12/19] Keep column widths in local storage. --- .../app/components/component-table.tsx | 10 +- .../app/components/part-tool-table.test.tsx | 22 ++- .../app/components/part-tool-table.tsx | 26 +++- apps/catalog/app/shared/column-layout.test.ts | 88 ++++++++++- apps/catalog/app/shared/column-layout.ts | 15 +- apps/catalog/app/shared/column-width.test.ts | 82 +++++++++- apps/catalog/app/shared/column-width.ts | 77 ++++++++++ apps/catalog/tests/on-the-part.spec.ts | 144 +++++++++++++++++- 8 files changed, 430 insertions(+), 34 deletions(-) diff --git a/apps/catalog/app/components/component-table.tsx b/apps/catalog/app/components/component-table.tsx index 8b22c73..36eb91e 100644 --- a/apps/catalog/app/components/component-table.tsx +++ b/apps/catalog/app/components/component-table.tsx @@ -12,7 +12,7 @@ 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 } from 'shared/column-width' +import { DEFAULT_COLUMN_WIDTH, fillingWidth, widthId } from 'shared/column-width' import { useFittedColumns } from 'shared/use-fitted-columns' import { colletTypeLabel, @@ -242,8 +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, shown.map((column) => column.code).join(' ')) + 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 @@ -359,8 +362,9 @@ export const ComponentTable = ({ className={cn(TABLE_FACE, TABLE_INK, 'flex min-h-0 min-w-0 flex-1 flex-col')} >
- {/* No stored layout and no `min-w-max`: `PartToolTable` says why. */} + {/* An id named after the columns, and no `min-w-max`: `PartToolTable` says why. */}
{ }) /** - * **Nothing about a column's width is remembered between visits.** The kit - * stores a dragged layout under the `id` it is given and hands it back on the - * next mount, which is a saved answer to a question a column being shown or - * hidden has already changed. + * **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('gives the kit no id to store a column layout under', () => { - show({ - columns: TOOL_COLUMNS, - hiddenColumns: [], - columnOrder: TOOL_COLUMNS.map((column) => column.code), - }) - - expect(Object.keys(localStorage).filter((key) => key.startsWith('table-'))).toHaveLength(0) - }) it('asks for tracks that divide the panel rather than floors under it', () => { expect(flexibleColumnWidth('10rem')).toBe('minmax(0, 10fr)') diff --git a/apps/catalog/app/components/part-tool-table.tsx b/apps/catalog/app/components/part-tool-table.tsx index bc7dbf8..4d7fc65 100644 --- a/apps/catalog/app/components/part-tool-table.tsx +++ b/apps/catalog/app/components/part-tool-table.tsx @@ -19,7 +19,7 @@ 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 } from 'shared/column-width' +import { DEFAULT_COLUMN_WIDTH, fillingWidth, widthId } from 'shared/column-width' import { useFittedColumns } from 'shared/use-fitted-columns' import { ToolTypeIcon } from './tool-icons' import { @@ -476,9 +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, shown.map((column) => column.code).join(' ')) + useFittedColumns(inside, codes.join(' ')) const selectionCameFromTable = useRef(false) const setSelection = useCallback((next: SetStateAction) => { setSelectedRows((current) => { @@ -610,14 +619,15 @@ export const PartToolTable = ({ >
{/* - **No `id`, and no `min-w-max`** (Paul, 2026-09-11). The kit stores a - dragged layout under its `id` and hands it back on the next visit, - which is a saved answer to a question — how wide is a column — that a - column being shown or hidden has already changed. And `min-w-max` was - half of what made the list open wider than its panel; - `shared/column-width` is the whole story. + **An id named after the columns, and no `min-w-max`** (Paul, + 2026-09-11). The kit stores a dragged layout under its `id` and hands + it back on the next visit — which is worth keeping, and is only ever + an answer about the columns it was dragged on, so the column set *is* + the id. `min-w-max` was half of what made the list open wider than its + panel; `shared/column-width` is the whole story on both. */}
= [ { code: 'catalogNumber', default: true }, @@ -93,3 +104,68 @@ describe('the columns a list shows', () => { expect(readLayout(stored, later)).toMatchObject({ hidden: ['SIG'], touched: ['RE'] }) }) }) + +/** + * The press that edits the columns, and what it costs a dragged width. + * + * **Every stored width goes** (Paul, 2026-09-11: "on changing columns + * shown/hidden delete all localstorage keys saving column widths … Go back to + * the default sizes."). The rule is `shared/column-width.ts`; this is the wire + * from the picker to it, which is the part that can be got wrong silently — + * clearing on the columns *changing* rather than on the press throws away the + * width the list is about to settle on, since the list edits its own columns a + * tick after it loads. + */ +describe('the press that edits the columns', () => { + const COLUMNS: ReadonlyArray = [ + { code: 'catalogNumber', default: true }, + { code: 'brand', default: true }, + { code: 'RE', default: false }, + ] + + beforeEach(() => { + localStorage.clear() + }) + + const widths = () => Object.keys(localStorage).filter((key) => key.startsWith('table-')) + + it('drops every stored width when a column is shown or hidden', () => { + localStorage.setItem('table-part-tools.catalogNumber.brand', '8px 50% 50%') + localStorage.setItem('table-part-holders.catalogNumber', '8px 100%') + const { result } = renderHook(() => useColumnLayout(COLUMN_KEY.tools, COLUMNS)) + + act(() => { + result.current.toggle('brand') + }) + + expect(widths()).toEqual([]) + expect(result.current.hidden).toContain('brand') + }) + + it('drops them when the columns are dragged into another order too', () => { + localStorage.setItem('table-part-tools.catalogNumber.brand', '8px 50% 50%') + const { result } = renderHook(() => useColumnLayout(COLUMN_KEY.tools, COLUMNS)) + + act(() => { + result.current.reorder(['brand', 'catalogNumber', 'RE']) + }) + + expect(widths()).toEqual([]) + }) + + /** + * The list turning a column on for itself is not a press. A width stored + * under the set the list settles on has to survive the settling. + */ + it('leaves them alone when the list edits its own columns', () => { + localStorage.setItem('table-part-tools.catalogNumber.brand.RE', '8px 40% 30% 30%') + const { result } = renderHook(() => useColumnLayout(COLUMN_KEY.tools, COLUMNS)) + + act(() => { + result.current.setHidden((hidden) => hidden.filter((code) => code !== 'RE')) + }) + + expect(widths()).toEqual(['table-part-tools.catalogNumber.brand.RE']) + expect(result.current.hidden).not.toContain('RE') + }) +}) diff --git a/apps/catalog/app/shared/column-layout.ts b/apps/catalog/app/shared/column-layout.ts index 24527f2..446786c 100644 --- a/apps/catalog/app/shared/column-layout.ts +++ b/apps/catalog/app/shared/column-layout.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { orderedCodes } from './column-order' +import { forgetStoredWidths } from './column-width' /** * Which columns a list shows, and in what order — remembered per browser. @@ -177,9 +178,19 @@ export const useColumnLayout = (key: string, columns: ReadonlyArray { + forgetStoredWidths(globalThis.localStorage ?? null) keep((current) => ({ ...current, hidden: current.hidden.includes(code) @@ -191,8 +202,10 @@ export const useColumnLayout = (key: string, columns: ReadonlyArray) => { + forgetStoredWidths(globalThis.localStorage ?? null) keep((current) => ({ ...current, order })) }, [keep], diff --git a/apps/catalog/app/shared/column-width.test.ts b/apps/catalog/app/shared/column-width.test.ts index 6991388..14f304a 100644 --- a/apps/catalog/app/shared/column-width.test.ts +++ b/apps/catalog/app/shared/column-width.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest' -import { DEFAULT_WEIGHT, columnWeight, fillingWidth } from './column-width' +import { + DEFAULT_WEIGHT, + columnWeight, + fillingWidth, + forgetStoredWidths, + widthId, +} from './column-width' /** * How wide a column is. @@ -34,3 +40,77 @@ describe('how wide a column is', () => { expect(columnWeight('')).toBe(DEFAULT_WEIGHT) }) }) + +/** + * Where the kit keeps what somebody dragged (Paul, 2026-09-11: "the column + * widths should be stored in local storage but invalidate the old stores/ids + * every time a column is added or hidden"). + * + * `@toolpath/ui` writes a grid track list under `table-` — positional, and + * silent about which column each track was for. The kit guards the one case it + * can see, a change in the column *count*, and a swap or a reorder leaves that + * alone. So the id carries the column set, and a set that has changed asks a + * different key rather than being handed the wrong answer. + */ +describe('where a list keeps its dragged widths', () => { + it('names the id after the list and the columns on screen', () => { + expect(widthId('part-tools', ['catalogNumber', 'brand', 'DC'])).toBe( + 'part-tools.catalogNumber.brand.DC', + ) + }) + + it('asks a different key once a column is hidden, added or moved', () => { + const shown = ['catalogNumber', 'brand', 'DC'] + const hidden = widthId('part-tools', ['catalogNumber', 'DC']) + const added = widthId('part-tools', [...shown, 'RE']) + // A reorder is a rearrangement of the very positions a track list indexes. + const moved = widthId('part-tools', ['brand', 'catalogNumber', 'DC']) + const same = widthId('part-tools', shown) + + expect(new Set([hidden, added, moved, same]).size).toBe(4) + expect(widthId('part-tools', shown)).toBe(same) + }) + + /** + * **Editing the columns puts every list back on its defaults** (Paul, + * 2026-09-11: "I don't want columns to change size as I show and hide + * columns"). Every list, not this one: a stored width is an answer about a + * column set, and the press that edits one set has invalidated the idea that + * an old answer is worth resurfacing. + */ + describe('clearing the stored widths', () => { + const store = (entries: Record): Storage => { + const held = new Map(Object.entries(entries)) + return { + get length() { + return held.size + }, + key: (at: number) => [...held.keys()][at] ?? null, + getItem: (key: string) => held.get(key) ?? null, + setItem: (key: string, value: string) => held.set(key, value), + removeItem: (key: string) => void held.delete(key), + clear: () => held.clear(), + } as Storage + } + + it('drops every width any list has stored, and nothing else', () => { + const held = store({ + 'table-part-tools.catalogNumber.brand': 'a', + 'table-part-tools.catalogNumber.brand.DC': 'b', + 'table-part-holders.catalogNumber': 'c', + 'tool-catalog.columns.tools': 'd', + 'tool-catalog.preferences': 'e', + }) + + forgetStoredWidths(held) + + expect(held.length).toBe(2) + expect(held.getItem('tool-catalog.columns.tools')).toBe('d') + expect(held.getItem('tool-catalog.preferences')).toBe('e') + }) + + it('does nothing where a browser has no storage', () => { + expect(() => forgetStoredWidths(null)).not.toThrow() + }) + }) +}) diff --git a/apps/catalog/app/shared/column-width.ts b/apps/catalog/app/shared/column-width.ts index cf59cd7..3844ccd 100644 --- a/apps/catalog/app/shared/column-width.ts +++ b/apps/catalog/app/shared/column-width.ts @@ -48,3 +48,80 @@ export const columnWeight = (width: string): number => { /** The grid track a column asks for: its share of the box, never more than it. */ export const fillingWidth = (width: string): string => `minmax(0, ${columnWeight(width)}fr)` + +/** + * The id the kit stores this list's dragged column widths under. + * + * **The column set is the id** (Paul, 2026-09-11: "the column widths should be + * stored in local storage but invalidate the old stores/ids every time a column + * is added or hidden. The table id controls the local storage so the id needs + * to be changed to be the cache breaker"). + * + * What `@toolpath/ui`'s table writes under `table-` is a grid track list — + * eleven percentages in the order the columns happened to be in when somebody + * let go of the handle. It is **positional**, and it says nothing about which + * column each track was for, so a stored answer means something different the + * moment the columns change: hide one and every width after it lands on its + * neighbour. The kit guards the one case it can see, a change in the *count*, + * and is blind to a swap or a reorder, which leave the count alone. Widths were + * taken out of storage entirely for that reason earlier the same day. + * + * Naming the id after the columns is what puts them back safely: one stored + * layout per column set, found again when that set comes back, and never + * applied to any other. The **order** is in it too — a reorder is a + * rearrangement of the very positions the track list is indexed by. + * + * `shared/column-layout.ts` is the other half of this: which columns are shown + * and in what order is stored by *code*, so it survives the column set moving + * rather than being invalidated by it. The difference is the whole reason these + * are two modules. + */ +export const widthId = (list: string, shown: ReadonlyArray): string => + [list, ...shown].join('.') + +/** What the kit prefixes its own storage keys with — `use-column-layout.ts` in `@toolpath/ui`. */ +const KIT_PREFIX = 'table-' + +/** + * Every stored width, dropped. + * + * **Showing or hiding a column puts every list back on its default widths** + * (Paul, 2026-09-11: "on changing columns shown/hidden delete all localstorage + * keys saving column widths, they should all be invalidated. I don't want + * columns to change size as I show and hide columns. Go back to the default + * sizes."). A stored answer is only about the set it was dragged on, and a + * column set that has been edited is not that set — so the honest thing is a + * clean sheet rather than an old answer resurfacing under some id somebody + * happens to arrive back at. + * + * **Called from the press, not from the id.** A sweep hung on the id changing + * was built first and deleted the store the list was about to settle on: the + * tool list passes through two column sets on every load — the defaults, and + * then the set `shared/auto-columns.ts` settles on once it can see what is on + * the list, corner radius coming on for end mills a tick after the tools + * arrive. The id at mount is not the id a drag was stored under. A press in the + * column picker is a decision; that first change is the list finishing loading, + * and only one of the two should throw a width away. `shared/column-layout.ts` + * is where the press lives. + * + * **Cleared, not kept empty.** The kit writes its current layout back on any + * mouse-up once it has one in hand, so a key for the columns now on screen + * reappears within a click or two. What it holds then is the tracks the list + * computed for itself, which is exactly what going back to the default sizes + * means — what is gone is the old answer, not the file. + */ +export const forgetStoredWidths = ( + storage: Pick | null, +): void => { + if (storage === null) { + return + } + const stale: Array = [] + for (let at = 0; at < storage.length; at++) { + const key = storage.key(at) + if (key !== null && key.startsWith(KIT_PREFIX)) { + stale.push(key) + } + } + stale.forEach((key) => storage.removeItem(key)) +} diff --git a/apps/catalog/tests/on-the-part.spec.ts b/apps/catalog/tests/on-the-part.spec.ts index 81d5168..2172655 100644 --- a/apps/catalog/tests/on-the-part.spec.ts +++ b/apps/catalog/tests/on-the-part.spec.ts @@ -842,8 +842,13 @@ test('the columns divide the panel, at every width and column set', async ({ pag await page.mouse.move(grip!.x + grip!.width / 2 + 40, grip!.y + grip!.height / 2, { steps: 5 }) await page.mouse.up() - const dragged = await tableFit(page) - expect(dragged.table).toBe(dragged.room) + // Retried: a drag is five synthetic mouse moves, and a loaded machine can + // read the box between the last of them and the layout that follows it. + let dragged = await tableFit(page) + await expect(async () => { + dragged = await tableFit(page) + expect(dragged.table).toBe(dragged.room) + }).toPass() await page.getByRole('button', { name: 'Which columns to show' }).first().click() const columns = page.getByRole('group', { name: 'Columns' }).first() @@ -858,6 +863,141 @@ test('the columns divide the panel, at every width and column set', async ({ pag }).toPass() }) +/** + * **A dragged width is kept, and kept only for the columns it was about** + * (Paul, 2026-09-11: "the column widths should be stored in local storage but + * invalidate the old stores/ids every time a column is added or hidden. The + * table id controls the local storage so the id needs to be changed to be the + * cache breaker"). + * + * What `@toolpath/ui` writes is a grid track list — percentages in the order + * the columns were in when the handle was let go. It is positional and silent + * about which column each track was for, and the kit only guards a change in + * the *count*. So the column set is the id, and the rules are in + * `app/shared/column-width.ts`. + * + * Only a real browser can answer this one: the write happens on `mouseup` + * inside the kit, and nothing in jsdom can drag. + */ +test('keeps a dragged column width, and drops every one when the columns change', async ({ + page, +}) => { + await ready(page) + await keepFeature(page) + await expect(page.getByRole('grid').first().getByRole('row').nth(1)).toBeVisible() + + /** + * How much of the list the first column takes, in hundredths. + * + * A **share**, not a width in pixels: what the kit stores is percentages of + * the box, and the box is not the same size on the next visit — the column of + * questions over the part is a different width once a feature is on the order + * list, and the list gets what is left. Comparing pixels across the reload + * compares two panels. + * + * Named, not `.first()`: the kit draws an 8px selection column ahead of them. + */ + const firstShare = async () => { + const column = (await page + .locator('[data-part-tool-table]') + .first() + .getByRole('columnheader', { name: /Catalog number/ }) + .boundingBox())!.width + const { room } = await tableFit(page) + return Math.round((column / room) * 100) + } + + const stores = () => + page.evaluate(() => Object.keys(localStorage).filter((key) => key.startsWith('table-'))) + + const columnCount = () => + page.locator('[data-part-tool-table]').first().getByRole('columnheader').count() + + const opened = await firstShare() + const shownAtDrag = await columnCount() + + const handle = page.locator('[data-part-tool-table] .resizer-area').first() + const grip = await handle.boundingBox() + expect(grip).not.toBeNull() + await page.mouse.move(grip!.x + grip!.width / 2, grip!.y + grip!.height / 2) + await page.mouse.down() + await page.mouse.move(grip!.x + grip!.width / 2 + 60, grip!.y + grip!.height / 2, { steps: 5 }) + await page.mouse.up() + + const dragged = await firstShare() + expect(dragged).toBeGreaterThan(opened + 3) + /* + Stored under the columns it was dragged on: the kit writes under + `table-`, and the id is this list and the whole set it is drawing, in + order — `app/shared/column-width.ts` names it. + + Asserted as "one of these keys is the tool list's" rather than as the only + key there, because the kit writes a layout of its own accord as the list + settles; what this test is about is whether the *width* survives, which the + reload below is what answers. + */ + const stored = (await stores()).filter((each) => each.startsWith('table-part-tools.')) + expect(stored.length).toBeGreaterThan(0) + + /* + And it is still there on the next visit. Asserted on the store rather than + on the pixels: the kit restores by id, and which id this list settles on + depends on what is on it — `shared/auto-columns.ts` brings corner radius and + tip angle on for the forms the list happens to be holding, so the column set + after a reload is not reliably the one a drag was stored under. That the + drag *survives* is this half; that it is only ever applied to its own column + set is `shared/column-width.test.ts`. + */ + await page.reload() + await ready(page) + await expect(page.getByRole('grid').first().getByRole('row').nth(1)).toBeVisible() + expect(await stores()).toEqual(expect.arrayContaining(stored)) + + /* + And now the invalidation (Paul, 2026-09-11: "I don't want columns to change + size as I show and hide columns. Go back to the default sizes."). Hiding one + column drops **every** stored width, so the list is back on the tracks it + computes rather than on eleven percentages meant for twelve columns. + */ + await page.getByRole('button', { name: 'Which columns to show' }).first().click() + const columns = page.getByRole('group', { name: 'Columns' }).first() + await expect(columns).toBeVisible() + await columns.getByRole('checkbox', { name: 'Flutes' }).click() + await page.keyboard.press('Escape') + + await expect(async () => { + expect(await columnCount()).toBe(shownAtDrag - 1) + expect(await stores()).toEqual([]) + }).toPass() + + /* + Back on the share the tracks give it — `minmax(0, 10fr)` against the weights + beside it — rather than on the one that was dragged. + + Not equal to the share it opened at, and correctly so: a column has gone, so + the ten weights left divide the box between fewer of them and every share + rises a little. What it must not be is the dragged one, and the panel + filling exactly is what says the list is back on its own tracks. + */ + expect(await firstShare()).toBeLessThan(dragged - 2) + const fit = await tableFit(page) + expect(fit.table).toBe(fit.room) + + /* + And it does not come back on the next visit — the drag is genuinely gone + rather than merely not applied this time. + + Asserted on the share and not on an empty store, because the store does not + stay empty: the kit writes its current layout on any mouse-up once it has + one in hand, so a key for the columns now on screen reappears within a click + or two. What it holds is the tracks the list computed, which is the point. + */ + await page.reload() + await ready(page) + await expect(page.getByRole('grid').first().getByRole('row').nth(1)).toBeVisible() + expect(await firstShare()).toBeLessThan(dragged - 2) +}) + /** * **A shop sets its columns once** (Paul, 2026-09-11: "save column order and * visibility in local storage"). From 2f1782fb153ffc5ff218283d09ec2aef3f382613 Mon Sep 17 00:00:00 2001 From: Brad Estey Date: Fri, 11 Sep 2026 17:04:26 -0400 Subject: [PATCH 13/19] Add blue line when reordering columns. --- .../app/components/column-filter.test.tsx | 71 +++++++++ apps/catalog/app/components/column-filter.tsx | 150 +++++++++++------- apps/catalog/app/shared/column-order.test.ts | 38 ++++- apps/catalog/app/shared/column-order.ts | 38 +++++ 4 files changed, 242 insertions(+), 55 deletions(-) diff --git a/apps/catalog/app/components/column-filter.test.tsx b/apps/catalog/app/components/column-filter.test.tsx index 5d70fdd..905afea 100644 --- a/apps/catalog/app/components/column-filter.test.tsx +++ b/apps/catalog/app/components/column-filter.test.tsx @@ -230,6 +230,77 @@ describe('the shape a stored bound has', () => { }) 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() + }) + + /** 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') + }) + + 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) + }) + it('keeps the pencil at the table header touch target size', () => { render( { const [open, setOpen] = useState(false) const [held, setHeld] = useState(null) + /** 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) @@ -1126,61 +1129,100 @@ export const ColumnPicker = ({ aria-label="Columns" className="max-h-[var(--available-height)] overflow-y-auto" > - {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 : ( + -
- ))} + ) + })}
diff --git a/apps/catalog/app/shared/column-order.test.ts b/apps/catalog/app/shared/column-order.test.ts index 3e25fac..916e9ee 100644 --- a/apps/catalog/app/shared/column-order.test.ts +++ b/apps/catalog/app/shared/column-order.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { movedBy, movedTo, orderedCodes } from './column-order' +import { dropEdge, movedBy, movedTo, orderedCodes } from './column-order' describe('the column order', () => { const order = ['DC', 'LCF', 'LD', 'RE'] @@ -42,3 +42,39 @@ describe('the column order', () => { expect(movedBy(order, 'RE', 1)).toEqual(order) }) }) + +/** + * The line that says where a dragged column would land. + * + * **Decided by `movedTo`, not by what looks right** (Paul, 2026-09-11: "the + * list should show a blue line (2px horizontal) where the item will be + * dropped"). `movedTo` lifts the code out before reading the index, so a drop + * below where the drag started lands *after* the row under the pointer and one + * above lands *before* it — and a line drawn on the other edge would promise a + * position the drop does not deliver. + */ +describe('where a dragged column would land', () => { + const ORDER = ['A', 'B', 'C', 'D'] + + it('goes under the row when the drag is downwards', () => { + expect(dropEdge(ORDER, 'A', 2)).toBe('below') + // And that is where it lands: [B, C, A, D]. + expect(movedTo(ORDER, 'A', 2)).toEqual(['B', 'C', 'A', 'D']) + }) + + it('goes over the row when the drag is upwards', () => { + expect(dropEdge(ORDER, 'D', 1)).toBe('above') + expect(movedTo(ORDER, 'D', 1)).toEqual(['A', 'D', 'B', 'C']) + }) + + /** A row cannot land on itself, so it draws no line while it is picked up. */ + it('draws nothing over the row being dragged', () => { + expect(dropEdge(ORDER, 'B', 1)).toBeNull() + }) + + it('draws nothing for a code or a row this list does not have', () => { + expect(dropEdge(ORDER, 'Z', 2)).toBeNull() + expect(dropEdge(ORDER, 'A', -1)).toBeNull() + expect(dropEdge(ORDER, 'A', 4)).toBeNull() + }) +}) diff --git a/apps/catalog/app/shared/column-order.ts b/apps/catalog/app/shared/column-order.ts index b7d65e3..4e6decd 100644 --- a/apps/catalog/app/shared/column-order.ts +++ b/apps/catalog/app/shared/column-order.ts @@ -50,3 +50,41 @@ export const movedBy = (order: ReadonlyArray, code: string, by: number): const from = order.indexOf(code) return from === -1 ? [...order] : movedTo(order, code, from + by) } + +/** Which side of a row a dragged column would land on, or nothing over its own row. */ +export type DropEdge = 'above' | 'below' + +/** + * Where the line goes while a column is being dragged over a row. + * + * **A drag with no line is a guess** (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"). The picker moved a column on drop and said nothing + * before it, so the only way to find out where a row would land was to drop it + * and look. + * + * The edge is decided by {@link movedTo} rather than chosen to look right: that + * function lifts the code out *before* reading the index, so dropping on a row + * below where the drag started lands **after** that row, and dropping on one + * above lands **before** it. Say [A, B, C, D] and drag A onto C — without A the + * list is [B, C, D] and inserting at 2 gives [B, C, A, D], which is under C. + * Drag D onto B and the same arithmetic puts it over B. A line drawn any other + * way is a line that lies about the drop. + * + * @param order the codes as the list is drawing them. + * @param held the code being dragged. + * @param over the index of the row the pointer is on. + */ +export const dropEdge = ( + order: ReadonlyArray, + held: string, + over: number, +): DropEdge | null => { + const from = order.indexOf(held) + // Nothing over the row being dragged, and nothing for a code this list has + // never heard of: neither is a drop that would move anything. + if (from === -1 || from === over || over < 0 || over >= order.length) { + return null + } + return from < over ? 'below' : 'above' +} From 003935624c9cdbfc1f4788e8ec6a61072c16bf8e Mon Sep 17 00:00:00 2001 From: Brad Estey Date: Fri, 11 Sep 2026 17:22:41 -0400 Subject: [PATCH 14/19] Handle race condition resetting hidden columns or reload. --- apps/catalog/app/routes/part.tsx | 22 ++--- apps/catalog/app/shared/column-layout.test.ts | 46 ++++++++++- apps/catalog/app/shared/column-layout.ts | 15 +++- apps/catalog/tests/on-the-part.spec.ts | 82 +++++++++++++++++++ 4 files changed, 147 insertions(+), 18 deletions(-) diff --git a/apps/catalog/app/routes/part.tsx b/apps/catalog/app/routes/part.tsx index 0f888f8..60453c9 100644 --- a/apps/catalog/app/routes/part.tsx +++ b/apps/catalog/app/routes/part.tsx @@ -538,18 +538,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: * stored answer means once the catalog's columns have moved under it. */ const toolLayout = useColumnLayout(COLUMN_KEY.tools, TOOL_COLUMNS) - const { - hidden: hiddenColumns, - order: columnOrder, - /** - * The columns somebody has decided for themselves. - * - * Tip angle and corner radius follow the list — `shared/auto-columns.ts` - * is the rule — and a code in here is one the list stops deciding about. - */ - touched: touchedColumns, - setHidden: setHiddenColumns, - } = toolLayout + const { hidden: hiddenColumns, order: columnOrder, setHidden: setHiddenColumns } = toolLayout /** * The tap list's columns, kept apart from the tool list's. * @@ -2363,8 +2352,13 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: */ const listedForms = useMemo(() => listed.map((each) => each.form), [listed]) useEffect(() => { - setHiddenColumns((current) => hiddenAfterAuto(current, listedForms, new Set(touchedColumns))) - }, [listedForms, touchedColumns, setHiddenColumns]) + // Both halves off one layout: what is hidden, and what somebody has already + // decided for themselves. `shared/column-layout.ts` § `setHidden` says what + // reading those two out of two different states cost. + setHiddenColumns((layout) => + hiddenAfterAuto(layout.hidden, listedForms, new Set(layout.touched)), + ) + }, [listedForms, setHiddenColumns]) /** * The list narrowed by what was typed into the catalog number column. * diff --git a/apps/catalog/app/shared/column-layout.test.ts b/apps/catalog/app/shared/column-layout.test.ts index 0c31be8..b34b05a 100644 --- a/apps/catalog/app/shared/column-layout.test.ts +++ b/apps/catalog/app/shared/column-layout.test.ts @@ -157,15 +157,57 @@ describe('the press that edits the columns', () => { * The list turning a column on for itself is not a press. A width stored * under the set the list settles on has to survive the settling. */ - it('leaves them alone when the list edits its own columns', () => { + it('leaves the widths alone when the list edits its own columns', () => { localStorage.setItem('table-part-tools.catalogNumber.brand.RE', '8px 40% 30% 30%') const { result } = renderHook(() => useColumnLayout(COLUMN_KEY.tools, COLUMNS)) act(() => { - result.current.setHidden((hidden) => hidden.filter((code) => code !== 'RE')) + result.current.setHidden((layout) => layout.hidden.filter((code) => code !== 'RE')) }) expect(widths()).toEqual(['table-part-tools.catalogNumber.brand.RE']) expect(result.current.hidden).not.toContain('RE') }) }) + +/** + * The rule that edits the columns for the list, and the state it reads. + * + * **Both halves off one layout** (Paul, 2026-09-11: "I hide corner radius and + * tip angle … I hit refresh. They come back. Local storage also changes to add + * them back in."). `hiddenAfterAuto` asks two questions of this state — what is + * hidden, and what has somebody already decided — and the answer to the second + * is what stops it undoing the first. While the rule took only `hidden`, the + * caller had to read `touched` out of its own render, and on the frame where a + * stored layout is restored those are different states: the hidden set came + * from the update queue and `touched` was still the empty default. + */ +describe('a rule rewriting the hidden set', () => { + const COLUMNS: ReadonlyArray = [ + { code: 'DC', default: true }, + { code: 'RE', default: false }, + ] + + beforeEach(() => { + localStorage.clear() + }) + + it('reads what somebody decided out of the same layout as the hidden set', () => { + localStorage.setItem( + COLUMN_KEY.tools, + written({ hidden: ['RE'], order: ['DC', 'RE'], touched: ['RE'] }, COLUMNS), + ) + const { result } = renderHook(() => useColumnLayout(COLUMN_KEY.tools, COLUMNS)) + const seen: Array> = [] + + act(() => { + result.current.setHidden((layout) => { + seen.push(layout.touched) + return layout.hidden + }) + }) + + // The restored answer, not the empty default the hook started at. + expect(seen).toEqual([['RE']]) + }) +}) diff --git a/apps/catalog/app/shared/column-layout.ts b/apps/catalog/app/shared/column-layout.ts index 446786c..367e224 100644 --- a/apps/catalog/app/shared/column-layout.ts +++ b/apps/catalog/app/shared/column-layout.ts @@ -215,11 +215,22 @@ export const useColumnLayout = (key: string, columns: ReadonlyArray