diff --git a/AGENTS.md b/AGENTS.md index 3fef690..7912a49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -437,10 +437,13 @@ scrape:holding` into `scrape-out/toolholding/`, because a shop re-scrapes end mill) and `FamilyDefinition.familyCode` on toolholding. `clampLength` maps from `L9` rather than `LF` — a different quantity under the same name, which is why catalog version 11 is a re-ingest and not a rebuild — and a collet - publishing no `L9` states no grip rather than borrowing one. The hand-pinned - `KENNAMETAL_HOLDING_CODES` is a fallback for what upstream leaves silent, not - the source: keeping it ahead of the vendor's own is how one goes stale, and - one had. See `docs/TOOL-CATALOG-PLAN.md` § _Length below the holder_. + publishing no `L9` states no grip rather than borrowing one. **The family code + is the scraper's, and only the scraper's**: the hand-pinned + `KENNAMETAL_HOLDING_CODES` came out of `scrape.ts` on 2026-09-10, because + keeping a code ahead of the vendor's own is how one goes stale, and one had. + A family the scraper states no code for is one this package cannot reach, and + `holdingReachable` says so. See `docs/TOOL-CATALOG-PLAN.md` § _Length below + the holder_ and `docs/TOOL-SCRAPER-REFACTOR.md`. - **`src/vendors/` is dead and kept on purpose.** Nothing calls it now that the seam is taken. It is the only written record of REGO-FIX's DIN 4000 code pinning, each mapping citing its evidence, so it stays until either that diff --git a/apps/catalog/app/components/assembly-panel.tsx b/apps/catalog/app/components/assembly-panel.tsx index bf3a7ab..2e27c8a 100644 --- a/apps/catalog/app/components/assembly-panel.tsx +++ b/apps/catalog/app/components/assembly-panel.tsx @@ -179,10 +179,10 @@ export const AssemblyPanel = ({ {/* **The same sheet, whichever component is being read.** `` - owns the drawing and its Tool / Tool + holder switch; what changes with - the tree's selection is the column under it. A second drawing for the + owns the drawing and the press that frames it; what changes with the + tree's selection is the column under it. A second drawing for the holder is what this replaces — it came out lying on its side, and it - took the switch away the moment somebody looked at a holder. + took the press away the moment somebody looked at a holder. */} {tool !== null && toolDetails !== undefined ? (
{toolDetails(facts)}
diff --git a/apps/catalog/app/components/catalog-drawing.test.tsx b/apps/catalog/app/components/catalog-drawing.test.tsx index 32fa950..73564eb 100644 --- a/apps/catalog/app/components/catalog-drawing.test.tsx +++ b/apps/catalog/app/components/catalog-drawing.test.tsx @@ -126,6 +126,27 @@ describe('the catalog drawing', () => { ) }) + /** + * **The zoom is the package's, handed straight on.** + * + * This is a sensor on the wire rather than on the framing: what the cut is, + * how much holder stays above it and when a zoom is refused are all + * `@toolpath/tool-drawing`'s rules, and it marks the sheet `data-zoom="tool"` + * only where the zoom actually took. So a prop that never reached it would + * leave that attribute off — which is exactly what the panel's press would + * look like if it were doing nothing. + */ + it('frames the sheet on the working end when asked, and on the stack by default', () => { + const whole = drawn() + expect(whole.querySelector('figure svg')?.getAttribute('data-zoom')).toBeNull() + + StubResizeObserver.all = [] + const working = drawn( + , + ) + expect(working.querySelector('figure svg')?.getAttribute('data-zoom')).toBe('tool') + }) + /** * 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 @@ -282,39 +303,43 @@ describe('the overlay this application draws', () => { }) /** - * **The room for the material is the caller's**, because only the caller - * knows how much sheet there is. + * **Reserving room for the material costs the tool none of the sheet.** + * + * This replaces the inverse test, and the inversion is the point. + * `padding` used to be priced as a *margin*: the package clamped an + * over-large request to 0.6 of the axis and paid for it out of the scale, so + * `MATERIAL_ROOM` on the part page's ~400 px panel took the whole allowance + * and the assembly was crushed into the top third (2026-09-03). The old test + * pinned the workaround — that asking for less left the tool more sheet — and + * the application carried a `materialRoom` prop so a narrow panel could ask + * for less. * - * `` is told its padding before it has measured its panel, so - * it cannot take a share of an axis it has not seen; it only clamps an - * over-large request to 0.6 of the axis and scales the dimension bands back - * with it. On the part page's tool panel — a column beside the part, not a - * full-width card — 240 px *was* that whole 0.6, and the assembly was - * crushed into the top third (2026-09-03). Asking for less has to leave the - * tool more of the sheet, or the prop is decoration. + * `@toolpath/tool-drawing` 1.0.0 retired that: the reservation is granted out + * of room the drawing cannot use, so the guess no longer comes out of the + * scale and the caller is told to ask for as much as the widest sheet could + * use. The prop went with it, and this drawing now asks for one figure always. + * + * So what is pinned is the guarantee that made deleting the prop safe. The + * only reservation this component still varies is whether there is an overlay + * to reserve for at all — with a curve it asks for `MATERIAL_ROOM` on the + * `+r` flank, without one it asks for nothing — and the tool has to be framed + * the same either way. Re-pricing padding as a margin upstream would shrink + * the first against the second, which is the regression this drawing cannot + * see for itself. */ - it('draws the tool larger when the caller keeps less of the sheet for the material', () => { - const wide = drawn( + it('frames the tool the same whether or not it reserves room for the material', () => { + const reserved = drawn( , ) - const generous = wide.querySelector('svg')?.getAttribute('viewBox') StubResizeObserver.all = [] - const narrow = drawn( - , - ) - const tight = narrow.querySelector('svg')?.getAttribute('viewBox') + const bare = drawn() + + const across = (container: HTMLElement) => + Number((container.querySelector('svg')?.getAttribute('viewBox') ?? '0 0 0 0').split(' ')[2]) - const across = (viewBox: string | null | undefined) => - Number((viewBox ?? '0 0 0 0').split(' ')[2]) - expect(across(tight)).toBeGreaterThan(0) - expect(across(tight)).toBeLessThan(across(generous)) + expect(across(bare)).toBeGreaterThan(0) + expect(across(reserved)).toBe(across(bare)) }) /** diff --git a/apps/catalog/app/components/catalog-drawing.tsx b/apps/catalog/app/components/catalog-drawing.tsx index d19feaa..c1f481b 100644 --- a/apps/catalog/app/components/catalog-drawing.tsx +++ b/apps/catalog/app/components/catalog-drawing.tsx @@ -11,11 +11,13 @@ import { formatLength, type UnitSystem } from '@toolpath/tool-support' import { SHEETS, ToolDrawing, + useDrawingContext, type Box, type Extent, type Padding, type Sheet, type ViewerAssembly, + type Zoom, } from '@toolpath/tool-drawing' import { assemblyOutline } from '@toolpath/tool-drawing/geometry' import { @@ -24,7 +26,14 @@ import { tightestGaps, type Gaps, } from '@toolpath/tool-drawing/clearance' +import { useEffect, useRef } from 'react' import { assemblyLabel } from 'shared/assemblies' +import { + clearanceCase, + clearanceReport, + type ClearanceCase, + type ClearanceDebugInput, +} from 'shared/clearance-debug' import { getProfile } from 'shared/catalog' import { toViewerAssembly } from 'shared/tool-drawing-input' import { useTheme } from 'shared/use-theme' @@ -48,16 +57,22 @@ import { useTheme } from 'shared/use-theme' * Room reserved on the `+r` flank for the material, in pixels. * * A number, because `` is told its padding before it has measured - * anything. The drawing this replaces gave the material whatever the panel had - * spare, which it could do only because it did its own framing; the package - * clamps an over-large request back to `MOST_OF_A_PANEL` — 0.6 of the axis — - * and scales the dimension bands back with it. + * anything — the caller has to guess, and the guess is the same on every sheet + * this application draws. + * + * **One figure, asked generously** (2026-09-11). It used to be a prop, because + * `padding` was priced as a *margin*: the package clamped an over-large request + * to 0.6 of the axis and paid for it out of the scale, so 240 px on the part + * page's ~400 px panel was the whole allowance and the assembly came out + * crushed into the top third (2026-09-03). The wide card asked for 240 and the + * panel had to ask for 130 to dodge that. * - * That clamp is a guard, not a layout: on the part page's tool panel, 240 px - * of a 400 px-tall sheet *is* the whole 0.6, so the assembly was crushed into - * the top third and the dimension bands with it (2026-09-03). So the room is a - * prop, and this is the wide card's default rather than everybody's — see - * {@link CatalogDrawingProps.materialRoom}. + * `@toolpath/tool-drawing` 1.0.0 retired those semantics: `padding` is now a + * *reservation*, granted out of room the drawing itself cannot use, so asking + * for more than the panel has costs the drawing nothing and a narrow sheet + * simply grants what it has. The package's own guidance is to ask for as much + * as the widest sheet could use — which is one number, not a per-caller one, so + * the prop and the panel's smaller figure both came off. */ export const MATERIAL_ROOM = 240 @@ -83,6 +98,30 @@ export const MATERIAL_ROOM = 240 */ const UNDIMENSIONED: Gaps = { axial: null, radial: null } +/** + * The colour of the sheet the drawing is on, for the panel it sits in. + * + * **The drawing is a sheet of paper and the panel was a grey wash around it** + * (Paul, 2026-09-01), which read as a white rectangle inset in a card rather + * than as a drawing (Paul, 2026-09-11). A panel flush with the sheet has no + * inset to read, and the sheet reaches the card's own edge. + * + * It is a hook rather than a constant because the two grounds are not the same + * colour: white on a light page, and `#22252b` on a dark one, which is a step + * above the card on purpose — the package will not put a torch in a dark + * application. So a panel that hard-coded white would match in one theme and + * glare in the other. + * + * Here rather than in the panel because this is the file wired to + * `@toolpath/tool-drawing`: `SHEETS` is the package's word for its own ground, + * and a second copy of those two colours in a stylesheet is a drift with a + * delay on it. + */ +export const useSheetGround = (): string => { + const [theme] = useTheme() + return SHEETS[theme].ground +} + export interface CatalogDrawingProps { readonly tool: CatalogTool /** The stack around the tool, or null to draw the tool alone. */ @@ -122,14 +161,19 @@ export interface CatalogDrawingProps { */ readonly measured?: boolean /** - * Room reserved on the `+r` flank for the material, in pixels. + * How much of the stack the sheet is framed to. * - * The caller's, because only the caller knows how much sheet there is: the - * package measures its panel *after* it has been told its padding, so it - * cannot ask for a share of an axis it has not seen yet. A narrow panel - * passes less; {@link MATERIAL_ROOM} is what a full-width card wants. + * `'assembly'` is the whole stack, `'tool'` the working end and a sliver of + * the holder above it. The package's own prop, handed straight on: the cut, + * the headroom above it and the refusal to zoom a tool that states no length + * are all its rules, and a second copy of them here would be a drift with a + * delay on it. + * + * The caller's rather than this file's because it is a reading decision — + * the panel that has a button for it passes what the button says, and a card + * beside a list has no button and takes the whole stack. */ - readonly materialRoom?: number + readonly zoom?: Zoom } /** @@ -160,6 +204,92 @@ const verdictNote = ( return said === null ? at : `${at} · ${said}` } +/** + * What the clearance wall was drawn from, in the console, while it is wrong. + * + * **Four of the overlay's inputs are only knowable from inside the sheet.** The + * curve and the cutting radius are this file's, but the extent the sheet was + * framed to, the panel as the `ResizeObserver` measured it and the room the + * frame actually granted on the `+r` flank are all settled after + * `` has been called — so a caller debugging a wall that collapsed + * against the cut can see none of them. A child inside the drawing can: the + * frame reaches it through `useDrawingContext`, and the `` it is rendered + * into is the box that was measured. + * + * Dev only, and it draws nothing. It prints once per drawing rather than once + * per render — pointing at a dimension line re-renders the sheet and would + * otherwise fill the console — and leaves `__clearanceDebug()` behind, which + * reprints the report and returns the case as JSON for + * `scratchpad/reach-probe.mjs`. + */ +const ClearanceProbe = ({ + about, + curve, + cuttingRadius, + profile, + margins, + asked, + stickout, +}: Omit) => { + const drawing = useDrawingContext() + /** An anchor in the sheet, for the one thing context does not publish: the measured box. */ + const anchor = useRef(null) + const frame = drawing?.frame ?? null + const extent = drawing?.extent ?? null + /** + * One line per drawing, not one per render. + * + * The scale stands in for the panel: the box is measured after the paint that + * would have to report it, and every box that framed differently reaches here + * as a different scale. + */ + const signature = JSON.stringify([ + about, + curve.horizontalOffset, + curve.verticalOffset, + cuttingRadius, + extent, + frame?.scale, + frame?.reserve?.plus, + ]) + useEffect(() => { + if (!import.meta.env.DEV || frame === null || extent === null) { + return + } + const measured = anchor.current?.ownerSVGElement?.getBoundingClientRect() + const input: ClearanceDebugInput = { + about, + curve, + cuttingRadius, + profile, + margins, + asked, + stickout, + extent, + box: { width: measured?.width ?? 0, height: measured?.height ?? 0 }, + granted: { padding: frame.padding, reserve: frame.reserve ?? null }, + scale: frame.scale, + fontSize: frame.fontSize, + viewBox: frame.viewBox, + } + const report = (): ClearanceCase => { + console.log(clearanceReport(input)) + const shape = clearanceCase(input) + console.log('case.json for scratchpad/reach-probe.mjs:\n' + JSON.stringify(shape, null, 2)) + return shape + } + report() + ;(window as unknown as { __clearanceDebug?: () => ClearanceCase }).__clearanceDebug = report + return () => { + delete (window as unknown as { __clearanceDebug?: () => ClearanceCase }).__clearanceDebug + } + // The signature is what identifies a drawing; the rest is read through it, + // so it is the whole dependency. (No `react-hooks` plugin is configured in + // this repository, so there is no exhaustive-deps directive to disable.) + }, [signature]) + return +} + export const CatalogDrawing = ({ tool, assembly = null, @@ -171,7 +301,7 @@ export const CatalogDrawing = ({ highlight = null, onDimensionHover, measured = true, - materialRoom = MATERIAL_ROOM, + zoom = 'assembly', }: CatalogDrawingProps) => { const [theme] = useTheme() const format = (millimetres: number) => formatLength(millimetres, unit) @@ -205,7 +335,7 @@ export const CatalogDrawing = ({ : null const overlaid = profile !== null && gaps !== null && outline !== null - const padding: Partial = overlaid ? { plus: materialRoom } : {} + const padding: Partial = overlaid ? { plus: MATERIAL_ROOM } : {} return ( {overlaid && profile !== null && gaps !== null && outline !== null ? ( - + <> + + {import.meta.env.DEV && curve !== null ? ( + + ) : null} + ) : null} ) diff --git a/apps/catalog/app/components/tool-details.test.tsx b/apps/catalog/app/components/tool-details.test.tsx index f2eafae..8f62e8a 100644 --- a/apps/catalog/app/components/tool-details.test.tsx +++ b/apps/catalog/app/components/tool-details.test.tsx @@ -1,7 +1,8 @@ import type { ReactElement } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { CatalogTool, Holder } from '@toolpath/catalog-data' +import { applyTheme } from 'shared/use-theme' import { ToolDetails } from './tool-details' const tool = { @@ -185,6 +186,24 @@ describe('pointing at a number', () => { }) }) +/** + * **The panel reads a tool; it does not assemble one** (2026-09-11). + * + * It carried a Holder dropdown and a Collet dropdown from 2026-08-31. The tool + * assembly tree took that job on 2026-09-08 and covered every state but one — + * a tool read with no feature selected — so the pair survived there, and the + * page had two ways to fill one slot with no rule saying which won. What used + * to be checked here was the dropdowns' own behaviour; what is checked here + * now is that they are not offered at all. + */ +describe('what the panel offers for a holder', () => { + it('offers no holder or collet control of its own', () => { + render() + + expect(screen.queryByRole('combobox', { name: 'Holder' })).toBeNull() + expect(screen.queryByRole('combobox', { name: 'Collet' })).toBeNull() + }) +}) /** * **The part is drawn beside the tool** (2026-09-03). * @@ -258,10 +277,13 @@ describe('the material around the feature', () => { 'Below holderLBH12.70 mm*', ) - // And with the sheet switched back to the bare tool, its own figure again. - fireEvent.click(screen.getByRole('button', { name: 'Tool' })) + // And it stays that figure under the zoom, which is the 2026-09-11 half of + // the same rule: the press over the sheet moves the frame, not the stack. + // The switch it replaced drew a bare tool, and the number went back to the + // tool's own 46 mm with it; there is no bare tool to go back to now. + fireEvent.click(screen.getByRole('button', { name: 'Zoom to tool' })) expect(screen.getByText('Below holder').closest('div')?.textContent).toBe( - 'Below holderLBH46.00 mm*', + 'Below holderLBH12.70 mm*', ) }) @@ -284,17 +306,145 @@ describe('the material around the feature', () => { }) /** - * The material is the feature's, not the holder's, so it stays on the sheet - * with the stack switched off — the gaps are then the bare cutter's, which - * is the honest answer rather than a blank flank. + * The material is the feature's, not the frame's, so it stays on the sheet + * with the drawing zoomed to the working end — which is the half of the + * stack the gaps are tightest against anyway. */ - it('keeps the material on the sheet with the drawing switched back to the tool', () => { + it('keeps the material on the sheet with the drawing zoomed to the tool', () => { const { container } = measured( , ) - fireEvent.click(screen.getByRole('button', { name: 'Tool' })) + fireEvent.click(screen.getByRole('button', { name: 'Zoom to tool' })) expect(container.querySelector('[data-part="material"]')).not.toBeNull() }) }) + +/** + * **The press over the sheet moves the frame, not the subject** (2026-09-11). + * + * It used to be a Tool / Tool + holder switch, which let the one picture on the + * page disagree with the tree about what was on the tool: the tree had a holder + * in its slot and the sheet drew a bare cutter. What a reader wanted from the + * *Tool* half was the working end drawn bigger, and `@toolpath/tool-drawing` + * frames that itself — so the holder stays on the sheet either way and the + * press chooses how much of it the sheet is cut to. + * + * Read off `data-zoom`, which the package sets only where the zoom actually + * took: a tool with no length to frame to is drawn whole, and a test pinned to + * the button's own label would pass on a sheet that never moved. + */ +describe('how much of the stack the sheet is framed to', () => { + const holder: Holder = { + guid: 'h-er16', + familyId: 'bt30', + brand: 'REGO-FIX', + vendor: 'REGO-FIX', + catalogNumber: 'BT 30 / ER 16 x 060', + materialNumber: null, + taper: 'BT30', + contact: null, + clamping: 'collet', + gaugeLength: 60, + colletSeries: 'ER16', + boreDiameter: null, + noseDiameter: 28, + noseLength: 8, + bodyDiameter: 42, + bodyLength: 3, + projection: 11.6, + flangeDiameter: 46, + colletProtrusion: 2, + productLink: null, + cadModelUrl: null, + provenance: {}, + } + const held = { holder, collet: null } + + /** The drawing, not the tool-type icon above it — both are ``. */ + const sheet = (container: HTMLElement) => container.querySelector('figure svg')! + + it('frames the whole stack until the press asks for the working end', () => { + const { container } = measured() + + expect(sheet(container).getAttribute('data-zoom')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Zoom to tool' })) + expect(sheet(container).getAttribute('data-zoom')).toBe('tool') + + fireEvent.click(screen.getByRole('button', { name: 'Zoom out' })) + expect(sheet(container).getAttribute('data-zoom')).toBeNull() + }) + + /** + * Zoomed or fitted, the holder the tree chose is on the sheet — the nose is + * the part the zoom deliberately keeps above the cut. This is the guard on + * the thing that came out: the panel could draw a bare cutter while the tree + * held a full stack. + */ + it('keeps the holder on the sheet at either frame', () => { + const { container } = measured() + + expect(container.querySelector('[data-part="nose"]')).not.toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Zoom to tool' })) + expect(container.querySelector('[data-part="nose"]')).not.toBeNull() + }) + + /** Nothing holding the cutter is nothing to cut the sheet against. */ + it('offers no press where the tree has chosen no holder', () => { + measured() + + expect(screen.queryByRole('button', { name: 'Zoom to tool' })).toBeNull() + }) +}) + +/** + * **The drawing is a sheet, not a white rectangle inset in a card** (Paul, + * 2026-09-11). The panel was a grey wash and the sheet is capped at 16 rem, so + * the wash filled whatever the panel had either side of the drawing and the + * figure read as a box rather than as paper. + * + * Read off the DOM in both directions rather than pinned to a colour: the two + * grounds are `@toolpath/tool-drawing`'s to state, and a hex written here would + * be a second copy of them that nothing keeps in step. What this holds is that + * they are the *same* colour — which a hard-coded white would satisfy in light + * and break in dark, where the package's ground is a step above the card + * rather than white. + */ +describe('the ground the panel is painted in', () => { + const ground = (container: HTMLElement) => ({ + panel: (container.firstElementChild as HTMLElement).style.background, + sheet: container.querySelector('figure')!.style.background, + }) + + afterEach(() => { + applyTheme(document.documentElement, 'dark') + }) + + for (const theme of ['dark', 'light'] as const) { + it(`matches the sheet the tool is drawn on in ${theme}`, () => { + applyTheme(document.documentElement, theme) + + const { panel, sheet } = ground( + measured().container, + ) + + expect(sheet).not.toBe('') + expect(panel).toBe(sheet) + }) + } + + /** And the two grounds are different colours, so neither test passes by accident. */ + it('turns over with the theme rather than stating one colour', () => { + applyTheme(document.documentElement, 'dark') + const dark = ground(measured().container).panel + + cleanup() + applyTheme(document.documentElement, 'light') + const light = ground(measured().container).panel + + expect(dark).not.toBe(light) + }) +}) diff --git a/apps/catalog/app/components/tool-details.tsx b/apps/catalog/app/components/tool-details.tsx index 0f575b7..3353555 100644 --- a/apps/catalog/app/components/tool-details.tsx +++ b/apps/catalog/app/components/tool-details.tsx @@ -1,6 +1,6 @@ import { useState, type ReactNode } from 'react' import { ArrowSquareOutIcon } from '@phosphor-icons/react' -import { Badge, Button, Combobox, Toggle, cn } from '@toolpath/ui' +import { Badge, Button, cn } from '@toolpath/ui' import { NO_MARGINS, type CatalogTool, @@ -9,33 +9,45 @@ import { type Margins, } from '@toolpath/catalog-data' import type { ReachCurve } from '@toolpath/part-contracts' -import { formatLength, type UnitSystem } from '@toolpath/tool-support' +import type { UnitSystem } from '@toolpath/tool-support' +import type { Zoom } from '@toolpath/tool-drawing' import { formatGeometry } from 'shared/geometry' import { getFamily } from 'shared/catalog' import { drawnAssembly } from 'shared/drawn-assembly' import { thresholdsFrom } from 'shared/holder-choice' import { ToolTypeIcon, formLabel } from './tool-icons' import { MeasurementIcon } from './feature-icons' -import { CatalogDrawing } from './catalog-drawing' -import { CatalogComboboxButton } from './catalog-combobox-button' +import { CatalogDrawing, useSheetGround } from './catalog-drawing' /** * The tool being read, beside the part. * - * Paul's panel (2026-08-31): the cutter drawn on its own, the numbers it is - * chosen on in a form somebody can read at a glance, and the two decisions - * that finish an assembly — a holder and a collet — asked here rather than - * only in the list. The vendor's page is a button at the top, because "where - * do I buy this" is asked of the thing on screen. + * Paul's panel (2026-08-31): the cutter drawn on its own and the numbers it + * is chosen on, in a form somebody can read at a glance. The vendor's page is + * a button at the top, because "where do I buy this" is asked of the thing on + * screen. + * + * **It reads a tool; it does not assemble one** (2026-09-11). It carried a + * holder dropdown and a collet dropdown from 2026-08-31, and the tool assembly + * tree took that job on 2026-09-08 — but the pair survived in the one state + * the tree does not cover, a panel with no feature selected, so the page still + * had two ways to fill a slot and no rule saying which won. The dropdowns are + * gone; `stack` is what this panel is told about a holder. * * This is the working panel, not a reference sheet: the tool's own page and * the standalone catalog browser were removed on 2026-09-03, so the panel * beside the part is the only place a tool is read. * - * **The drawing shows the tool, or the tool and what holds it** (Paul, - * 2026-09-01), and says so with a switch rather than by whether a holder - * happens to have been chosen. Either way it is dimensioned — the lengths and - * widths the vendor states, drawn on the tool the way a drawing states them. + * **The drawing shows the whole stack, and the switch over it is a zoom** + * (2026-09-11). It used to choose between the tool and the tool with its + * holder, which was a second answer to a question the tree already settles — + * a panel drawing the cutter alone beside a stack the tree had fully + * assembled. What a reader actually wanted from the *Tool* half was the + * working end drawn bigger, and `@toolpath/tool-drawing` now frames that + * itself: `zoom` cuts the sheet just above the holder nose rather than + * dropping the holder out of the picture. Either way it is dimensioned — the + * lengths and widths the vendor states, drawn on the tool the way a drawing + * states them. */ /** The numbers a tool is chosen on, in the order the question is asked. */ @@ -74,19 +86,6 @@ const KEY_LABELS: Record<(typeof KEY_CODES)[number], string> = { */ const UNLETTERED: ReadonlySet = new Set(['LD', 'NOF']) -/** - * Room for the material on this panel's sheet, in pixels. - * - * The drawing card's own figure is `MATERIAL_ROOM`, 240, which is right on a - * full-width `h-96` card and wrong here: this panel is a column beside the - * part, `minSize={280}` wide and around 400 tall, and the package caps every - * flank at 0.6 of the axis — so 240 was the whole allowance, taken from the - * assembly and from the dimension bands that share it (2026-09-03). About a - * third of the short axis leaves the tool the sheet and the material a band - * wide enough to read. - */ -const PANEL_MATERIAL_ROOM = 130 - export interface ToolDetailsProps { readonly tool: CatalogTool readonly unit: UnitSystem @@ -153,9 +152,9 @@ export interface ToolDetailsProps { * the details column should change when a different component is selected"). * Selecting a holder in the tree used to swap the whole panel for a second * drawing in a box of its own — which is why that one came out lying on its - * side, and why the Tool / Tool + holder switch vanished the moment somebody - * looked at a holder. The sheet above is the same sheet either way; this is - * the half that answers "which component am I reading". + * side, and why the zoom over the sheet vanished the moment somebody looked + * at a holder. The sheet above is the same sheet either way; this is the + * half that answers "which component am I reading". */ readonly details?: ReactNode } @@ -172,10 +171,11 @@ export const ToolDetails = ({ }: ToolDetailsProps) => { const family = getFamily(tool.familyId) /** - * Which of the two is drawn. Kept while the panel is up, so a shop reading - * cutters does not have to say so again on every tool it clicks. + * How much of the stack the sheet is framed to. Kept while the panel is up, + * so a shop reading cutters does not have to say so again on every tool it + * clicks. */ - const [view, setView] = useState<'tool' | 'stack'>('stack') + const [zoom, setZoom] = useState('assembly') /** * The number the reader is pointing at, by ISO 13399 code. * @@ -189,11 +189,16 @@ export const ToolDetails = ({ * about what is lit. */ const [pointed, setPointed] = useState(null) + /** The ground the panel is painted in: the drawing's own, whichever theme. */ + const ground = useSheetGround() /** - * The stack this panel is drawing, which is the tree's and nothing else's - * (Paul, 2026-09-10). The panel used to offer a holder and a collet of its - * own when no feature was open, so a stack could be assembled in two places - * — the tree, and a pair of dropdowns over a tool nobody had ordered. + * The stack this panel draws, and the only place it can come from. + * + * **The tree is what assembles a tool** (2026-09-11). This panel used to + * offer a holder and a collet of its own, which made it a second way to fill + * a slot the tree already owns — and the two could disagree, because the + * dropdowns wrote to `picked` and the tree wrote to the assembly. `stack` is + * now the whole answer: what the tree has put in the slots, or nothing. */ const chosen = { holderGuid: stack?.holder?.guid ?? null, @@ -224,18 +229,39 @@ export const ToolDetails = ({ thresholdsFrom(), holderChosen === undefined ? [] : [holderChosen], ) - /** Whether the sheet below is the stack rather than the bare tool. */ - const drawnAsStack = drawn.assembly !== null && view === 'stack' + /** + * Whether there is a stack to draw at all. + * + * **Not a choice any more** (2026-09-11). The panel drew the cutter alone + * whenever the switch said `tool`, which meant the one picture on the page + * could disagree with the tree about what was on the tool. The sheet is now + * whatever the tree assembled, and the press over it moves the frame rather + * than the subject. + */ + const drawnAsStack = drawn.assembly !== null return ( /* - **The panel is a grey wash, and everything on it floats** (Paul, - 2026-09-01): the head, the two selections, the sheet the tool is drawn - on and the numbers each sit on their own surface, in the tone the table's - rows are. The padding is also what keeps a square-cornered band out of - the card's rounded corner. + **The panel is the sheet the tool is drawn on** (Paul, 2026-09-11). It + was a grey wash, and everything on it floated in the tone the table's + rows are (Paul, 2026-09-01) — which left the drawing reading as a white + rectangle inset in a card rather than as a drawing, because the sheet is + capped at 16 rem and the wash filled whatever the panel had either side + of it. Flush with the sheet, there is no inset to read: the head and the + numbers still float, a shade off the ground rather than onto it. + + **The colour is the package's, not a zinc step** — `useSheetGround`, so + the two grounds turn over together. Dark is `#22252b`, a deliberate step + above the card, so a hard-coded white would have matched in one theme and + glared in the other. A runtime value, hence `style`. + + The padding is also what keeps a square-cornered band out of the card's + rounded corner. */ -
+
{/* **What it is, then who makes it** (Paul, 2026-09-01): the number a shop orders by, and under it the vendor, the family it belongs to and what @@ -315,24 +341,34 @@ export const ToolDetails = ({ leave. */}
+ {/* + **One press, and it says which way it goes** (2026-09-11) — the same + rule `NoColletToggle` follows: a toggle labelled with its own state + leaves the reader working out which of the two they are looking at + from the picture, which is the thing they were looking at the picture + to find out. So it names the frame it would move to. + + Drawn only where a holder is: the cut is the length of tool below the + holder, so with nothing holding the cutter the zoom has no nose face + to frame against and the sheet is already the tool. + */} {drawn.holder === null ? null : (
- { - if (next === 'tool' || next === 'stack') { - setView(next) - } - }} +
)} {/* @@ -360,29 +396,44 @@ export const ToolDetails = ({ the sheet, all lay the tool on its side; the first fix only gave the box a definite height, which left the ratio to chance. - **A capped width and a floor under the height**, rather than an - aspect ratio off `h-full`. That version was right two runs in three - and wrong in the other: `h-full` resolves against a parent whose own - height is not definite on the first layout pass, so `aspect-ratio` - derived the height from the width instead and the box came out - landscape — and the package reads the box once. 16 rem of width under - 18 rem of height cannot be landscape whatever the panel is doing, - because neither figure waits on a percentage to resolve. A taller - panel only makes it more portrait. + **The height, and a width derived from it** (Paul, 2026-09-11: "be + more aggressive about adjusting the viewer panel to match the + available space"). It was a flat 16 rem cap on the width, which is + upright at any panel size and throttles the drawing at most of them: + the scale is the smaller of the two ratios that fit, so on a panel + taller than it is wide the *width* is what binds — 16 rem of sheet + under 56 rem of panel drew a BT40 stack a third of the height it had + room for, with the rest of the sheet empty above and below it. + + So the box fills the height it is given and takes its width from + that: `width = height × 3/4`, clamped by the panel it is in. A box + whose width is three quarters of its own height cannot be landscape + whichever of the two the browser settles first, which is what the + 16 rem cap was buying — and on a panel narrower than that it is the + `max-w-full` that binds, so the sheet takes the whole column. + + The floor under the height is what keeps `w-auto` from collapsing: + the width is derived from the used height, so a height of nothing + would be a width of nothing. And the package re-measures — it watches + its own `` with a `ResizeObserver` as of 0.3.1 — so a box that + settles a frame late is corrected rather than fixed wrong, which is + what the first aspect-ratio attempt could not rely on. */} -
-
+ {/* Named so a test can measure the sheet against the room it was + given — the rule is a ratio, and a ratio needs both numbers. */} +
+
diff --git a/apps/catalog/app/kit-usage.test.ts b/apps/catalog/app/kit-usage.test.ts index 50ef015..5f92d86 100644 --- a/apps/catalog/app/kit-usage.test.ts +++ b/apps/catalog/app/kit-usage.test.ts @@ -15,20 +15,22 @@ const componentFiles = (dir: string): Array => /** * The file with its comments taken out. * - * A sensor that reads source rather than exercising it counts whatever the - * regex sees, and on 2026-09-09 that was five `