From fca8e44b787674bc591794cb4d0a6b1e91a606e5 Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Thu, 10 Sep 2026 15:02:48 -0400 Subject: [PATCH 01/12] fix for compatible vendor tool count not showing in filters --- AGENTS.md | 1 + .../app/client/catalog-matcher.worker.ts | 40 +++++++- apps/catalog/app/components/column-filter.tsx | 3 +- apps/catalog/app/components/filter-panel.tsx | 16 ---- apps/catalog/app/routes/part.tsx | 63 +++++++++---- .../app/shared/catalog-matcher.test.ts | 68 ++++++++++++++ apps/catalog/app/shared/catalog-matcher.ts | 91 ++++++++++++++++++- apps/catalog/app/shared/filter.ts | 37 +++++++- apps/catalog/tests/on-the-part.spec.ts | 53 ++++++++++- docs/CATALOG-SPEC.md | 32 +++++-- 10 files changed, 353 insertions(+), 51 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 77d602a..51edeb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,6 +281,7 @@ application unless that application says otherwise. | 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 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` | diff --git a/apps/catalog/app/client/catalog-matcher.worker.ts b/apps/catalog/app/client/catalog-matcher.worker.ts index a267190..a7d487d 100644 --- a/apps/catalog/app/client/catalog-matcher.worker.ts +++ b/apps/catalog/app/client/catalog-matcher.worker.ts @@ -1,6 +1,8 @@ +import type { CatalogTool } from '@toolpath/catalog-data' import { collets, holders, allTools } from '../shared/catalog' import { detailedMatch, + facetPool, matchKey, prepareMatch, recommendationMatch, @@ -10,6 +12,7 @@ import { type MatchResponse, type RecommendationResult, } from '../shared/catalog-matcher' +import { facetsNarrowing, withoutFacets } from '../shared/filter' const catalog = { tools: allTools, holders, collets } @@ -44,6 +47,18 @@ class Lru { // and recur for every feature and one-each draft, so they get the larger cache. const tables = new Lru>(4) const recommendations = new Lru(256) +/** + * The widened pools the facet counts are measured over, by the question with + * every facet cleared. + * + * **A tick must not cost a judging pass.** The counts beside the Vendor + * checkboxes are measured over the question *without* the vendor, so that pool + * is identical for every vendor, every family and every type ticked on the same + * feature — which is exactly the sequence somebody makes while narrowing. Keyed + * on the facet-free question so the cache hits across all of them, and never + * sent anywhere: only the counts cross the boundary. + */ +const pools = new Lru>(4) /** * The part this worker is answering about, kept between requests. @@ -56,6 +71,22 @@ const recommendations = new Lru(256) */ let held: { readonly key: string; readonly features: MatchContext['features'] } | null = null +/** The widened pool for one demand, judged once and kept for the next tick. */ +const poolFor = ( + context: MatchContext, + demand: MatchRequest['demands'][number], +): ReadonlyArray => { + const widened = { ...context, query: withoutFacets(context.query) } + const key = matchKey('table', widened, [demand]) + const cached = pools.get(key) + if (cached !== undefined) { + return cached + } + const built = facetPool(context, demand, catalog) + pools.set(key, built) + return built +} + self.onmessage = (event: MessageEvent) => { const incoming = event.data if (incoming.context.features.length > 0) { @@ -86,9 +117,12 @@ self.onmessage = (event: MessageEvent) => { cached ?? (() => { const prepared = prepareMatch(request.context, catalog) - const matched = request.demands.map((demand) => - detailedMatch(request.context, demand, catalog, prepared), - ) + const matched = request.demands.map((demand) => { + const pool = facetsNarrowing(request.context.query) + ? poolFor(request.context, demand) + : undefined + return detailedMatch(request.context, demand, catalog, prepared, pool) + }) tables.set(request.key, matched) return matched })() diff --git a/apps/catalog/app/components/column-filter.tsx b/apps/catalog/app/components/column-filter.tsx index dc4b138..f4a868e 100644 --- a/apps/catalog/app/components/column-filter.tsx +++ b/apps/catalog/app/components/column-filter.tsx @@ -971,6 +971,7 @@ export const TermFilter = ({ return (
{option.label} - + {option.count}
diff --git a/apps/catalog/app/components/filter-panel.tsx b/apps/catalog/app/components/filter-panel.tsx index 9bccc18..9aff38c 100644 --- a/apps/catalog/app/components/filter-panel.tsx +++ b/apps/catalog/app/components/filter-panel.tsx @@ -164,22 +164,6 @@ const Monogram = ({ brand }: { brand: string }) => ( * it is held. The two ranges the rules fill in from the feature come last, * because they are usually already answered by the time anybody looks. */ -/** - * The axes whose options are narrowed by the rest of the query. - * - * The term axes that are properties of a tool, which is what a facet count can - * be measured over. The holding axes — a spindle taper, a collet series — are - * properties of the crib and are counted elsewhere (Paul, 2026-09-01). - */ -export const FACET_AXES: ReadonlyArray = [ - 'brand', - // The two phrases this catalog builds rather than facets a vendor publishes: - // the type with its shank in it, and the family with its product line. - 'type', - 'family', - 'materialGroups', - 'NOF', -] export const QUICK_FILTERS: ReadonlyArray = [ { diff --git a/apps/catalog/app/routes/part.tsx b/apps/catalog/app/routes/part.tsx index 24ad498..1b5986b 100644 --- a/apps/catalog/app/routes/part.tsx +++ b/apps/catalog/app/routes/part.tsx @@ -100,7 +100,7 @@ import { type Holding, } from 'components/part-tool-table' import { ColumnPicker, sameBound } from 'components/column-filter' -import { BUTTON_FILTERS, FACET_AXES } from 'components/filter-panel' +import { BUTTON_FILTERS } from 'components/filter-panel' import { orderedCodes } from 'shared/column-order' import { hiddenAfterAuto } from 'shared/auto-columns' import { capRows, firstBy, keptFirst, oneEach } from 'shared/tool-order' @@ -177,6 +177,7 @@ import { ComponentTable } from 'components/component-table' import { CLAMPING_KNOB, withClampingLength, type ClampingRule } from 'shared/clamping-length' import { EMPTY_QUERY, + FACET_AXES, countBy, countsByAxis, stillOffered, @@ -1897,6 +1898,28 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: // every drill on the sheet's "closest to the hole diameter" row. return drillsFirst(shownTools.filter((each) => predrillForms.includes(each.form))) }, [asking, catalogList, tools, closest, overrideTools, drillsOnly, predrillForms]) + /** + * The counts the **matcher** measured, where a facet is narrowing the list. + * + * `shared/catalog-matcher.ts` § `facetCounts` is the whole of it: while a + * vendor is chosen, no other vendor's tools have been judged for this + * feature, so the rows on screen cannot say what a second vendor would bring + * — they said nought, and Paul could see on the part that it was not nought + * (2026-09-10). The worker judges the question with the facets cleared and + * sends the counts back, and they are the answer whenever it has one. + */ + const judgedFacets = useMemo( + () => + detailed?.facetCounts == null + ? null + : new Map>( + Object.entries(detailed.facetCounts).map(([axis, values]) => [ + axis, + new Map(Object.entries(values)), + ]), + ), + [detailed], + ) /** * What each axis would leave, counted against every other filter. * @@ -1905,31 +1928,33 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: * the axis's own term, so choosing one vendor does not hide the rest * (Paul, 2026-09-01). * - * **Over the rows the list is actually holding**, which is what "offer what - * is there" means. A feature's list has already been answered against the - * query — the matcher applied it — so the query is not applied a second time - * here: the nearest misses stand in when nothing fits, and they are outside - * the query by construction, which counted four rows on screen as nothing to - * narrow by. Without a feature the pool is the whole catalog, so there the - * query is what makes the counts mean anything. + * With a feature and nothing the worker could widen — no facet narrowing yet, + * or a question nothing fits at all — it falls back to **the rows the list is + * actually holding**, which is what "offer what is there" means. The list has + * already been answered against the query, so the query is not applied a + * second time there: the nearest misses stand in when nothing fits, and they + * are outside the query by construction, which counted four rows on screen as + * nothing to narrow by. Without a feature the pool is the whole catalog, so + * there the query is what makes the counts mean anything. */ const axisCounts = useMemo( () => asking - ? countsByAxis(listed, EMPTY_QUERY, FACET_AXES) + ? (judgedFacets ?? countsByAxis(listed, EMPTY_QUERY, FACET_AXES)) : countsByAxis(allTools, effectiveQuery, FACET_AXES), - [asking, allTools, listed, effectiveQuery], + [asking, allTools, listed, effectiveQuery, judgedFacets], ) /** * What each axis is offering, which is not always what it can still count. * - * `shared/filter.ts` § `stillOffered` has the reason: with a feature on the - * screen the counts are measured over what the matcher judged, and the - * matcher only judges what the terms already admit — so an axis that has been - * narrowed can only report itself, and a second vendor was unreachable. It - * keeps offering the list it last had instead. The memory is this feature's: - * another question is another set of values, and carrying one over would - * offer the last feature's vendors under this one's name. + * `shared/filter.ts` § `stillOffered` has the reason, and {@link judgedFacets} + * is why it is not always needed: a count the worker measured over the + * widened pool already holds every other vendor, so the memory would only put + * a stale number beside a fresh one. It stands for the answers that still + * come off the rows — a list the worker has not answered yet, and the near + * misses standing in when nothing fits. The memory is this feature's: another + * question is another set of values, and carrying one over would offer the + * last feature's vendors under this one's name. */ const offeredAxes = useRef(new Map>()) const askedOf = useRef(null) @@ -1943,14 +1968,14 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: const counts = axisCounts.get(axis) ?? new Map() offered.set( axis, - asking + asking && judgedFacets === null ? stillOffered(counts, query.terms[axis] ?? [], offeredAxes.current.get(axis)) : counts, ) } offeredAxes.current = offered return offered - }, [asking, axisCounts, query.terms, ask]) + }, [asking, axisCounts, query.terms, ask, judgedFacets]) /** One axis's values as the pickers read them, however they were arrived at. */ const countsOn = useCallback( diff --git a/apps/catalog/app/shared/catalog-matcher.test.ts b/apps/catalog/app/shared/catalog-matcher.test.ts index 56a6807..2552856 100644 --- a/apps/catalog/app/shared/catalog-matcher.test.ts +++ b/apps/catalog/app/shared/catalog-matcher.test.ts @@ -4,6 +4,7 @@ import type { PartFeature } from '@toolpath/part-contracts' import { SHEET_CLAMPING, withClampingLength } from './clamping-length' import { detailedMatch, + facetPool, featuresKey, matchKey, prepareMatch, @@ -444,6 +445,73 @@ describe('catalog matcher protocol', () => { }) }) +/** + * **An axis must never count itself** (Paul, 2026-09-10: filter to Kennametal, + * open the Vendor menu again, and every other vendor reads nought — "they have + * compatible tools", and ticking one proves it). + * + * With a feature on the screen the rows are what the matcher judged, and the + * matcher only judges what the terms already admit, so no other vendor's tools + * had ever been put to the rules. The count beside a checkbox is a question + * about every filter **but** that checkbox's own axis, which is why it is + * answered here — beside the pipeline that is the only thing able to answer it + * — rather than by counting the rows on screen. + */ +describe('the counts an axis offers while it is narrowing', () => { + const branded = (guid: string, brand: string, DC: number): CatalogTool => + ({ ...tool(guid, DC), brand, vendor: brand }) as CatalogTool + + const crib = { + tools: [ + branded('k-1', 'Kennametal', 6), + branded('w-1', 'WIDIA', 6), + branded('w-2', 'WIDIA', 7), + ], + holders: [], + collets: [], + } + const feature = pocket('pocket-1') + const demand = { demandKey: 'one', tags: [feature.featureTag] } + const asked = (terms: Record>): MatchContext => ({ + ...context([feature]), + query: { ...EMPTY_QUERY, terms }, + }) + + it('says nothing while no facet is narrowing, because the rows are the answer', () => { + expect(detailedMatch(asked({}), demand, crib).facetCounts).toBeNull() + }) + + it('counts the vendors a chosen vendor hid, over tools the rules actually admit', () => { + const result = detailedMatch(asked({ brand: ['Kennametal'] }), demand, crib) + + expect(result.heldGuids).toEqual(['k-1']) + expect(result.facetCounts?.brand).toEqual({ Kennametal: 1, WIDIA: 2 }) + }) + + it('measures one axis against every other filter that is set', () => { + const result = detailedMatch( + asked({ brand: ['Kennametal'], type: ['Flat end mill'] }), + demand, + crib, + ) + + // The vendor count still answers "what would WIDIA bring" *with* the type + // narrowing standing, and the type count answers it with the vendor's. + expect(result.facetCounts?.brand).toEqual({ Kennametal: 1, WIDIA: 2 }) + expect(result.facetCounts?.type).toEqual({ 'Flat end mill': 1 }) + }) + + it("leaves a caller's widened pool alone rather than judging a second time", () => { + const pool = facetPool(asked({ brand: ['Kennametal'] }), demand, crib) + + expect([...pool.map((each) => each.guid)].sort()).toEqual(['k-1', 'w-1', 'w-2']) + expect( + detailedMatch(asked({ brand: ['Kennametal'] }), demand, crib, undefined, pool).facetCounts + ?.brand, + ).toEqual({ Kennametal: 1, WIDIA: 2 }) + }) +}) + /** * **The report crosses the worker boundary once** (Paul, 2026-09-07: "it still * lags quite a bit when I finish with one feature then go to select another … diff --git a/apps/catalog/app/shared/catalog-matcher.ts b/apps/catalog/app/shared/catalog-matcher.ts index 6181496..755d054 100644 --- a/apps/catalog/app/shared/catalog-matcher.ts +++ b/apps/catalog/app/shared/catalog-matcher.ts @@ -2,7 +2,15 @@ import type { CatalogTool, Collet, Holder, HolderFilters, Margins } from '@toolp import type { PartFeature } from '@toolpath/part-contracts' import { formatLength, type UnitSystem } from '@toolpath/tool-support' import { withClampingLength, type ClampingRule } from './clamping-length' -import { filterTools, type ToolQuery } from './filter' +import { + countBy, + facetsNarrowing, + filterTools, + FACET_AXES, + withoutFacets, + withoutTerm, + type ToolQuery, +} from './filter' import { holdable, policyOf, type HoldThresholds } from './holder-choice' import { holdableTools, splitHolding } from './holding' import { closestMisses, closestPerForm, type Format, type Reason, type Verdict } from './judge' @@ -152,6 +160,30 @@ export interface DetailedResult { readonly ruleTally: Readonly> readonly narrowedGuids: ReadonlyArray readonly heldGuids: ReadonlyArray + /** + * What each facet axis would hold if that axis alone were cleared, by value. + * + * **A count measured over the list is the axis counting itself** (Paul, + * 2026-09-10: picking Kennametal and then opening Vendor again shows every + * other vendor at nought, "but they have compatible tools"). With a feature + * on the screen the rows are what the matcher judged, and the matcher only + * judges what the terms already admit — so the moment a vendor is chosen, no + * other vendor's tools have been judged for that feature and there is + * nothing to count. The panel's own rule since 2026-09-01 is that a count is + * measured against every filter **but** the axis's own, and this is the only + * place both halves of that exist: the answer for the list, and the pool the + * other values would come from. + * + * `null` when no facet axis is narrowing, because then the rows already are + * the whole answer and counting them a second time is work for nothing — and + * `null` again when the widened pool holds nothing either, for the reason + * given where it is built. + * + * A plain record rather than a `Map` so it survives the worker boundary, and + * counts rather than tools so what crosses it is a few hundred numbers rather + * than the widened pool itself. + */ + readonly facetCounts: Readonly>>> | null } export interface RecommendationResult { @@ -548,14 +580,64 @@ const nearestFew = ( ] } +/** + * The tools a facet count is measured over: the same question with every facet + * axis cleared, judged and held. + * + * **Judged, not merely filtered.** A vendor's tools that were never put to the + * rules cannot be counted, and the rules are what the list is; taking the + * catalog's own count instead would offer a vendor 900 tools and then show + * three. So this is the whole pipeline again over the widened pool — measured + * on the scraped catalog at ~135 ms against ~50 ms for a narrowed one, in a + * worker, and only while a facet is actually narrowing. + * + * Everything that is not a facet is left standing: the geometry's forms, the + * ranges, the crib's taper and collet series. Widening those would count tools + * the question is not about. + */ +export const facetPool = ( + context: MatchContext, + demand: MatchDemand, + catalog: MatcherCatalog, +): ReadonlyArray => { + const widened = { ...context, query: withoutFacets(context.query) } + return matchDemand(widened, demand, catalog, prepareMatch(widened, catalog)).held.map( + (verdict) => verdict.tool, + ) +} + +/** One pool read per axis, each against every filter but that axis's own. */ +const countsOverPool = ( + pool: ReadonlyArray, + query: ToolQuery, +): Record> => { + const counts: Record> = {} + for (const axis of FACET_AXES) { + counts[axis] = Object.fromEntries(countBy(filterTools(pool, withoutTerm(query, axis)), axis)) + } + return counts +} + /** Runs the existing detailed table pipeline with only cloneable request inputs. */ export const detailedMatch = ( context: MatchContext, demand: MatchDemand, catalog: MatcherCatalog, prepared: PreparedMatch = prepareMatch(context, catalog), + /** + * The widened pool, where a caller already holds one. + * + * The worker keeps it across every tick of a facet, because it is the one + * thing on this page that does not change when one is ticked — the pool is + * the question with the facets *cleared*. Passing it in is what keeps a + * second vendor from costing a second judging pass. + */ + pool?: ReadonlyArray, ): DetailedResult => { const matched = matchDemand(context, demand, catalog, prepared) + const widened = !facetsNarrowing(context.query) + ? null + : (pool ?? facetPool(context, demand, catalog)) return { demandKey: demand.demandKey, fitting: matched.fitting.map(compact), @@ -577,6 +659,13 @@ export const detailedMatch = ( ruleTally: ruleTally(matched.excluded), narrowedGuids: matched.narrowed.map((verdict) => verdict.tool.guid), heldGuids: matched.held.map((verdict) => verdict.tool.guid), + /** + * Nothing held in the widened pool is nothing to say: the rows on screen + * are then the near misses standing in, and counting a value at nought + * against a list that is itself a stand-in would read as an answer. + */ + facetCounts: + widened === null || widened.length === 0 ? null : countsOverPool(widened, context.query), } } diff --git a/apps/catalog/app/shared/filter.ts b/apps/catalog/app/shared/filter.ts index db4600f..881c50b 100644 --- a/apps/catalog/app/shared/filter.ts +++ b/apps/catalog/app/shared/filter.ts @@ -382,6 +382,41 @@ export const prioritise = ( ) } +/** + * The axes whose options are narrowed by the rest of the query. + * + * The term axes that are properties of a tool, which is what a facet count can + * be measured over. The holding axes — a spindle taper, a collet series — are + * properties of the crib and are counted elsewhere (Paul, 2026-09-01). + * + * Here rather than beside the panel that draws them because the **matcher** + * has to know them too: a facet count with a feature on screen is measured + * over a pool judged without these terms, and `shared/` may not import + * `components/`. + */ +export const FACET_AXES: ReadonlyArray = [ + 'brand', + // The two phrases this catalog builds rather than facets a vendor publishes: + // the type with its shank in it, and the family with its product line. + 'type', + 'family', + 'materialGroups', + 'NOF', +] + +/** Whether any facet axis is narrowing, which is when a count needs widening. */ +export const facetsNarrowing = (query: ToolQuery): boolean => + FACET_AXES.some((axis) => (query.terms[axis]?.length ?? 0) > 0) + +/** The same query with every facet axis taken out — the pool a count is measured over. */ +export const withoutFacets = (query: ToolQuery): ToolQuery => { + const terms = { ...query.terms } + for (const axis of FACET_AXES) { + delete terms[axis] + } + return { ...query, terms } +} + /** * The same query with one axis taken out. * @@ -390,7 +425,7 @@ export const prioritise = ( * one vendor would report every other as zero and there would be no way to add * a second. */ -const withoutTerm = (query: ToolQuery, key: string): ToolQuery => { +export const withoutTerm = (query: ToolQuery, key: string): ToolQuery => { const terms = { ...query.terms } delete terms[key] return { ...query, terms } diff --git a/apps/catalog/tests/on-the-part.spec.ts b/apps/catalog/tests/on-the-part.spec.ts index d80cf3b..8d2a551 100644 --- a/apps/catalog/tests/on-the-part.spec.ts +++ b/apps/catalog/tests/on-the-part.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Locator, type Page } from '@playwright/test' -import { onThePart, openCube, orderList } from './cube-fixture' +import { onThePart, openCube, openCubeWithHole, orderList } from './cube-fixture' /** * What a click on the part means. @@ -2212,6 +2212,57 @@ test.describe('the tool assembly tree', () => { await expect(picker.getByRole('checkbox', { name: before[1]!, exact: true })).toBeChecked() }) + /** + * **And it says what the second value would bring** (Paul, 2026-09-10: pick a + * vendor, open the vendor picker again, and every other vendor reads nought — + * "they have compatible tools", which ticking one proves on the spot). + * + * Offering the value and offering nought beside it are two different answers. + * The counts used to be measured over the rows on screen, and the rows are + * what the matcher judged — so while a value was ticked, nothing else on that + * axis had ever been put to the rules and every one of them counted zero. The + * worker now judges the question with the facets cleared and sends the counts + * back (`shared/catalog-matcher.ts` § `facetCounts`), so a count beside an + * unticked value is what pressing it would actually bring. + */ + test('says what a second vendor would bring while one vendor is narrowing', async ({ page }) => { + /* + A ⌀6 blind hole, because the plain cube's faces answer "nothing fits" and + a count over a list of stand-ins is not the thing under test. This hole + the nine tools do answer, with both vendors and three types on it. + + **The vendor is chosen before the feature is**, which is Paul's own + sequence and the reason the picker's memory (`shared/filter.ts` § + `stillOffered`) could not cover this: the memory is the question's, so a + filter already standing when the question is asked has nothing behind it. + */ + await openCubeWithHole(page, { diameter: 6, depth: 8, query: '&brand=Kennametal' }) + await expect(page.locator('canvas')).toBeVisible() + await ready(page) + await keepFeature(page) + await page + .locator('[data-assembly-tree]') + .getByRole('button', { name: /^TOOL for / }) + .click() + + // The list is the one vendor's, which is what was asked for. + await expect(page.getByRole('grid').first().getByText('WIDIA')).toBeHidden() + + await page.getByRole('button', { name: 'Filter by Vendor', exact: true }).click() + const picker = page.getByRole('group', { name: 'Vendor' }) + const count = (vendor: string) => + picker.locator(`[data-term-option="${vendor}"] [data-term-count]`) + + // The other vendor is offered on the list itself rather than behind the `…` + // row, and it says how many tools pressing it would actually bring. + await expect(count('Kennametal')).toHaveText('1') + await expect(count('WIDIA')).toHaveText('2') + + // And the number is the truth: ticking it brings exactly those two. + await picker.getByRole('checkbox', { name: 'WIDIA', exact: true }).click() + await expect(page.getByRole('grid').first().getByText('WIDIA')).toHaveCount(2) + }) + /** * **Three buttons in the table's chrome, and the open one is lit** (Paul, * 2026-09-07: "I want the table tabs for tools, holders, and collets back, diff --git a/docs/CATALOG-SPEC.md b/docs/CATALOG-SPEC.md index 6a225f2..421b2ea 100644 --- a/docs/CATALOG-SPEC.md +++ b/docs/CATALOG-SPEC.md @@ -343,16 +343,28 @@ always somebody's preference, and always visible and reversible. `components/column-filter.tsx` § `optionsMatching` is the rule and `components/column-filter.test.tsx` the sensor. The catalog number stays a plain text box: it narrows the rows, not a list of options. -- **An axis that has been narrowed keeps offering what it last offered.** With a - feature on screen the counts are measured over the rows the matcher answered +- **An axis that has been narrowed is counted over a pool judged without it** + (2026-09-10). With a feature on screen the rows are what the matcher answered with, and the matcher only judges what the terms already admit — so the moment one vendor is ticked, no other vendor's tools have been judged for that - feature and the axis can only report itself. `shared/filter.ts` § - `stillOffered` remembers the list from the last moment the question could be - answered, so a second value is one press away; clearing the axis asks it - again. The counts themselves are over **the rows the list is holding**, near - misses included — a picker saying "nothing to narrow by" over four rows on - screen was the same rule broken at the other end. + feature and counting the rows reports every other vendor at nought. Which is + what it did, over a list of vendors that plainly had tools for the job (Paul, + 2026-09-10: filter to Kennametal, drop the family filter, and every other + vendor reads zero until you tick one). So the worker judges the same question + a second time with **every facet cleared** and sends the counts back — + `shared/catalog-matcher.ts` § `facetCounts`, measured per axis against every + filter but that axis's own. It is the whole pipeline again, ~135 ms against + ~50 ms on the scraped catalog, so it runs only while a facet is actually + narrowing and the widened pool is kept across every tick of one: the pool is + the question with the facets _off_, which is the one thing ticking a facet + does not change. +- **What is left when the worker cannot answer.** No facet narrowing yet, or a + question nothing fits at all, and the counts are over **the rows the list is + holding**, near misses included — a picker saying "nothing to narrow by" over + four rows on screen was the same rule broken at the other end. `shared/filter.ts` + § `stillOffered` still carries that half: it remembers the list from the last + moment the question could be answered, so a second value stays one press away + while nothing has been judged for it. > **Open questions** > @@ -367,7 +379,9 @@ always somebody's preference, and always visible and reversible. **Where it lives** - `app/shared/filter.ts` — the query, what it matches, and the per-axis counts -- `app/components/filter-panel.tsx` — `QUICK_FILTERS` and `FACET_AXES` — every axis and its values +- `app/shared/filter.ts` — `FACET_AXES`, the axes a count is measured over +- `app/shared/catalog-matcher.ts` — `facetPool` / `facetCounts`, the counts with a feature on screen +- `app/components/filter-panel.tsx` — `QUICK_FILTERS` — every axis and its values - `app/shared/column-filters.ts` — which column asks which filter, and what is parked - `app/shared/tool-type.ts` — what a tool is in one phrase, shank and all - `app/components/column-filter.tsx` — the header filters, their menu, and the compare operators From f530064b3bb742f1475aa4f121eba96af05915a0 Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Thu, 10 Sep 2026 15:29:00 -0400 Subject: [PATCH 02/12] flute count filtering fix, removed holder and collet columns from tool table and holder/collet selection drop downs from tool viewer --- AGENTS.md | 1 + .../app/client/catalog-matcher.test.ts | 1 + apps/catalog/app/components/column-filter.tsx | 12 +- .../app/components/part-tool-table.test.tsx | 40 +-- .../app/components/part-tool-table.tsx | 170 +----------- .../app/components/tool-columns.test.ts | 35 ++- .../app/components/tool-details.test.tsx | 150 +++-------- apps/catalog/app/components/tool-details.tsx | 143 ++-------- apps/catalog/app/routes/part.tsx | 246 +++--------------- .../app/shared/catalog-matcher.test.ts | 51 ++++ apps/catalog/app/shared/catalog-matcher.ts | 57 +++- .../catalog/app/shared/column-filters.test.ts | 6 - apps/catalog/app/shared/column-filters.ts | 14 +- apps/catalog/app/shared/filter.test.ts | 35 +++ apps/catalog/app/shared/filter.ts | 62 ++++- apps/catalog/app/shared/tool-fit.test.ts | 56 ++++ apps/catalog/app/shared/tool-fit.ts | 10 +- apps/catalog/tests/on-the-part.spec.ts | 46 ++++ docs/CATALOG-SPEC.md | 8 +- docs/TOOL-ASSEMBLY-TREE.md | 34 ++- 20 files changed, 464 insertions(+), 713 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 51edeb6..19d4d45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,6 +281,7 @@ application unless that application says otherwise. | 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` | +| which bounds are somebody's own, not the geometry's | `ownBounds`, `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` | diff --git a/apps/catalog/app/client/catalog-matcher.test.ts b/apps/catalog/app/client/catalog-matcher.test.ts index f580eef..0677689 100644 --- a/apps/catalog/app/client/catalog-matcher.test.ts +++ b/apps/catalog/app/client/catalog-matcher.test.ts @@ -33,6 +33,7 @@ const context: MatchContext = { margins: { radial: 0, axial: 0 }, thresholds: thresholdsFrom(), overrides: [], + ownRanges: {}, } afterEach(() => { diff --git a/apps/catalog/app/components/column-filter.tsx b/apps/catalog/app/components/column-filter.tsx index f4a868e..e94a935 100644 --- a/apps/catalog/app/components/column-filter.tsx +++ b/apps/catalog/app/components/column-filter.tsx @@ -16,6 +16,7 @@ import { decimalsFor, } from '@toolpath/tool-support' import { movedBy, movedTo } from 'shared/column-order' +import { sameBound } from 'shared/filter' import { LAYER_COLUMN_FILTER, useEscape, useKeyLayer } from 'shared/use-escape' import { CatalogComboboxButton } from './catalog-combobox-button' @@ -96,17 +97,6 @@ const says = (draft: string, value: number | undefined, unit: UnitSystem, kind: return Math.abs(meant - value) < 1e-9 } -const sameEnd = (a: number | undefined, b: number | undefined): boolean => - a === undefined || b === undefined ? a === b : Math.abs(a - b) < 1e-9 - -/** Whether two bounds ask the same thing, allowing for a float's last digit. */ -export const sameBound = (a: Bound | undefined, b: Bound | undefined): boolean => { - if (a === undefined || b === undefined) { - return a === b - } - return sameEnd(a.min, b.min) && sameEnd(a.max, b.max) -} - /** The bound an operator and one or two numbers add up to. */ export const boundFor = ( compare: Compare, diff --git a/apps/catalog/app/components/part-tool-table.test.tsx b/apps/catalog/app/components/part-tool-table.test.tsx index 615df98..aa0021b 100644 --- a/apps/catalog/app/components/part-tool-table.test.tsx +++ b/apps/catalog/app/components/part-tool-table.test.tsx @@ -119,22 +119,19 @@ describe('PartToolTable', () => { expect(choices).toHaveBeenCalledTimes(1) }) - it('does not select a row while changing its holder', async () => { - const onChoose = show({ - holding: { - holdersFor: () => [{ guid: 'holder', label: 'BT30', trouble: null, holder: {} as never }], - colletsFor: () => [], - chosen: () => ({ holderGuid: null, colletGuid: null }), - requiredStickout: () => null, - stickoutFor: () => null, - onChoose: vi.fn(), - }, - }) - - fireEvent.click(screen.getAllByRole('combobox', { name: 'Holder for T-20' })[0]!) - fireEvent.click(screen.getByRole('option', { name: 'BT30' })) + /** + * **A row holds no holder** (Paul, 2026-09-10). The list used to draw a + * holder and a collet dropdown per row; the assembly tree took that decision + * off the row, the page stopped handing the list any holding, and the two + * columns became dashes over a choice made elsewhere. They are gone, and a + * stored column order naming them cannot bring them back. + */ + it('draws no holder or collet column, whatever the order says', () => { + show({ hiddenColumns: [], columnOrder: ['DC', 'holder', 'collet'], columns: TOOL_COLUMNS }) - expect(onChoose).not.toHaveBeenCalled() + expect(screen.queryByRole('columnheader', { name: /Holder/ })).not.toBeInTheDocument() + expect(screen.queryByRole('columnheader', { name: /Collet/ })).not.toBeInTheDocument() + expect(screen.queryByRole('combobox', { name: /Holder for/ })).not.toBeInTheDocument() }) /** @@ -320,19 +317,6 @@ describe('the filters a heading asks', () => { expect(within(type).getByRole('checkbox', { name: /Flat end mill/ })).toBeInTheDocument() }) - /** The two cells that set a choice rather than hold a value ask nothing. */ - it('leaves the holder and collet cells alone', () => { - show({ - filtering: filtering(), - hiddenColumns: [], - columnOrder: ['DC', 'holder', 'collet'], - columns: TOOL_COLUMNS, - }) - - expect(screen.queryByRole('button', { name: 'Filter by Holder' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Filter by Collet' })).not.toBeInTheDocument() - }) - /** * A tap list is swept out of the catalog by its thread, so the tool filters * never reach it. It narrows on its catalog number, and its other headings diff --git a/apps/catalog/app/components/part-tool-table.tsx b/apps/catalog/app/components/part-tool-table.tsx index c6ec933..628f1eb 100644 --- a/apps/catalog/app/components/part-tool-table.tsx +++ b/apps/catalog/app/components/part-tool-table.tsx @@ -8,8 +8,8 @@ import { type ReactNode, } from 'react' import { ArrowSquareOutIcon, CheckIcon, InfoIcon, XCircleIcon } from '@phosphor-icons/react' -import { Button, Combobox, Table, cn } from '@toolpath/ui' -import type { CatalogTool, Holder } from '@toolpath/catalog-data' +import { Button, Table, cn } from '@toolpath/ui' +import type { CatalogTool } from '@toolpath/catalog-data' import type { UnitSystem } from '@toolpath/tool-support' import { formatGeometry } from 'shared/geometry' import { askOfToolColumn, type ColumnAsk } from 'shared/column-filters' @@ -27,7 +27,6 @@ import { type ColumnHeadingProps, } from './column-heading' import type { Bound, ColumnOverride } from './column-filter' -import { CatalogComboboxButton } from './catalog-combobox-button' export interface PartToolColumn { readonly code: string @@ -68,9 +67,11 @@ const IDENTITY: ReadonlyArray = [ * brings the angle — `shared/auto-columns.ts` is the rule. They are `false` * here because that is what a fresh list, holding neither, shows. * - * The holder and the collet stay off until somebody asks for them, and sit at - * the end so that turning one on adds a column rather than moving every other - * one along. + * **The holder and the collet are not columns** (Paul, 2026-09-10). They were + * the per-row dropdowns from before the assembly tree, and the tree took that + * choice off the row: the page hands the list no holding at all, so ticking + * either in the picker drew a column of dashes over a decision made in the + * tree beside it. */ export const TOOL_COLUMNS: ReadonlyArray = [ ...IDENTITY, @@ -83,15 +84,11 @@ export const TOOL_COLUMNS: ReadonlyArray = [ { code: 'RE', label: 'Corner radius', default: false }, { code: 'SFDM', label: 'Shank', default: false }, { code: 'SIG', label: 'Tip angle', default: false }, - { code: 'holder', label: 'Holder', default: false }, - { code: 'collet', label: 'Collet', default: false }, ] export const TAP_COLUMNS: ReadonlyArray = [ ...IDENTITY, { code: 'DC', label: 'Thread diameter', default: true }, - { code: 'holder', label: 'Holder', default: false }, - { code: 'collet', label: 'Collet', default: false }, { code: 'LCF', label: 'Thread length', default: true }, { code: 'LBH', label: 'Below holder', default: true }, { code: 'LD', label: 'L/D', default: true }, @@ -141,42 +138,6 @@ export const TOOLS_ON_OPENING = 8 */ export const TABLE_OPENS_AT = OVER_THE_ROWS + TOOLS_ON_OPENING * ROW -export interface Holding { - readonly holdersFor: (tool: CatalogTool) => ReadonlyArray<{ - readonly guid: string - readonly label: string - readonly trouble: string | null - readonly holder: Holder - }> - readonly colletsFor: ( - tool: CatalogTool, - holderGuid: string | null, - ) => ReadonlyArray<{ readonly guid: string; readonly label: string }> - readonly chosen: (tool: CatalogTool) => { - readonly holderGuid: string | null - readonly colletGuid: string | null - } - readonly requiredStickout: (tool: CatalogTool) => number | null - readonly stickoutFor: (tool: CatalogTool) => number | null - readonly reachNote?: (tool: CatalogTool) => string | null - /** - * How many holders that would otherwise fit were left off for having no - * picture, so the panel can say so. - * - * **An empty dropdown that fits is indistinguishable from one that was - * filtered** (Paul, 2026-09-07: "I just asked you to hide holders that do - * not have profiles, and now I see no holders"). `drawable` hid every holder - * in the rack, and the panel showed the same "No holder" it shows for a tool - * nothing holds — so a missing measuring run read as a broken page. This is - * the count behind that silence, and zero where nothing was hidden. - */ - readonly undrawable?: (tool: CatalogTool) => number - readonly onChoose: ( - tool: CatalogTool, - choice: { readonly holderGuid: string | null; readonly colletGuid: string | null }, - ) => void -} - /** * A tool as the table sorts it. * @@ -197,7 +158,6 @@ interface Selection { readonly ids: Array } -export const isHolding = (code: string): boolean => code === 'holder' || code === 'collet' export const isStack = (code: string): boolean => code === 'LBH' /** The four that say which tool this is, rather than a number about it. */ @@ -209,8 +169,6 @@ const WIDTH: Readonly> = { brand: '7rem', type: '12rem', family: '9rem', - holder: '10rem', - collet: '10rem', } /** What a column sorts on: the words on the row, or the number behind it. */ @@ -236,102 +194,6 @@ const columnsShown = ( ).flatMap((code) => kept.filter((column) => column.code === code)) } -const HoldingCell = ({ - tool, - code, - holding, -}: { - tool: CatalogTool - code: string - holding: Holding -}) => { - const { holderGuid, colletGuid } = holding.chosen(tool) - if (code === 'holder') { - const holders = holding.holdersFor(tool) - const items = ['', ...holders.map((each) => each.guid)] - return ( -
event.stopPropagation()}> - { - const nextHolder = typeof next === 'string' && next !== '' ? next : null - holding.onChoose(tool, { holderGuid: nextHolder, colletGuid: null }) - }} - itemToStringLabel={(guid) => { - if (guid === '') { - return 'No holder' - } - const holder = holders.find((each) => each.guid === guid) - return holder === undefined - ? '' - : `${holder.label}${holder.trouble === null ? '' : ` · ${holder.trouble}`}` - }} - size="sm" - variant="ghost" - > - - - - {items.map((guid) => { - const holder = holders.find((each) => each.guid === guid) - return ( - - {guid === '' ? 'No holder' : holder?.label} - {holder?.trouble === null || holder === undefined - ? null - : ` · ${holder.trouble}`} - - - ) - })} - - - -
- ) - } - const collets = holding.colletsFor(tool, holderGuid) - const items = ['', ...collets.map((each) => each.guid)] - return ( -
event.stopPropagation()}> - - holding.onChoose(tool, { - holderGuid, - colletGuid: typeof next === 'string' && next !== '' ? next : null, - }) - } - itemToStringLabel={(guid) => - guid === '' ? 'No collet' : (collets.find((each) => each.guid === guid)?.label ?? '') - } - size="sm" - variant="ghost" - > - - - - {items.map((guid) => ( - - {guid === '' ? 'No collet' : collets.find((each) => each.guid === guid)?.label} - - - ))} - - - -
- ) -} - /** * **One glyph for anything the rules have something against** (Paul, * 2026-09-09: "can we use the same red X icon instead of the warning triangle @@ -367,24 +229,15 @@ const GeometryCell = ({ tool, code, mark, - holding, below, unit, }: { tool: CatalogTool code: string mark: Mark | undefined - holding: Holding | undefined below: BelowHolder | null unit: UnitSystem }) => { - if (isHolding(code)) { - return holding === undefined ? ( - — - ) : ( - - ) - } if (isStack(code) && (mark === undefined || mark.ok)) { const own = tool.geometry.LBH const needed = below?.length ?? null @@ -524,7 +377,6 @@ export interface PartToolTableProps { readonly hiddenColumns: ReadonlyArray readonly columnOrder: ReadonlyArray readonly marks?: (tool: CatalogTool) => Record - readonly holding?: Holding /** * The length below the holder each candidate would stand at, in the stack * that is open — `shared/drawn-assembly`'s {@link BelowHolder}. @@ -571,7 +423,6 @@ export const PartToolTable = ({ hiddenColumns, columnOrder, marks, - holding, below, inBom, keptElsewhere, @@ -763,11 +614,7 @@ export const PartToolTable = ({ {shown.map((column) => ( {column.code === 'catalogNumber' ? ( <> @@ -830,7 +677,6 @@ export const PartToolTable = ({ tool={tool} code={column.code} mark={rowMarks[column.code]} - holding={holding} below={rowBelow} unit={unit} /> diff --git a/apps/catalog/app/components/tool-columns.test.ts b/apps/catalog/app/components/tool-columns.test.ts index abc4f27..6263f71 100644 --- a/apps/catalog/app/components/tool-columns.test.ts +++ b/apps/catalog/app/components/tool-columns.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { GEOMETRY_FIELDS } from '@toolpath/catalog-data' import { AXES_IN_TOOL_COLUMNS } from 'shared/column-filters' -import { TAP_COLUMNS, TOOL_COLUMNS, isHolding, isIdentity, isStack } from './part-tool-table' +import { TAP_COLUMNS, TOOL_COLUMNS, isIdentity, isStack } from './part-tool-table' /** * **A column has to have a number behind it.** @@ -12,15 +12,13 @@ import { TAP_COLUMNS, TOOL_COLUMNS, isHolding, isIdentity, isStack } from './par * no error to say so (Paul, 2026-08-31: "what is the usable length? It's * showing as empty — what is the intent?"). * - * Every column is either a field the catalog defines, or one of the two the - * table works out for itself — the holder and collet it is held in, and the - * stickout the stack needs. + * Every column is either a field the catalog defines, one of the four that say + * which tool it is, or the length below the holder the table works out. */ describe('the columns the list offers', () => { it('names a field the catalog defines, or one the table works out', () => { const unknown = TOOL_COLUMNS.filter( (column) => - !isHolding(column.code) && !isStack(column.code) && // The four that say which tool this is rather than a number about it. !isIdentity(column.code) && @@ -39,7 +37,7 @@ describe('the columns the list offers', () => { * 2026-08-31). It is the tool's own **length below holder** now: the overall * length less the shank the clamping rule holds, which is a number every tool * has and which decides whether it reaches (Paul, 2026-09-01). So it opens - * with the rest, and the holder and collet still wait to be asked for. + * with the rest. */ describe('the columns a list opens with', () => { it('opens with the numbers a tool is chosen on, reach among them', () => { @@ -62,7 +60,7 @@ describe('the columns a list opens with', () => { * the column picker lists them the same way top to bottom, because the page * seeds its order from this list. */ - it('is in the order a row is read, holding last', () => { + it('is in the order a row is read', () => { expect(TOOL_COLUMNS.map((column) => column.code)).toEqual([ 'catalogNumber', 'brand', @@ -77,8 +75,6 @@ describe('the columns a list opens with', () => { 'RE', 'SFDM', 'SIG', - 'holder', - 'collet', ]) }) @@ -97,9 +93,16 @@ describe('the columns a list opens with', () => { } }) - it('leaves the holder and the collet for somebody to ask for', () => { + /** + * **The holder and the collet are not columns** (Paul, 2026-09-10). They were + * the per-row dropdowns from before the assembly tree; the tree took the + * choice off the row and the page stopped handing the list any holding, so + * both had become columns of dashes anybody could tick in the picker. + */ + it('offers neither a holder nor a collet, on either list', () => { for (const code of ['holder', 'collet']) { - expect(TOOL_COLUMNS.find((column) => column.code === code)?.default).toBe(false) + expect(TOOL_COLUMNS.map((column) => column.code)).not.toContain(code) + expect(TAP_COLUMNS.map((column) => column.code)).not.toContain(code) } }) }) @@ -118,16 +121,15 @@ describe('the columns a tap list offers', () => { /** Every number 129 taps in the dataset carry, and nothing they do not. */ const ON_A_TAP = ['DC', 'SFDM', 'OAL', 'LCF', 'NOF', 'LBH', 'LD'] - it('offers a number a tap carries, or the holding every list can ask for', () => { + it('offers a number a tap carries, and nothing else', () => { const unknown = TAP_COLUMNS.filter( - (column) => - !isHolding(column.code) && !isIdentity(column.code) && !ON_A_TAP.includes(column.code), + (column) => !isIdentity(column.code) && !ON_A_TAP.includes(column.code), ).map((column) => column.code) expect(unknown).toEqual([]) }) - it('opens with all of them, and leaves the holding to be asked for', () => { + it('opens with all of them', () => { expect(TAP_COLUMNS.filter((column) => column.default).map((column) => column.code)).toEqual([ 'catalogNumber', 'brand', @@ -141,9 +143,6 @@ describe('the columns a tap list offers', () => { 'NOF', 'SFDM', ]) - for (const code of ['holder', 'collet']) { - expect(TAP_COLUMNS.find((column) => column.code === code)?.default).toBe(false) - } }) /** diff --git a/apps/catalog/app/components/tool-details.test.tsx b/apps/catalog/app/components/tool-details.test.tsx index f0e98cf..f2eafae 100644 --- a/apps/catalog/app/components/tool-details.test.tsx +++ b/apps/catalog/app/components/tool-details.test.tsx @@ -185,102 +185,6 @@ describe('pointing at a number', () => { }) }) -describe('choosing a holder and a collet', () => { - const holding = (over: Partial[0]['holding'] & object> = {}) => { - const onChoose = vi.fn() - return { - onChoose, - holding: { - /* - The holder carries its own guid, the way the page's own `holdersFor` - mints it (`routes/part.tsx`: `guid: option.holder.guid`). The panel - hands the chosen holder to `shared/drawn-assembly`, which finds it by - that guid — a wrapper guid over a holder with none is a stack that - never assembles. - */ - holdersFor: () => [ - { - guid: 'h-er16', - label: 'ER16 chuck · takes this collet', - trouble: null, - holder: { guid: 'h-er16' }, - }, - { guid: 'h-pg6', label: 'PG6 chuck', trouble: null, holder: { guid: 'h-pg6' } }, - ], - colletsFor: (_tool: unknown, holderGuid: string | null) => - holderGuid === null - ? [{ guid: 'c-er16', label: 'ER16-4 · ER16' }] - : holderGuid === 'h-er16' - ? [{ guid: 'c-er16', label: 'ER16-4' }] - : [], - chosen: () => ({ holderGuid: null, colletGuid: null }), - requiredStickout: () => null, - stickoutFor: () => null, - reachNote: () => null, - onChoose, - ...over, - } as unknown as Parameters[0]['holding'], - } - } - - it('offers the collets that grip the shank with no holder chosen', () => { - render() - - const collet = screen.getByRole('combobox', { name: 'Collet' }) - expect(collet).toBeEnabled() - fireEvent.click(collet) - expect(screen.getByRole('option', { name: 'ER16-4 · ER16' })).toBeInTheDocument() - }) - - /** - * **The number in the table is the number on the sheet** (2026-09-03). - * - * The panel printed the tool's own `LBH` beside a drawing of the stack, and - * the two were different quantities: `LBH` was the most the tool could stand - * out and the drawing was drawn at the setup, so the sheet dimensioned a - * length the table beside it contradicted — the report's symptom, a - * dimension line running up into the holder body. They are one number now, - * and this is the lockstep that keeps them one. AGENTS.md § Testing: a - * duplicate across a boundary gets a test, not a comment. - */ - it('prints the stickout the stack is drawn at, not the tool’s own', () => { - const { holding: held } = holding({ - chosen: () => ({ holderGuid: 'h-er16', colletGuid: null }), - stickoutFor: () => 19, - }) - render() - - // Not the 46 mm the tool carries on its own. - expect(screen.getByText('Below holder').closest('div')?.textContent).toBe( - 'Below holderLBH19.00 mm*', - ) - - // And with the sheet switched back to the bare tool, its own figure again. - fireEvent.click(screen.getByRole('button', { name: 'Tool' })) - expect(screen.getByText('Below holder').closest('div')?.textContent).toBe( - 'Below holderLBH46.00 mm*', - ) - }) - - it('keeps a collet the new holder can take, and drops one it cannot', () => { - const { onChoose, holding: takes } = holding({ - chosen: () => ({ holderGuid: null, colletGuid: 'c-er16' }), - }) - render() - - fireEvent.click(screen.getByRole('combobox', { name: 'Holder' })) - fireEvent.click(screen.getByRole('option', { name: /ER16 chuck/ })) - expect(onChoose).toHaveBeenLastCalledWith(tool, { - holderGuid: 'h-er16', - colletGuid: 'c-er16', - }) - - fireEvent.click(screen.getByRole('combobox', { name: 'Holder' })) - fireEvent.click(screen.getByRole('option', { name: /PG6 chuck/ })) - expect(onChoose).toHaveBeenLastCalledWith(tool, { holderGuid: 'h-pg6', colletGuid: null }) - }) -}) - /** * **The part is drawn beside the tool** (2026-09-03). * @@ -321,19 +225,49 @@ describe('the material around the feature', () => { provenance: {}, } - const held = { - holdersFor: () => [{ guid: 'h-er16', label: 'ER16 chuck', trouble: null, holder }], - colletsFor: () => [], - chosen: () => ({ holderGuid: 'h-er16', colletGuid: null }), - requiredStickout: () => null, - stickoutFor: () => 19, - reachNote: () => null, - onChoose: vi.fn(), - } as unknown as Parameters[0]['holding'] + /** + * The stack the tree hands in. **The only way a holder reaches this panel** + * (Paul, 2026-09-10): the two dropdowns that used to offer one of its own + * came off, so a stack on this sheet is a stack somebody assembled in the + * tree. + */ + const held = { holder, collet: null } + + /** + * **The number in the table is the number on the sheet** (2026-09-03). + * + * The panel printed the tool's own `LBH` beside a drawing of the stack, and + * the two were different quantities: `LBH` is the most the tool could stand + * out and the drawing is drawn at the setup, so the sheet dimensioned a + * length the table beside it contradicted — the report's symptom, a + * dimension line running up into the holder body. They are one number now, + * and this is the lockstep that keeps them one. AGENTS.md § Testing: a + * duplicate across a boundary gets a test, not a comment. + * + * The figure is `shared/drawn-assembly`'s, worked out from this holder and + * this tool. It used to be whatever the panel's own `stickoutFor` said, + * which was a dropdown's answer; with the dropdowns gone the stack is the + * only thing that can answer it. + */ + it('prints the stickout the stack is drawn at, not the tool\u2019s own', () => { + measured() + + // `drawnAssembly`'s figure for this tool in this chuck, not the 46 mm the + // tool carries on its own. + expect(screen.getByText('Below holder').closest('div')?.textContent).toBe( + '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' })) + expect(screen.getByText('Below holder').closest('div')?.textContent).toBe( + 'Below holderLBH46.00 mm*', + ) + }) it('draws the part wall and the gaps beside the stack when there is a feature', () => { const { container } = measured( - , + , ) expect(container.querySelector('[data-part="material"]')).not.toBeNull() @@ -342,7 +276,7 @@ describe('the material around the feature', () => { /** No feature to clear is the tool on its own, with no clearance claimed. */ it('draws the tool alone when the panel is given no feature', () => { - const { container } = measured() + const { container } = measured() expect(container.querySelector('svg')).not.toBeNull() expect(container.querySelector('[data-part="material"]')).toBeNull() @@ -356,7 +290,7 @@ describe('the material around the feature', () => { */ it('keeps the material on the sheet with the drawing switched back to the tool', () => { const { container } = measured( - , + , ) fireEvent.click(screen.getByRole('button', { name: 'Tool' })) diff --git a/apps/catalog/app/components/tool-details.tsx b/apps/catalog/app/components/tool-details.tsx index d544474..b202c0a 100644 --- a/apps/catalog/app/components/tool-details.tsx +++ b/apps/catalog/app/components/tool-details.tsx @@ -17,7 +17,6 @@ import { thresholdsFrom } from 'shared/holder-choice' import { ToolTypeIcon, formLabel } from './tool-icons' import { MeasurementIcon } from './feature-icons' import { CatalogDrawing } from './catalog-drawing' -import type { Holding } from './part-tool-table' import { CatalogComboboxButton } from './catalog-combobox-button' /** @@ -91,8 +90,6 @@ const PANEL_MATERIAL_ROOM = 130 export interface ToolDetailsProps { readonly tool: CatalogTool readonly unit: UnitSystem - /** The holder and collet for this tool, asked the same way the list asks. */ - readonly holding?: Holding | undefined /** * Keeping the tool, from the panel it was assembled in. * @@ -138,14 +135,14 @@ export interface ToolDetailsProps { /** Room the shop wants kept between the stack and the part. */ readonly margins?: Margins /** - * The stack around the tool, where something other than this panel holds it. + * The stack around the tool, worked out by the tree that holds it. * - * **The tool assembly tree is that something** (Paul, 2026-09-07): with the - * tree on, a holder is a slot with a table of its own and this panel offers - * no dropdowns at all — so `holding` is absent, and without this the sheet - * would draw the cutter on its own beside a stack the tree had fully - * assembled. `holding` still wins where both are given: the dropdowns and the - * picture must not be able to disagree. + * **The tree is the only thing that holds a tool** (Paul, 2026-09-07 for the + * tree, 2026-09-10 for the last of the dropdowns): a holder is a slot with a + * table of its own, and this panel offers no holding of its own at all. + * Without this the sheet would draw the cutter on its own beside a stack the + * tree had fully assembled; with it there is one answer rather than two that + * can disagree. */ readonly stack?: { readonly holder: Holder | null; readonly collet: Collet | null } /** @@ -166,7 +163,6 @@ export interface ToolDetailsProps { export const ToolDetails = ({ tool, unit, - holding, actions = [], mappedTo = [], curve = null, @@ -193,29 +189,17 @@ export const ToolDetails = ({ * about what is lit. */ const [pointed, setPointed] = useState(null) - const chosen = holding?.chosen(tool) ?? { + /** + * 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. + */ + const chosen = { holderGuid: stack?.holder?.guid ?? null, colletGuid: stack?.collet?.guid ?? null, } - const holders = holding?.holdersFor(tool) ?? [] - /** - * Holders that fit and have no picture, which is why they are not on the - * list above (Paul, 2026-09-07). Zero once they have been measured, and the - * note goes with it. - */ - const undrawable = holding?.undrawable?.(tool) ?? 0 - const collets = holding?.colletsFor(tool, chosen.holderGuid) ?? [] - /** - * What the stack has to stand out to clear, from the list rather than the - * drawing. `drawnAssembly` works the same number out as `drawn.required`, - * and this is the one shown: the tool list was sorted and graded on - * `holding`'s, so printing the drawing's beside a list ordered by the - * other would be two answers to one question. - */ - const needed = holding?.requiredStickout(tool) ?? null - const holderChosen = - holders.find((each) => each.guid === chosen.holderGuid)?.holder ?? stack?.holder ?? undefined - const stickout = holding?.stickoutFor?.(tool) ?? null + const holderChosen = stack?.holder ?? undefined /** * The stack, worked out where every other page works it out. * @@ -234,7 +218,7 @@ export const ToolDetails = ({ */ const drawn = drawnAssembly( tool, - { holder: chosen.holderGuid, collet: chosen.colletGuid, stickout }, + { holder: chosen.holderGuid, collet: chosen.colletGuid, stickout: null }, curve, margins, thresholdsFrom(), @@ -324,102 +308,11 @@ export const ToolDetails = ({

)} - {/* - **Holding first, because it changes the picture below it.** The tool is - assembled here: a holder and a collet, then what that stack looks like, - then the numbers (Paul, 2026-09-01). - */} - {holding === undefined ? null : ( -
- {/* No label over either: the select says which it is (Paul, 2026-09-01). */} - each.guid)]} - value={chosen.holderGuid ?? ''} - aria-label="Holder" - onValueChange={(next) => { - const holderGuid = typeof next === 'string' && next !== '' ? next : null - const kept = holding - .colletsFor(tool, holderGuid) - .some((each) => each.guid === chosen.colletGuid) - holding.onChoose(tool, { - holderGuid, - colletGuid: kept ? chosen.colletGuid : null, - }) - }} - itemToStringLabel={(guid) => - guid === '' ? 'No holder' : (holders.find((each) => each.guid === guid)?.label ?? '') - } - size="sm" - variant="ghost" - > - - - - {['', ...holders.map((each) => each.guid)].map((guid) => ( - - {guid === '' ? 'No holder' : holders.find((each) => each.guid === guid)?.label} - - - ))} - - - - {/* - **An empty dropdown has to say which empty it is** (Paul, - 2026-09-07: "now I see no holders"). Nothing fitting and everything - fitting but unmeasured both showed the same "No holder", so a - measuring run nobody had made read as a broken page. - */} - {undrawable === 0 ? null : ( -

- {holders.length === 0 - ? `${undrawable} holder${undrawable === 1 ? '' : 's'} fit here, none with a model to draw.` - : `${undrawable} more fit but have no model to draw.`} -

- )} - each.guid)]} - value={chosen.colletGuid ?? ''} - aria-label="Collet" - disabled={collets.length === 0} - onValueChange={(next) => - holding.onChoose(tool, { - holderGuid: chosen.holderGuid, - colletGuid: typeof next === 'string' && next !== '' ? next : null, - }) - } - itemToStringLabel={(guid) => - guid === '' ? 'No collet' : (collets.find((each) => each.guid === guid)?.label ?? '') - } - size="sm" - variant="ghost" - > - - - - {['', ...collets.map((each) => each.guid)].map((guid) => ( - - {guid === '' ? 'No collet' : collets.find((each) => each.guid === guid)?.label} - - - ))} - - - - {needed === null ? null : ( -

- This stack has to stand out{' '} - {formatLength(needed, unit)} to clear - the part. -

- )} -
- )} {/* **The drawing takes the room** (Paul, 2026-09-01). It was a fixed 16 rem in a panel half a screen tall, which is a thumbnail of the one thing - the panel exists to show. It fills what the head, the holding and the - numbers leave. + the panel exists to show. It fills what the head and the numbers + leave. */}
{drawn.holder === null ? null : ( diff --git a/apps/catalog/app/routes/part.tsx b/apps/catalog/app/routes/part.tsx index 1b5986b..3ff9542 100644 --- a/apps/catalog/app/routes/part.tsx +++ b/apps/catalog/app/routes/part.tsx @@ -10,14 +10,7 @@ import { } from 'react' import { Link, useNavigate, useParams, useSearchParams } from 'react-router' import { Badge, Button, Card, IconButton, cn, Panels } from '@toolpath/ui' -import { - colletsFor, - colletsForShank, - toolCollisions, - type CatalogTool, - type Holder, - type Margins, -} from '@toolpath/catalog-data' +import { toolCollisions, type CatalogTool, type Holder, type Margins } from '@toolpath/catalog-data' import { useAnalysisEvents } from '@toolpath/part-client' import type { PartFeature, PublicInspectionReport } from '@toolpath/part-contracts' import { heldRegions } from '@toolpath/part-contracts/selection' @@ -97,9 +90,8 @@ import { TABLE_OPENS_AT, ToolTableToolbar, hiddenByDefault, - type Holding, } from 'components/part-tool-table' -import { ColumnPicker, sameBound } from 'components/column-filter' +import { ColumnPicker } from 'components/column-filter' import { BUTTON_FILTERS } from 'components/filter-panel' import { orderedCodes } from 'shared/column-order' import { hiddenAfterAuto } from 'shared/auto-columns' @@ -180,6 +172,7 @@ import { FACET_AXES, countBy, countsByAxis, + ownBounds, stillOffered, filterTools, queryFromSearch, @@ -203,14 +196,7 @@ import { } from 'shared/holding' import { sectionOf } from 'shared/section-of' import { belowHolder, type BelowHolder } from 'shared/drawn-assembly' -import { - drawable, - holdable, - holderOptions, - policyOf, - thresholdsFrom, - type HolderOption, -} from 'shared/holder-choice' +import { drawable, holdable, holderOptions, policyOf, thresholdsFrom } from 'shared/holder-choice' import { closestMisses, closestPerForm, type Format } from 'shared/judge' import { cautionedTypes, @@ -424,22 +410,6 @@ const busyTyping = (event: KeyboardEvent): boolean => typingInto(event) || (event.target as HTMLElement | null)?.closest('[data-part-tool-table]') != null -/** - * Whether a holder option has a silhouette to draw. - * - * **Only the holders that can be drawn are offered** (Paul, 2026-09-07: - * "exclude any holders without models"). A record with no measured profile and - * no published nose has no shape at all, so picking it draws a blank panel. - * `drawable` is the rule and `holder-choice.ts` documents it; this is the one - * place the catalog's own profile document is what answers it. - * - * The dropdown only. Whether a tool can be *held* is a different question from - * whether its holder has a picture, and narrowing the tool list by this would - * take tools off a shop's list because a vendor publishes no CAD. - */ -const hasPicture = (option: HolderOption): boolean => - drawable(option.holder, (guid: string) => getProfile(guid) !== null) - const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: string }) => { const [unit, setUnit] = useUnit() const [search, setSearch] = useSearchParams() @@ -1549,6 +1519,18 @@ 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. + */ + const own = useMemo( + () => ownBounds(query.ranges, suggestions.ranges), + [query.ranges, suggestions], + ) const tableContext = useMemo( () => ({ features: report.features, @@ -1560,6 +1542,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: margins, thresholds, overrides: overriding, + ownRanges: own, }), [ report.features, @@ -1571,6 +1554,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: margins, thresholds, overriding, + own, ], ) const tableDemand = useMemo(() => { @@ -1792,7 +1776,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: if (!asking || (tools.length > 0 && !shortOfDrills)) { return [] } - const admitted = closeCandidates(nearMisses, query) + const admitted = closeCandidates(nearMisses, query, own) /** * **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 @@ -1836,6 +1820,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: shortOfDrills, nearMisses, query, + own, holeChoice.mode, outOfReach, predrillForms, @@ -2081,17 +2066,10 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: */ useEffect(() => { setOverriding((current) => { - const kept = current.filter((code) => { - const bound = query.ranges[code] - if (bound === undefined || (bound.min === undefined && bound.max === undefined)) { - return false - } - const suggested = suggestions.ranges[code] - return suggested === undefined || !sameBound(bound, suggested) - }) + const kept = current.filter((code) => own[code] !== undefined) return kept.length === current.length ? current : kept }) - }, [query.ranges, suggestions]) + }, [own]) /** * Every value each axis has, so a contextual list can say what it is not @@ -2559,174 +2537,6 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: wantedTool.current = null }, [focused]) - /** - * The holder and collet for a tool: the columns, the stickout column, and - * the panel beside the part all ask the same question. - * - * Grading every holder in the crib against a tool is real work, so it is - * done **per tool that asks**, cached for as long as the crib and the - * clearances hold still. Nothing is graded until something calls for it, so - * the panel's one tool costs one tool, and a list of two hundred pays only - * for the columns that are actually ticked. - */ - const optionsFor = useMemo(() => { - const cache = new Map>() - return (each: CatalogTool): Array => { - const had = cache.get(each.guid) - if (had) { - return had - } - const made = holderOptions( - each, - allHolders, - allCollets, - holderFilters, - curve, - margins, - thresholds, - ) - cache.set(each.guid, made) - return made - } - }, [holderFilters, curve, margins, thresholds]) - const holding = useMemo(() => { - return { - /** - * **A collet chosen first puts its own chucks at the top** (Paul, - * 2026-09-01: "then all holders are shown but we show the ones that work - * with that collet at the top"). Every holder is still offered — the - * collet is a preference, not a filter — and the ones of its series lead. - */ - holdersFor: (each) => { - const chosenCollet = picked[each.guid]?.colletGuid - const series = - chosenCollet == null ? undefined : (getCollet(chosenCollet)?.series ?? undefined) - // Only the holders that can be drawn — `hasPicture` above says why, and - // `undrawable` below reports what that hid. - const options = optionsFor(each).filter(hasPicture) - const ordered = - series === undefined - ? options - : [ - ...options.filter((option) => option.holder.colletSeries === series), - ...options.filter((option) => option.holder.colletSeries !== series), - ] - return ordered.map((option) => ({ - guid: option.holder.guid, - label: - option.holder.colletSeries === series - ? `${option.holder.catalogNumber} · takes this collet` - : option.holder.catalogNumber, - holder: option.holder, - trouble: option.unstocked - ? `no ${option.holder.colletSeries ?? 'matching'} collet stocked` - : option.clears === false - ? 'collision with geometry' - : option.band === 'bad' - ? 'too little grip' - : null, - })) - }, - /** - * How many holders were left off for having no picture, so the panel can - * say so rather than showing an empty dropdown (Paul, 2026-09-07). - */ - undrawable: (each) => optionsFor(each).filter((option) => !hasPicture(option)).length, - /** - * With a holder: the collets of its series that close on the shank. - * **Without one: every collet that closes on the shank**, whatever series - * it belongs to, each saying which series that is — the dropdown used to - * be empty until a holder was picked, which read as broken (Paul, - * 2026-09-01). - */ - colletsFor: (each, holderGuid) => { - const holder = optionsFor(each).find((option) => option.holder.guid === holderGuid)?.holder - if (holder === undefined) { - return colletsForShank(each, allCollets).map((collet) => ({ - guid: collet.guid, - label: `${collet.catalogNumber} · ${collet.series}`, - })) - } - return colletsFor(each, holder, allCollets).map((collet) => ({ - guid: collet.guid, - label: collet.catalogNumber, - })) - }, - chosen: (each) => ({ - holderGuid: picked[each.guid]?.holderGuid ?? null, - colletGuid: picked[each.guid]?.colletGuid ?? null, - }), - /** What the chosen stack stands out at: the person's, or the option's own. */ - stickoutFor: (each) => { - const holderGuid = picked[each.guid]?.holderGuid ?? null - return ( - picked[each.guid]?.stickout ?? - optionsFor(each).find((option) => option.holder.guid === holderGuid)?.stickout ?? - null - ) - }, - requiredStickout: (each) => { - const holderGuid = picked[each.guid]?.holderGuid ?? null - if (holderGuid === null) { - return null - } - return ( - optionsFor(each).find((option) => option.holder.guid === holderGuid)?.required ?? null - ) - }, - /** - * Why nothing in the crib can hold it, in one line. - * - * The holder stage drops a tool for one of two reasons and said neither: - * every stack fouls the part at the stickout this feature needs, or the - * tool is too short to stand out that far and keep hold. Both are about - * a length, and a length is what somebody can go and change. - */ - reachNote: (each) => { - const options = optionsFor(each) - /** - * **Never "no holder grips this shank"** (Paul, 2026-09-01: "means - * nothing, never show it"). It said the crib holds nothing that takes - * this shank, which is a fact about the crib rather than about the - * length the cell is for — and it stood in that cell against every - * tool of a size nobody has a collet for, which is most of a - * seventeen-thousand-tool catalog. - */ - if (options.length === 0) { - return null - } - if (options.some((option) => option.grade !== 'bad')) { - return null - } - /** - * **One stack's story, not two halves of two.** - * - * Taking the least required stickout from one holder and the longest - * grip from another read as "needs 53 mm out; holds at 55" — which - * says it fits (Paul, 2026-08-31). The stack that comes closest is the - * one worth quoting, and closest means the smallest gap between what - * it needs and what it can hold. - */ - const gaps = options.flatMap((option) => { - const needs = option.required - const most = option.range?.max ?? null - return needs === null || most === null || needs <= most - ? [] - : [{ needs, most, by: needs - most }] - }) - const closestStack = gaps.sort((a, b) => a.by - b.by)[0] - return closestStack === undefined - ? 'no holder clears the part here' - : `needs ${format(closestStack.needs, 'mm')} out, holds ${format(closestStack.most, 'mm')}` - }, - onChoose: (each, choice) => - setPicked((current) => ({ - ...current, - [each.guid]: { ...current[each.guid], ...choice }, - })), - } - }, [optionsFor, picked]) - /** * The list: the ten best, each as the assembly the rules recommend — and, * when fewer than ten fit, the nearest misses after them, marked @@ -5035,7 +4845,14 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: if (panelTool === null) { return [] } - const held = holding.chosen(panelTool) + /** + * Whatever this tool is standing in, which is the tree's doing: the panel + * has offered no holding of its own since 2026-09-10. + */ + const held = { + holderGuid: picked[panelTool.guid]?.holderGuid ?? null, + colletGuid: picked[panelTool.guid]?.colletGuid ?? null, + } const first = distinctIn(askedNow.tags)[0]?.[0] const line = first === undefined ? null : chosenFor(sheet, first, panelTool.guid) const wanted = toolActions({ @@ -5109,7 +4926,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: })) }, [ panelTool, - holding, + picked, distinctIn, askedNow, asking, @@ -6981,7 +6798,6 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: ): MatchContext => ({ margins: { radial: 0, axial: 0 }, thresholds: thresholdsFrom(), overrides: [], + ownRanges: {}, }) const catalog = { tools: [tool('SMALL', 6), tool('LARGE', 10)], holders: [], collets: [] } @@ -545,3 +546,53 @@ describe('the features key', () => { expect(key).not.toContain('featureType') }) }) + +/** + * **A bound somebody typed is obeyed by the list that stands in when nothing + * fits** (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'"). + * + * And obeyed **here**, before the fifty nearest are taken. The cap ranks on how + * far a tool is outside the *rules*, which never asks the flute count — so the + * one three-flute tool that could stand in is exactly the one a cap taken first + * would drop, and no amount of narrowing on the far side of the worker boundary + * could put it back. + */ +describe('the misses a typed bound leaves standing', () => { + const flutes = (guid: string, DC: number, NOF: number): CatalogTool => { + const each = tool(guid, DC) + return { ...each, geometry: { ...each.geometry, NOF } } as CatalogTool + } + + const feature = pocket('pocket-1') + const demand = { demandKey: 'one', tags: [feature.featureTag] } + // Sixty four-flute cutters that miss the pocket by a hair, and one + // three-flute that misses it by a mile. + const crib = { + tools: [ + ...Array.from({ length: 60 }, (each, at) => flutes(`four-${String(at)}`, 11, 4)), + flutes('three', 30, 3), + ], + holders: [], + collets: [], + } + + it('ranks on the rules alone while only the geometry is bounding the list', () => { + const result = detailedMatch(context([feature]), demand, crib) + + expect(result.excludedCount).toBe(61) + expect(result.nearMisses).toHaveLength(50) + expect(result.nearMisses.map((each) => each.toolGuid)).not.toContain('three') + }) + + it('narrows to the bound before the nearest are taken', () => { + const result = detailedMatch( + { ...context([feature]), ownRanges: { NOF: { max: 3 } } }, + demand, + crib, + ) + + 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 755d054..de6b4a8 100644 --- a/apps/catalog/app/shared/catalog-matcher.ts +++ b/apps/catalog/app/shared/catalog-matcher.ts @@ -7,6 +7,7 @@ import { facetsNarrowing, filterTools, FACET_AXES, + withinRanges, withoutFacets, withoutTerm, type ToolQuery, @@ -51,6 +52,19 @@ export interface MatchContext { * move it and must not evict its cache entry. */ 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. + * + * 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. + */ + readonly ownRanges: ToolQuery['ranges'] } /** One question in a table request or a recommendation batch. */ @@ -131,10 +145,11 @@ export interface DetailedResult { * offers, nearest first. * * Distinct from {@link nearMisses} in both directions. Those are the closest - * misses to the *rules*, drawn without the ranges so that a tool a little - * outside one can be offered when nothing fits; these are what the person's - * own ranges ask for, however far outside a rule they land. `tool-fit.ts` - * `overridableTools` is the rule and says why the two cannot be one list. + * misses to the *rules*, drawn without the geometry's own bounds so that a + * tool a little outside one can be offered when nothing fits; these are what + * the person's own ranges ask for, however far outside a rule they land. + * `tool-fit.ts` `overridableTools` is the rule and says why the two cannot be + * one list. * * Empty until {@link MatchContext.overrides} names a column: nothing is * forgiven that nobody asked to have forgiven. @@ -299,10 +314,11 @@ export const matchKey = ( stable({ kind, // Recommendation verdicts contain only a GUID, so display units cannot affect - // either the answer or its cache entry. + // either the answer or its cache entry. Nor can the two things that only + // move the removed set: a one-each pick is never drawn from it. context: { ...(kind === 'recommendations' - ? { ...context, unit: 'millimeters', overrides: [] } + ? { ...context, unit: 'millimeters', overrides: [], ownRanges: {} } : { ...context, overrides: [...context.overrides].sort() }), features: featuresKey(context.features), }, @@ -423,9 +439,10 @@ export interface PreparedMatch { * before a single drill reached the table. Judging this set instead is 65 ms. * * The ranges are deliberately left out rather than folded in with the rest: - * `closeCandidates` drops them too, because "close" is exactly a tool a - * little outside a range, and a near miss judged away here could not be - * offered as one later. + * `closeCandidates` drops the geometry's own too, because "close" is exactly + * a tool a little outside one, and a near miss judged away here could not be + * offered as one later. A bound somebody typed is obeyed instead, where the + * misses are ranked — {@link nearestFew}. */ readonly considered: ReadonlyArray /** Those of them the filters actually admit — what a row may show. */ @@ -562,21 +579,31 @@ 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. */ const nearestFew = ( excluded: ReadonlyArray, forms: ReadonlyArray, + own: ToolQuery['ranges'], ): Array => { - const overall = closestMisses(excluded, NEAR_MISSES) + const asked = + Object.keys(own).length === 0 + ? excluded + : excluded.filter((verdict) => withinRanges(verdict.tool, own)) + const overall = closestMisses(asked, NEAR_MISSES) if (forms.length < 2) { return overall } const sent = new Set(overall.map((verdict) => verdict.tool.guid)) return [ ...overall, - ...closestPerForm(excluded, forms, NEAR_MISSES).filter( - (verdict) => !sent.has(verdict.tool.guid), - ), + ...closestPerForm(asked, forms, NEAR_MISSES).filter((verdict) => !sent.has(verdict.tool.guid)), ] } @@ -641,7 +668,9 @@ export const detailedMatch = ( return { demandKey: demand.demandKey, fitting: matched.fitting.map(compact), - nearMisses: nearestFew(matched.excluded, context.query.terms.form ?? []).map(compact), + nearMisses: nearestFew(matched.excluded, context.query.terms.form ?? [], context.ownRanges).map( + compact, + ), overridable: overridableTools( matched.excluded, prepared.admittedGuids, diff --git a/apps/catalog/app/shared/column-filters.test.ts b/apps/catalog/app/shared/column-filters.test.ts index 61724a1..958c388 100644 --- a/apps/catalog/app/shared/column-filters.test.ts +++ b/apps/catalog/app/shared/column-filters.test.ts @@ -38,12 +38,6 @@ describe('what a tool column asks', () => { expect(askOfToolColumn('LD')).toEqual({ shape: 'range', kind: 'ratio' }) }) - /** They set the holding on that row; there is no value in them to narrow on. */ - it('asks nothing of the holder and collet cells', () => { - expect(askOfToolColumn('holder')).toBeNull() - expect(askOfToolColumn('collet')).toBeNull() - }) - it('names every axis a header takes over from the button row', () => { for (const axis of AXES_IN_TOOL_COLUMNS) { expect(askOfToolColumn(axis)).not.toBeNull() diff --git a/apps/catalog/app/shared/column-filters.ts b/apps/catalog/app/shared/column-filters.ts index ed883df..d4bed94 100644 --- a/apps/catalog/app/shared/column-filters.ts +++ b/apps/catalog/app/shared/column-filters.ts @@ -59,14 +59,13 @@ const TOOL_RANGE_KINDS: Readonly> = { } /** - * The two tool columns that are a control rather than a value. + * What the header over a tool column asks. * - * A holder cell is a dropdown that *sets* the holding on that row, so a funnel - * beside it would read as narrowing by a choice the row does not have yet. + * Every tool column is a value now, so every heading asks something: the two + * that were a control rather than a value — the holder and the collet + * dropdowns — came off the list on 2026-09-10. The `null` stays in the type + * because `askOfTapColumn` shares it and a tap column can still ask nothing. */ -const NOT_ASKED = ['holder', 'collet'] - -/** What the header over a tool column asks, or nothing where it asks nothing. */ export const askOfToolColumn = (code: string): ColumnAsk | null => { if (code === 'catalogNumber') { return { shape: 'text' } @@ -75,9 +74,6 @@ export const askOfToolColumn = (code: string): ColumnAsk | null => { if (axis !== undefined) { return { shape: 'terms', axis } } - if (NOT_ASKED.includes(code)) { - return null - } return { shape: 'range', kind: TOOL_RANGE_KINDS[code] ?? 'length' } } diff --git a/apps/catalog/app/shared/filter.test.ts b/apps/catalog/app/shared/filter.test.ts index 26c55a4..5310220 100644 --- a/apps/catalog/app/shared/filter.test.ts +++ b/apps/catalog/app/shared/filter.test.ts @@ -6,6 +6,7 @@ import { countsByAxis, cycleTerm, filterTools, + ownBounds, prioritise, queryFromSearch, searchFromQuery, @@ -618,3 +619,37 @@ 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). + */ +describe('the bounds somebody set themselves', () => { + it('leaves out a bound still reading what the feature suggested', () => { + expect(ownBounds({ DC: { max: 8 } }, { DC: { max: 8 } })).toEqual({}) + }) + + it('keeps a bound on a column the feature said nothing about', () => { + expect(ownBounds({ NOF: { max: 3 } }, { DC: { max: 8 } })).toEqual({ NOF: { max: 3 } }) + }) + + it('keeps a suggested bound that has been changed', () => { + expect(ownBounds({ DC: { max: 12 } }, { DC: { max: 8 } })).toEqual({ DC: { max: 12 } }) + }) + + /** + * The same float story `BOUND_SLACK` exists for: `0.75 × 25.4` is + * `19.049999999999997`, and a suggestion written in millimetres and read back + * through the URL must not come back as somebody's own answer. + */ + it('reads a bound a float rounded as the same bound', () => { + expect(ownBounds({ DC: { max: 0.75 * 25.4 } }, { DC: { max: 19.05 } })).toEqual({}) + }) + + /** A bound holding neither end narrows nothing, so it is nobody's answer. */ + it('leaves out a bound with no ends', () => { + expect(ownBounds({ DC: {} }, {})).toEqual({}) + }) +}) diff --git a/apps/catalog/app/shared/filter.ts b/apps/catalog/app/shared/filter.ts index 881c50b..042d6d4 100644 --- a/apps/catalog/app/shared/filter.ts +++ b/apps/catalog/app/shared/filter.ts @@ -144,7 +144,8 @@ export const withinRange = ( !(bound.min !== undefined && value < bound.min - BOUND_SLACK) && !(bound.max !== undefined && value > bound.max + BOUND_SLACK) -const matchesRanges = (tool: CatalogTool, ranges: ToolQuery['ranges']): boolean => +/** Whether a tool is inside every one of a set of bounds, missing values out. */ +export const withinRanges = (tool: CatalogTool, ranges: ToolQuery['ranges']): boolean => Object.entries(ranges).every(([key, bound]) => { if (bound.min === undefined && bound.max === undefined) { return true @@ -153,6 +154,63 @@ const matchesRanges = (tool: CatalogTool, ranges: ToolQuery['ranges']): boolean return value === undefined ? false : withinRange(value, bound) }) +const sameEnd = (a: number | undefined, b: number | undefined): boolean => + a === undefined || b === undefined ? a === b : Math.abs(a - b) < BOUND_SLACK + +/** + * 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/`. + */ +export const sameBound = ( + a: { readonly min?: number; readonly max?: number } | undefined, + b: { readonly min?: number; readonly max?: number } | undefined, +): boolean => { + if (a === undefined || b === undefined) { + return a === b + } + return sameEnd(a.min, b.min) && sameEnd(a.max, b.max) +} + +/** + * The bounds somebody set themselves, as against the ones the feature asked for. + * + * **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. + * + * **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. + * + * A bound holding neither end narrows nothing and is nobody's answer. + */ +export const ownBounds = ( + ranges: ToolQuery['ranges'], + suggested: ToolQuery['ranges'], +): ToolQuery['ranges'] => { + const own: Record = {} + for (const [key, bound] of Object.entries(ranges)) { + if (bound.min === undefined && bound.max === undefined) { + continue + } + if (!sameBound(bound, suggested[key])) { + own[key] = bound + } + } + return own +} + /** Pure, and the whole of the search: the same function the tests run on literals. */ export const filterTools = ( tools: ReadonlyArray, @@ -163,7 +221,7 @@ export const filterTools = ( (tool) => (text === '' || haystack(tool).includes(text)) && matchesTerms(tool, query.terms) && - matchesRanges(tool, query.ranges), + withinRanges(tool, query.ranges), ) } diff --git a/apps/catalog/app/shared/tool-fit.test.ts b/apps/catalog/app/shared/tool-fit.test.ts index d1a9e27..8886805 100644 --- a/apps/catalog/app/shared/tool-fit.test.ts +++ b/apps/catalog/app/shared/tool-fit.test.ts @@ -4,6 +4,7 @@ import type { Verdict } from './judge' import type { Rule } from './rules' import type { PartFeature } from '@toolpath/part-contracts' import { + closeCandidates, distinctQuestions, fittingTools, overridableCount, @@ -255,3 +256,58 @@ describe('how many questions a group actually asks', () => { expect(answer.excluded[0]?.removed[0]?.text).toContain('flute length') }) }) + +/** + * **A filter is obeyed by the list that stands in for one** (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 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. + */ +describe('the misses that may stand in when nothing fits', () => { + const miss = (guid: string, brand: string, geometry: Readonly>): Verdict => + ({ + tool: { guid, brand, geometry } as unknown as CatalogTool, + removed: [{ rule: bound('flute length'), text: 'flute length over', shortfall: 1 }], + warned: [], + demoted: [], + key: [], + readings: [], + }) as unknown as Verdict + + const excluded = [ + miss('THREE', 'Kennametal', { NOF: 3, LCF: 10 }), + miss('FOUR', 'Kennametal', { NOF: 4, LCF: 10 }), + miss('OTHER', 'WIDIA', { NOF: 3, LCF: 10 }), + ] + + const asking = (ranges: Record) => ({ + text: '', + terms: { brand: ['Kennametal'] }, + ranges, + }) + + it('obeys a bound somebody set themselves', () => { + const kept = closeCandidates(excluded, asking({ NOF: { max: 3 } }), { 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 } }), {}) + + expect(kept.map((each) => each.tool.guid)).toEqual(['THREE', 'FOUR']) + }) + + 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') + }) +}) diff --git a/apps/catalog/app/shared/tool-fit.ts b/apps/catalog/app/shared/tool-fit.ts index cf5debb..31d4760 100644 --- a/apps/catalog/app/shared/tool-fit.ts +++ b/apps/catalog/app/shared/tool-fit.ts @@ -135,19 +135,23 @@ 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. - * The ranges are left aside: they are the rules' bounds, and "close" is - * exactly a tool a little outside them. + * + * 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. */ export const closeCandidates = ( excluded: ReadonlyArray, query: ToolQuery, + own: ToolQuery['ranges'], ): Array => { const { tools: toolQuery, holding } = splitHolding(query) const kept = new Set( holdableTools( filterTools( excluded.map((verdict) => verdict.tool), - { ...toolQuery, ranges: {} }, + { ...toolQuery, ranges: own }, ), holding, ).map((each) => each.guid), diff --git a/apps/catalog/tests/on-the-part.spec.ts b/apps/catalog/tests/on-the-part.spec.ts index 8d2a551..8226c2c 100644 --- a/apps/catalog/tests/on-the-part.spec.ts +++ b/apps/catalog/tests/on-the-part.spec.ts @@ -3600,3 +3600,49 @@ test.describe('Enter with a column filter open', () => { await filterKeepsThePress(page) }) }) + +/** + * **A filter is obeyed by the list that stands in when nothing fits** (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'"). + * + * 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. + * + * 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 + * nothing at all — the answer Paul asked to see. + */ +test('the flute filter binds the list that stands in when nothing fits', async ({ page }) => { + await ready(page) + await keepFeature(page) + await page + .locator('[data-assembly-tree]') + .getByRole('button', { name: /^TOOL for / }) + .click() + + await page.getByRole('button', { name: 'Filter by Flutes', exact: true }).click() + await page.getByRole('combobox', { name: 'How to compare Flutes' }).click() + await page.getByRole('option', { name: '≤ at most' }).click() + const box = page.getByRole('textbox', { name: 'Flutes — value' }) + await box.fill('5') + + // Nothing fits this face, so the list is the closest misses standing in — + // and at most five admits them, which is the half that must survive. + await expect(page).toHaveURL(/max\.NOF=5/) + await expect(page.getByRole('grid').first().getByRole('row').first()).toBeVisible() + + await box.fill('3') + + await expect(page).toHaveURL(/max\.NOF=3/) + await expect(page.getByRole('grid').first().getByRole('row')).toHaveCount(0) + await expect( + page.getByText('No tool in the catalog matches every part of this selection.'), + ).toBeVisible() +}) diff --git a/docs/CATALOG-SPEC.md b/docs/CATALOG-SPEC.md index 421b2ea..99cc8d9 100644 --- a/docs/CATALOG-SPEC.md +++ b/docs/CATALOG-SPEC.md @@ -458,7 +458,13 @@ the order the rules rank them, with a mark on every number the rules read. | A threaded hole | Taps · Drills | two tabs, taps first | - **Nothing fits is never an empty table.** The closest misses are shown - instead, each with the number that stopped it painted red. + 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. - **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/TOOL-ASSEMBLY-TREE.md b/docs/TOOL-ASSEMBLY-TREE.md index 6f2b9be..b8c563f 100644 --- a/docs/TOOL-ASSEMBLY-TREE.md +++ b/docs/TOOL-ASSEMBLY-TREE.md @@ -470,9 +470,10 @@ reach the bottom of a 2.066 in pocket. Two numbers for one length, on one screen. Nothing was wrong with the machinery; the tree walked away from it. The column -asked `Holding.requiredStickout`, which reads a holder picked **in a dropdown on +asked `Holding.requiredStickout`, which read a holder picked **in a dropdown on the row** — and those dropdowns came out with the flag on 2026-09-08, so nothing -has set that holder since and every row fell back to the tool's own figure. +had set that holder since and every row fell back to the tool's own figure. +`Holding` itself is gone as of 2026-09-10; see § 8. A holder is the assembly's now, so the question is asked of the assembly once per row: the least the stack has to stand out to clear the part by the shop's @@ -956,12 +957,23 @@ crib, which is the claim. a bill, where `treeFromLines` puts the tap first however the lines are ordered, because a drill labelled `TAP` opens the tap list on a drill. - **`ComponentTable` is a sibling of `PartToolTable`, not a generalisation.** - That table carries the rules' marks, the holding comboboxes and the bill's - badge, all of which are about a _tool_; threading a row type through them would - have put every one behind a conditional to gain a shared shell. If a third - kind of component ever wants a table, extract then. -- **The tool table is handed no `holding`**, deliberately: the holder is a slot - of the stack with a table of its own, and a second way to set it from a - dropdown on the tool row is the defect the tree exists to remove. The prop is - still on `PartToolTable` — the component's own tests cover it — and the part - page passes it from nowhere. + That table carries the rules' marks and the bill's badge, both of which are + about a _tool_; threading a row type through them would have put every one + behind a conditional to gain a shared shell. If a third kind of component ever + wants a table, extract then. +- **The tool table has no holder and no collet at all.** The holder is a slot of + the stack with a table of its own, and a second way to set it from a dropdown + on the tool row is the defect the tree exists to remove. The page had already + stopped handing the list any `holding`, which left the two columns ticked in + the column picker to draw dashes — so on 2026-09-10 the columns, the + `HoldingCell` behind them and the `holding` prop came off `PartToolTable` + together. `components/tool-columns.test.ts` § _offers neither a holder nor a + collet_ is the sensor. +- **Neither does the tool panel.** `ToolDetails` kept a _No holder_ / _No + collet_ pair of its own, drawn on the panel with no feature open — the last of + the pre-tree dropdowns, and a second place a stack could be assembled. They + came off the same day, and with them the `Holding` contract, the `holding` + memo in `routes/part.tsx` and the per-tool `holderOptions` cache that fed it. + The panel draws whatever `stack` the tree hands it and nothing else, so a + holder reaches the sheet one way. `components/tool-details.test.tsx` § + _the material around the feature_ is that seam, drawn from `stack` alone. From 85a7e5150c3dc7a0c0a1fa63f8ac5ad90b3291df Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Thu, 10 Sep 2026 16:13:34 -0400 Subject: [PATCH 03/12] compatible tools/filtering fix - reduced shank tools not showing --- .../app/client/catalog-matcher.worker.ts | 10 +- apps/catalog/app/routes/part.tsx | 615 ++++++++++-------- .../app/shared/catalog-matcher.test.ts | 42 ++ apps/catalog/app/shared/catalog-matcher.ts | 63 +- docs/TOOL-ASSEMBLY-TREE.md | 99 +-- 5 files changed, 495 insertions(+), 334 deletions(-) diff --git a/apps/catalog/app/client/catalog-matcher.worker.ts b/apps/catalog/app/client/catalog-matcher.worker.ts index a7d487d..c29df15 100644 --- a/apps/catalog/app/client/catalog-matcher.worker.ts +++ b/apps/catalog/app/client/catalog-matcher.worker.ts @@ -77,7 +77,15 @@ const poolFor = ( demand: MatchRequest['demands'][number], ): ReadonlyArray => { const widened = { ...context, query: withoutFacets(context.query) } - const key = matchKey('table', widened, [demand]) + /* + **The stack is left out of the key, deliberately.** What it narrows is the + counts taken *over* this pool, never the pool itself — `detailedMatch` + § `counted` is where it is applied — so a holder or a collet picked in the + tree must not evict a judging pass that would come back identical. + `stable` drops an undefined value, so this is the key a demand with no + stack writes. + */ + const key = matchKey('table', widened, [{ ...demand, stack: undefined }]) const cached = pools.get(key) if (cached !== undefined) { return cached diff --git a/apps/catalog/app/routes/part.tsx b/apps/catalog/app/routes/part.tsx index 3ff9542..4dd1aa9 100644 --- a/apps/catalog/app/routes/part.tsx +++ b/apps/catalog/app/routes/part.tsx @@ -1531,6 +1531,270 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: () => ownBounds(query.ranges, suggestions.ranges), [query.ranges, suggestions], ) + /* + The tree, the row it belongs to and the key it is stored under stand ahead + of the matcher because the matcher is told about them (2026-09-10): the + counts a filter offers are measured over the pool the tool table draws, and + the table under a stack is that pool with `narrowTools` on it. See + `MatchDemand.stack` in `shared/catalog-matcher.ts`. + */ + /** Which feature the choice is for: the one being read, or the part as a whole. */ + /** + * Which feature the choice is for: the one being read, or the part as a whole. + * + * **The group's own tag, not the sibling that happened to be clicked** (Paul, + * 2026-09-02: "if a tool is on the list and a holder is added to it, it + * should update the existing tool on the BOM rather than create a new one"). + * Eight identical holes are one decision everywhere else on this page; keyed + * by whichever of them was under the mouse, the panel wrote a second line + * beside the one the feature list had already put there. + */ + const choiceKey = useMemo(() => { + if (focused === null) { + return '*' + } + /* + **The row's key, where a row holds this reading** (Paul, 2026-09-09). + It used to be the *hole group's* first tag, which was the same thing while + every row held whole hole groups — a hole cannot be asked about alone any + more than a bolt circle could. It is not the same thing now: a feature + made from the second hole of a group is keyed by that hole, and a panel + still keying by the first wrote its lines where nothing read them. + + Reading the list is also the honest way to state the 2026-09-02 rule: a + sibling clicked on a group already on the list updates that group's line + rather than opening a second one beside it, whatever grouping is doing. + */ + const holder = list.find((item) => item.tags.includes(focused)) + return holder ? (sheetKeysOf(holder)[0] ?? focused) : focused + }, [focused, list]) + + /** + * The row on the list the panel's buttons act on, where there is one. + * + * A previewed feature can already be on the list — clicking it on the part is + * how somebody goes back to it — so this is not simply the selected row. + */ + const activeItem = useMemo( + () => + /* + **An assembly being built is not a row, and is not any other row + either** (Paul, 2026-09-08). It asks about no features, so the search + below would hand back whichever row happened to match nothing — and the + press under the stack would write that row's lines instead of making + its own. + */ + (draft?.kind === 'assembly' ? null : selectedItem) ?? + /* + **A question about nothing matches no row** (Paul, 2026-09-07: "when no + feature is selected, the tool table should not show any assemblies"). + `[].every(...)` is true of every item, so with nothing being asked this + matched whichever row happened to be first and handed it out as the row + in play. `toolActions` never noticed — it is gated on `active` — but the + assembly tree took the row at its word and drew that feature's stacks + under a table showing the whole catalog. + */ + (askedNow.tags.length === 0 + ? null + : draft?.kind === 'assembly' + ? null + : /* + **A group being built is not the feature it holds** (Paul, + 2026-09-09, with the group editor's own confirm gone). A group of + one hole that is already a row matches that row here, so the press + under the stack read as *add a tool to that feature* and the group + was never made — the confirm button used to make it, and there is + no confirm button any more. A group being *edited* is the row it + is editing, which the search below still finds. + */ + draft?.kind === 'group' && draft.editing === null + ? null + : list.find((item) => askedNow.tags.every((tag) => item.tags.includes(tag)))) ?? + null, + [selectedItem, list, askedNow.tags, draft?.kind, draft?.editing], + ) + + /* ----------------------- the tool assembly tree ------------------------- */ + + /** + * The shape being tried out, and the way back off it. + * + * **A feature is answered with assemblies, not tools** (Paul, 2026-09-07). A + * cutter is chosen with a holder and a collet; a threaded hole needs two + * stacks before it is a hole at all. The page asked for a tool and hung the + * other two off it as dropdowns, which made a holder a footnote on a tool and + * made the second stack for a feature unreachable. + * + * Behind a flag, switched in the header, because it replaces enough of this + * page at once that going back has to be a press rather than a revert. + */ + /** Which slot of which stack is open, and the row it belongs to. */ + const [nodeHeld, setNodeHeld] = useState<{ itemId: string; node: TreeNode } | null>(null) + const [holderQuery, setHolderQuery] = useState(NO_QUERY) + const [colletQuery, setColletQuery] = useState(NO_QUERY) + /** + * Whether the rack shows the chucks the crib has no collet for. + * + * **Off to begin with** (Paul, 2026-09-10: "by default, the holders with no + * collet should be hidden"). The rack opens on what the shop can actually + * build this afternoon, and the press asks the wider question — what could + * hold this, if a collet were ordered. The rule they are hidden *by* is the + * same one either way, and the count on the press is what stops the narrower + * rack reading as an empty crib. + * + * `components/no-collet-toggle.tsx` is the press and says why it exists. Not + * a filter: it is counted and cleared nowhere near `Clear n filters`, because + * it puts rows on the list rather than taking them off, and a shop that + * 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), + ) + + /** + * Whose tree is on screen. + * + * **A draft has one before it is a row** (Paul, 2026-09-07: "when a new + * feature or group is selected (being created), the tree should already be + * shown"). Waiting for the row meant the bottom of the page had nothing in it + * during the one moment somebody is actually deciding — and the tools for the + * draft were already listed under it, which made the empty column beside them + * read as broken rather than as not-yet. + * + * The draft's stacks are kept under {@link DRAFT_TREE} and carried onto the + * row's own id when it is confirmed, so nothing built before the press is + * lost by making it. + * + * **Being created is wider than `draft`.** A plain click on a face previews a + * reading and offers the two ways in without opening a draft at all — that is + * `asked()`'s third row — and it is the commonest way a feature gets made. So + * anything being *asked* with no row of its own gets the draft's tree, which + * covers the preview and the group editor alike. + */ + const treeKey = + /* + **Only the group editor takes the plain key** (Paul, 2026-09-07: "new hole + selections should be treated as new"). Its tags change under the mouse as + faces are toggled, so a key that moved with them would reset the tree + mid-build. A *feature* draft has settled tags and was taking that key as + well — and the key is kept in the browser, so the tap and drill stacks + built for one hole were still standing the next time anybody pressed + *Add feature*, on a hole nobody had called threaded. + */ + draft?.kind === 'group' + ? DRAFT_TREE + : /* + **An assembly being built keeps its stacks under a key of its own.** + It has no tags, so `draftKeyFor([])` is that key — and `carryDraftTree` + reads the same one when the press makes the row, so nothing built + before the press is lost by making it. + */ + draft?.kind === 'assembly' + ? draftKeyFor([]) + : /* + **A part-level assembly is a tree with no question above it** (Paul, + 2026-09-08). It asks about no feature, so `asking` is false for it — + and the row is still the thing being built, keyed by its own id like + every other row's tree. + */ + selectedItem?.kind === 'assembly' + ? selectedItem.id + : // Nothing asked is nothing to assemble: the table is the whole catalog + // then, and a tree beside it would be answering for a feature nobody + // has selected. + !asking + ? null + : draft !== null + ? draftKeyFor(askedNow.tags) + : (activeItem?.id ?? draftKeyFor(askedNow.tags)) + /** + * The stacks for that row — what was built, or what the bill already holds. + * + * A part answered before this shape existed, or with the flag off, has lines + * on the sheet and no tree; `treeFromLines` opens on those rather than on an + * empty stack, so switching the flag on does not read as work lost. + * + * **And whatever it opens on, the roles follow the thread.** A tree is kept + * in the browser and the thread a hole is read for is not, so a tap stack + * outlived the reading that asked for it — `forThread` is that rule, and it + * leaves any stack somebody has put a component in exactly as it stands. + */ + const assemblies = useMemo>(() => { + if (treeKey === null) { + return [] + } + const threaded = threadSpec !== null + const kept = trees[treeKey] + if (kept !== undefined) { + return forThread(kept, threaded) + } + /* + A draft opens on empty stacks rather than on the bill: it has no lines + yet, and reading the focused feature's would show another row's answers + under a feature being created. + */ + return draft !== null || treeKey === DRAFT_TREE + ? defaultAssemblies(threaded) + : treeFromLines( + /* + A part-level assembly's lines are kept under its own id — it has no + feature to be keyed by — so reading `choiceKey` here would open it + on whatever face was last clicked. Every key of the row it is, + because that is where the lines were written (`shared/order-list`). + */ + linesOf(sheet, selectedItem === null ? [choiceKey] : sheetKeysOf(selectedItem)), + threaded, + // Which line is the tap is a fact about the tool, and the catalog is + // the route's to read. + (toolGuid) => getTool(toolGuid)?.form.startsWith('tap ') ?? false, + ) + }, [trees, treeKey, sheet, choiceKey, threadSpec, draft, selectedItem]) + + /** + * The slot open now: what was clicked while it still exists, else the first + * question the tree has not been answered. + * + * Derived rather than kept in an effect, and stamped with the row it was + * clicked on — every tree starts at `assembly-1`, so a node held across a + * change of row would land on a different feature's stack of the same name. + */ + const node = useMemo(() => { + const held = + nodeHeld !== null && + nodeHeld.itemId === treeKey && + assemblies.some((each) => each.id === nodeHeld.node.assemblyId) + ? nodeHeld.node + : null + return held ?? firstNode(assemblies) + }, [nodeHeld, treeKey, assemblies]) + + const selectNode = useCallback( + (next: TreeNode) => { + if (treeKey !== null) { + setNodeHeld({ itemId: treeKey, node: next }) + } + }, + [treeKey], + ) + + const assembly = useMemo( + () => assemblyNamed(assemblies, node?.assemblyId ?? null), + [assemblies, node], + ) + const treeTool = assembly?.toolGuid == null ? null : getTool(assembly.toolGuid) + const treeHolder = assembly?.holderGuid == null ? null : getHolder(assembly.holderGuid) + const treeCollet = assembly?.colletGuid == null ? null : getCollet(assembly.colletGuid) + const tableContext = useMemo( () => ({ features: report.features, @@ -1576,8 +1840,26 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: tags: askedNow.tags, ...(Object.keys(bores).length === 0 ? {} : { bores }), reachTag: focused, + /* + **What the open stack holds, so a count is measured over the rows it + stands beside** (Paul, 2026-09-10). `treeToolRows` narrows this answer + by the holder and the collet already chosen; without them the worker + counted what the *crib* could hold and offered six tools over a table + showing none. `MatchDemand.stack` is the rule. + */ + stack: { holderGuid: assembly?.holderGuid ?? null, colletGuid: assembly?.colletGuid ?? null }, } - }, [asking, perFeature, threadSpec, holeChoice.mode, focused, report.features, askedNow.tags]) + }, [ + asking, + perFeature, + threadSpec, + holeChoice.mode, + focused, + report.features, + askedNow.tags, + assembly?.holderGuid, + assembly?.colletGuid, + ]) const tableKey = useMemo( () => (tableDemand === null ? null : matchKey('table', tableContext, [tableDemand])), [tableContext, tableDemand], @@ -1883,6 +2165,20 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: // every drill on the sheet's "closest to the hole diameter" row. return drillsFirst(shownTools.filter((each) => predrillForms.includes(each.form))) }, [asking, catalogList, tools, closest, overrideTools, drillsOnly, predrillForms]) + /** + * The same rows with the open stack's own narrowing on them. + * + * **A count is measured over the list it stands beside** (Paul, 2026-09-10). + * The tool table under an assembly is {@link treeToolRows}, which is this + * list narrowed by the holder and the collet already chosen — so a count + * taken off `listed` offers tools the table has no intention of drawing. The + * matcher's own counts obey the same rule through `MatchDemand.stack`; this + * is the half that answers while the worker has nothing to say. + */ + const listedInStack = useMemo( + () => narrowTools(listed, { holder: treeHolder, collet: treeCollet }, allCollets), + [listed, treeHolder, treeCollet, allCollets], + ) /** * The counts the **matcher** measured, where a facet is narrowing the list. * @@ -1925,9 +2221,9 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: const axisCounts = useMemo( () => asking - ? (judgedFacets ?? countsByAxis(listed, EMPTY_QUERY, FACET_AXES)) + ? (judgedFacets ?? countsByAxis(listedInStack, EMPTY_QUERY, FACET_AXES)) : countsByAxis(allTools, effectiveQuery, FACET_AXES), - [asking, allTools, listed, effectiveQuery, judgedFacets], + [asking, allTools, listedInStack, effectiveQuery, judgedFacets], ) /** * What each axis is offering, which is not always what it can still count. @@ -1964,8 +2260,9 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: /** One axis's values as the pickers read them, however they were arrived at. */ const countsOn = useCallback( - (axis: string): ReadonlyMap => axisOptions.get(axis) ?? countBy(listed, axis), - [axisOptions, listed], + (axis: string): ReadonlyMap => + axisOptions.get(axis) ?? countBy(listedInStack, axis), + [axisOptions, listedInStack], ) /** @@ -2427,60 +2724,29 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: Object.values(sheet.choices) .flat() .map((choice) => choice.toolGuid), - ), - [sheet], - ) - - /** - * The features with a tool on the bill, for the part to mark. - * - * All that is left of the cards that used to float beside the part (Paul, - * 2026-09-02): what they said is on the feature list now, and what they - * painted is this. - * - * A guid that no longer resolves is not tooled — Justin Gray's rule that a - * reference lives, kept from the cards. - */ - const tooled = useMemo( - () => - Object.entries(sheet.choices).flatMap(([featureTag, kept]) => - kept.some((choice) => allTools.some((one) => one.guid === choice.toolGuid)) - ? [featureTag] - : [], - ), - [sheet, allTools], - ) - - /** Which feature the choice is for: the one being read, or the part as a whole. */ - /** - * Which feature the choice is for: the one being read, or the part as a whole. - * - * **The group's own tag, not the sibling that happened to be clicked** (Paul, - * 2026-09-02: "if a tool is on the list and a holder is added to it, it - * should update the existing tool on the BOM rather than create a new one"). - * Eight identical holes are one decision everywhere else on this page; keyed - * by whichever of them was under the mouse, the panel wrote a second line - * beside the one the feature list had already put there. - */ - const choiceKey = useMemo(() => { - if (focused === null) { - return '*' - } - /* - **The row's key, where a row holds this reading** (Paul, 2026-09-09). - It used to be the *hole group's* first tag, which was the same thing while - every row held whole hole groups — a hole cannot be asked about alone any - more than a bolt circle could. It is not the same thing now: a feature - made from the second hole of a group is keyed by that hole, and a panel - still keying by the first wrote its lines where nothing read them. + ), + [sheet], + ) - Reading the list is also the honest way to state the 2026-09-02 rule: a - sibling clicked on a group already on the list updates that group's line - rather than opening a second one beside it, whatever grouping is doing. - */ - const holder = list.find((item) => item.tags.includes(focused)) - return holder ? (sheetKeysOf(holder)[0] ?? focused) : focused - }, [focused, list]) + /** + * The features with a tool on the bill, for the part to mark. + * + * All that is left of the cards that used to float beside the part (Paul, + * 2026-09-02): what they said is on the feature list now, and what they + * painted is this. + * + * A guid that no longer resolves is not tooled — Justin Gray's rule that a + * reference lives, kept from the cards. + */ + const tooled = useMemo( + () => + Object.entries(sheet.choices).flatMap(([featureTag, kept]) => + kept.some((choice) => allTools.some((one) => one.guid === choice.toolGuid)) + ? [featureTag] + : [], + ), + [sheet, allTools], + ) /** * What is already kept for the feature being read. @@ -3670,231 +3936,14 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: selectRow(null) }, [draft, cancelDraft, selectRow]) - /** - * The row on the list the panel's buttons act on, where there is one. - * - * A previewed feature can already be on the list — clicking it on the part is - * how somebody goes back to it — so this is not simply the selected row. - */ - const activeItem = useMemo( - () => - /* - **An assembly being built is not a row, and is not any other row - either** (Paul, 2026-09-08). It asks about no features, so the search - below would hand back whichever row happened to match nothing — and the - press under the stack would write that row's lines instead of making - its own. - */ - (draft?.kind === 'assembly' ? null : selectedItem) ?? - /* - **A question about nothing matches no row** (Paul, 2026-09-07: "when no - feature is selected, the tool table should not show any assemblies"). - `[].every(...)` is true of every item, so with nothing being asked this - matched whichever row happened to be first and handed it out as the row - in play. `toolActions` never noticed — it is gated on `active` — but the - assembly tree took the row at its word and drew that feature's stacks - under a table showing the whole catalog. - */ - (askedNow.tags.length === 0 - ? null - : draft?.kind === 'assembly' - ? null - : /* - **A group being built is not the feature it holds** (Paul, - 2026-09-09, with the group editor's own confirm gone). A group of - one hole that is already a row matches that row here, so the press - under the stack read as *add a tool to that feature* and the group - was never made — the confirm button used to make it, and there is - no confirm button any more. A group being *edited* is the row it - is editing, which the search below still finds. - */ - draft?.kind === 'group' && draft.editing === null - ? null - : list.find((item) => askedNow.tags.every((tag) => item.tags.includes(tag)))) ?? - null, - [selectedItem, list, askedNow.tags, draft?.kind, draft?.editing], - ) - - /* ----------------------- the tool assembly tree ------------------------- */ - - /** - * The shape being tried out, and the way back off it. - * - * **A feature is answered with assemblies, not tools** (Paul, 2026-09-07). A - * cutter is chosen with a holder and a collet; a threaded hole needs two - * stacks before it is a hole at all. The page asked for a tool and hung the - * other two off it as dropdowns, which made a holder a footnote on a tool and - * made the second stack for a feature unreachable. - * - * Behind a flag, switched in the header, because it replaces enough of this - * page at once that going back has to be a press rather than a revert. - */ - /** Which slot of which stack is open, and the row it belongs to. */ - const [nodeHeld, setNodeHeld] = useState<{ itemId: string; node: TreeNode } | null>(null) - const [holderQuery, setHolderQuery] = useState(NO_QUERY) - const [colletQuery, setColletQuery] = useState(NO_QUERY) - /** - * Whether the rack shows the chucks the crib has no collet for. - * - * **Off to begin with** (Paul, 2026-09-10: "by default, the holders with no - * collet should be hidden"). The rack opens on what the shop can actually - * build this afternoon, and the press asks the wider question — what could - * hold this, if a collet were ordered. The rule they are hidden *by* is the - * same one either way, and the count on the press is what stops the narrower - * rack reading as an empty crib. - * - * `components/no-collet-toggle.tsx` is the press and says why it exists. Not - * a filter: it is counted and cleared nowhere near `Clear n filters`, because - * it puts rows on the list rather than taking them off, and a shop that - * 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), - ) - - /** - * Whose tree is on screen. - * - * **A draft has one before it is a row** (Paul, 2026-09-07: "when a new - * feature or group is selected (being created), the tree should already be - * shown"). Waiting for the row meant the bottom of the page had nothing in it - * during the one moment somebody is actually deciding — and the tools for the - * draft were already listed under it, which made the empty column beside them - * read as broken rather than as not-yet. - * - * The draft's stacks are kept under {@link DRAFT_TREE} and carried onto the - * row's own id when it is confirmed, so nothing built before the press is - * lost by making it. - * - * **Being created is wider than `draft`.** A plain click on a face previews a - * reading and offers the two ways in without opening a draft at all — that is - * `asked()`'s third row — and it is the commonest way a feature gets made. So - * anything being *asked* with no row of its own gets the draft's tree, which - * covers the preview and the group editor alike. - */ - const treeKey = - /* - **Only the group editor takes the plain key** (Paul, 2026-09-07: "new hole - selections should be treated as new"). Its tags change under the mouse as - faces are toggled, so a key that moved with them would reset the tree - mid-build. A *feature* draft has settled tags and was taking that key as - well — and the key is kept in the browser, so the tap and drill stacks - built for one hole were still standing the next time anybody pressed - *Add feature*, on a hole nobody had called threaded. - */ - draft?.kind === 'group' - ? DRAFT_TREE - : /* - **An assembly being built keeps its stacks under a key of its own.** - It has no tags, so `draftKeyFor([])` is that key — and `carryDraftTree` - reads the same one when the press makes the row, so nothing built - before the press is lost by making it. - */ - draft?.kind === 'assembly' - ? draftKeyFor([]) - : /* - **A part-level assembly is a tree with no question above it** (Paul, - 2026-09-08). It asks about no feature, so `asking` is false for it — - and the row is still the thing being built, keyed by its own id like - every other row's tree. - */ - selectedItem?.kind === 'assembly' - ? selectedItem.id - : // Nothing asked is nothing to assemble: the table is the whole catalog - // then, and a tree beside it would be answering for a feature nobody - // has selected. - !asking - ? null - : draft !== null - ? draftKeyFor(askedNow.tags) - : (activeItem?.id ?? draftKeyFor(askedNow.tags)) - /** - * The stacks for that row — what was built, or what the bill already holds. - * - * A part answered before this shape existed, or with the flag off, has lines - * on the sheet and no tree; `treeFromLines` opens on those rather than on an - * empty stack, so switching the flag on does not read as work lost. - * - * **And whatever it opens on, the roles follow the thread.** A tree is kept - * in the browser and the thread a hole is read for is not, so a tap stack - * outlived the reading that asked for it — `forThread` is that rule, and it - * leaves any stack somebody has put a component in exactly as it stands. - */ - const assemblies = useMemo>(() => { - if (treeKey === null) { - return [] - } - const threaded = threadSpec !== null - const kept = trees[treeKey] - if (kept !== undefined) { - return forThread(kept, threaded) - } - /* - A draft opens on empty stacks rather than on the bill: it has no lines - yet, and reading the focused feature's would show another row's answers - under a feature being created. - */ - return draft !== null || treeKey === DRAFT_TREE - ? defaultAssemblies(threaded) - : treeFromLines( - /* - A part-level assembly's lines are kept under its own id — it has no - feature to be keyed by — so reading `choiceKey` here would open it - on whatever face was last clicked. Every key of the row it is, - because that is where the lines were written (`shared/order-list`). - */ - linesOf(sheet, selectedItem === null ? [choiceKey] : sheetKeysOf(selectedItem)), - threaded, - // Which line is the tap is a fact about the tool, and the catalog is - // the route's to read. - (toolGuid) => getTool(toolGuid)?.form.startsWith('tap ') ?? false, - ) - }, [trees, treeKey, sheet, choiceKey, threadSpec, draft, selectedItem]) - - /** - * The slot open now: what was clicked while it still exists, else the first - * question the tree has not been answered. - * - * Derived rather than kept in an effect, and stamped with the row it was - * clicked on — every tree starts at `assembly-1`, so a node held across a - * change of row would land on a different feature's stack of the same name. - */ - const node = useMemo(() => { - const held = - nodeHeld !== null && - nodeHeld.itemId === treeKey && - assemblies.some((each) => each.id === nodeHeld.node.assemblyId) - ? nodeHeld.node - : null - return held ?? firstNode(assemblies) - }, [nodeHeld, treeKey, assemblies]) - - const selectNode = useCallback( - (next: TreeNode) => { - if (treeKey !== null) { - setNodeHeld({ itemId: treeKey, node: next }) - } - }, - [treeKey], - ) - - const assembly = useMemo( - () => assemblyNamed(assemblies, node?.assemblyId ?? null), - [assemblies, node], - ) - const treeTool = assembly?.toolGuid == null ? null : getTool(assembly.toolGuid) - const treeHolder = assembly?.holderGuid == null ? null : getHolder(assembly.holderGuid) - const treeCollet = assembly?.colletGuid == null ? null : getCollet(assembly.colletGuid) + /* + **The tool assembly tree is declared above the matcher**, not here + (2026-09-10). The counts beside a filter are measured over the pool the + table draws, and the table under a stack is `narrowTools` applied to that + pool — so the worker has to be told what the open slot already holds, and + `tableDemand` is built long before this point. `MatchDemand.stack` in + `shared/catalog-matcher.ts` states the rule the move is for. + */ /** * The length below the holder each row in the tool list would stand at. diff --git a/apps/catalog/app/shared/catalog-matcher.test.ts b/apps/catalog/app/shared/catalog-matcher.test.ts index 9e4c88b..518f432 100644 --- a/apps/catalog/app/shared/catalog-matcher.test.ts +++ b/apps/catalog/app/shared/catalog-matcher.test.ts @@ -502,6 +502,48 @@ describe('the counts an axis offers while it is narrowing', () => { expect(result.facetCounts?.type).toEqual({ 'Flat end mill': 1 }) }) + /** + * **A count is measured over the rows it stands beside** (Paul, 2026-09-10: a + * pocket offering six necked bull nose end mills over a table holding none). + * The tool table under an assembly is `narrowTools` applied to this answer, + * so a collet in the stack takes every shank it cannot close on off the + * screen — and the counts knew nothing about it. + */ + it('counts only what the collet in the stack can hold', () => { + const wider: Collet = { + ...collet, + guid: 'collet-2', + catalogNumber: 'PG6-7', + clampMin: 7, + clampMax: 7, + } + const stocked = { tools: crib.tools, holders: [holder], collets: [collet, wider] } + const query = asked({ brand: ['Kennametal'] }) + + // The crib holds both shanks, so all three tools are on the widened pool. + expect(detailedMatch(query, demand, stocked).facetCounts?.brand).toEqual({ + Kennametal: 1, + WIDIA: 2, + }) + + const inStack = { ...demand, stack: { holderGuid: holder.guid, colletGuid: collet.guid } } + + // `collet-1` closes on ⌀6 alone, which is the ⌀7 WIDIA tool off the table. + expect(detailedMatch(query, inStack, stocked).facetCounts?.brand).toEqual({ + Kennametal: 1, + WIDIA: 1, + }) + }) + + it('leaves a stack whose guids name nothing in this crib alone', () => { + const stocked = { tools: crib.tools, holders: [holder], collets: [collet] } + const gone = { ...demand, stack: { holderGuid: 'holder-gone', colletGuid: 'collet-gone' } } + + expect( + detailedMatch(asked({ brand: ['Kennametal'] }), gone, stocked).facetCounts?.brand, + ).toEqual({ Kennametal: 1, WIDIA: 1 }) + }) + it("leaves a caller's widened pool alone rather than judging a second time", () => { const pool = facetPool(asked({ brand: ['Kennametal'] }), demand, crib) diff --git a/apps/catalog/app/shared/catalog-matcher.ts b/apps/catalog/app/shared/catalog-matcher.ts index de6b4a8..26673cd 100644 --- a/apps/catalog/app/shared/catalog-matcher.ts +++ b/apps/catalog/app/shared/catalog-matcher.ts @@ -12,6 +12,7 @@ import { withoutTerm, type ToolQuery, } from './filter' +import { narrowTools } from './assembly-narrowing' import { holdable, policyOf, type HoldThresholds } from './holder-choice' import { holdableTools, splitHolding } from './holding' import { closestMisses, closestPerForm, type Format, type Reason, type Verdict } from './judge' @@ -75,6 +76,29 @@ export interface MatchDemand { readonly bores?: Readonly> /** The feature whose reach curve decides whether an assembly is usable. */ readonly reachTag?: string | null + /** + * What the open stack already holds, by guid — the narrowing the table puts + * on its own rows after this answer lands. + * + * **A count has to be measured over the list it is beside** (Paul, + * 2026-09-10: a pocket offering six necked bull nose end mills over a table + * holding none). The tool table under an assembly is `narrowTools` + * (`shared/assembly-narrowing.ts`) applied to this answer's rows, so a collet + * in the stack takes every shank it cannot close on off the screen — and + * `facetCounts` knew nothing about it, counting the tools the *crib* could + * hold rather than the ones this stack can. An ER32 collet bored for ⌀0.375 + * grips eleven of the catalog's 2,638 necked bull nose end mills, which is + * the whole of the gap between the number and the rows. + * + * Only the counts read it: the rows themselves are narrowed on the page, + * where the holder and the collet are chosen. It is on the demand rather than + * the context because it is one stack's answer, and the worker's pool cache + * deliberately leaves it out of its key — `poolFor` says why. + */ + readonly stack?: { + readonly holderGuid?: string | null + readonly colletGuid?: string | null + } } export type MatchKind = 'table' | 'recommendations' @@ -633,6 +657,34 @@ export const facetPool = ( ) } +/** + * The pool as the table would actually draw it: narrowed by the open stack. + * + * `MatchDemand.stack` is the reason and states what it cost. The guids are + * resolved here rather than sent as records because the worker holds the crib + * already, and a holder is ~40 fields nobody needs a second copy of. + */ +const forStack = ( + pool: ReadonlyArray, + demand: MatchDemand, + catalog: MatcherCatalog, +): ReadonlyArray => { + const holderGuid = demand.stack?.holderGuid ?? null + const colletGuid = demand.stack?.colletGuid ?? null + if (holderGuid === null && colletGuid === null) { + return pool + } + const holder = catalog.holders.find((each) => each.guid === holderGuid) ?? null + const collet = catalog.collets.find((each) => each.guid === colletGuid) ?? null + // A guid naming nothing in this crib narrows nothing: the stack is stored on + // the sheet and the catalog under it can be rebuilt, and a count measured as + // though the slot were empty is the same answer the table's own narrowing + // gives for the same missing record. + return holder === null && collet === null + ? pool + : narrowTools(pool, { holder, collet }, catalog.collets) +} + /** One pool read per axis, each against every filter but that axis's own. */ const countsOverPool = ( pool: ReadonlyArray, @@ -665,6 +717,15 @@ export const detailedMatch = ( const widened = !facetsNarrowing(context.query) ? null : (pool ?? facetPool(context, demand, catalog)) + /** + * The pool the counts are taken over, which is the pool the table draws. + * + * Narrowed here rather than in {@link facetPool} so the pool itself stays the + * facet-free question and the worker can keep one across every holder and + * collet tried in the stack — the judging pass is what that cache exists to + * save, and the stack does not change it. + */ + const counted = widened === null ? null : forStack(widened, demand, catalog) return { demandKey: demand.demandKey, fitting: matched.fitting.map(compact), @@ -694,7 +755,7 @@ export const detailedMatch = ( * against a list that is itself a stand-in would read as an answer. */ facetCounts: - widened === null || widened.length === 0 ? null : countsOverPool(widened, context.query), + counted === null || counted.length === 0 ? null : countsOverPool(counted, context.query), } } diff --git a/docs/TOOL-ASSEMBLY-TREE.md b/docs/TOOL-ASSEMBLY-TREE.md index b8c563f..3637269 100644 --- a/docs/TOOL-ASSEMBLY-TREE.md +++ b/docs/TOOL-ASSEMBLY-TREE.md @@ -889,55 +889,56 @@ gets it. ## 7. Where the rules live -| Rule | File | -| ------------------------------------------------- | ------------------------------------------------ | -| what a tree holds, its slots, its storage | `app/shared/assembly-tree.ts` | -| what a threaded hole starts with | `defaultAssemblies`, same file | -| the stacks following the thread on the hole read | `forThread`, same file | -| how the stacks nest, and their drawing order | `treeRows`, same file | -| what narrows what | `app/shared/assembly-narrowing.ts` | -| whether a chuck is worth offering with no collet | `holderMayTake`, `@toolpath/catalog-data` | -| why an offered chuck cannot be built today | `colletGap`, `app/shared/assembly-narrowing.ts` | -| whether the rack shows them, and how many | `holdersToOffer` `shown` / `stocked`, same file | -| the press that decides, and its words | `app/components/no-collet-toggle.tsx` | -| what a stack offers, and its button's words | `app/shared/assembly-actions.ts` | -| which press orders, and which Enter stands for | `isOrdering` / `orderingPress`, same file | -| which layer one press of Enter or Escape reaches | `useKeyLayer`, `app/shared/use-escape.ts` | -| whether the page stands down for an open filter | `columnFilterOpen`, same file | -| how tall a filter menu is, and which way it opens | `place`, `components/column-filter.tsx` | -| what overruling the rules offers | `overridableTools`, `app/shared/tool-fit.ts` | -| what an axis has that the list is not showing | `hiddenOn`, `app/routes/part.tsx` | -| the `…` row that offers it | `TermFilter`, `components/column-filter.tsx` | -| the form behind a Type phrase | `formOfTypeLabel`, `app/shared/tool-type.ts` | -| what a tick on Type asks the form filter | `formsAsking`, same file | -| the forms the type table stands down for | `asked`, `app/shared/judge.ts` | -| how many questions a group actually asks | `distinctQuestions`, `app/shared/tool-fit.ts` | -| folding one tool's verdicts as they are judged | `foldOnto`, `app/shared/judge.ts` | -| what a threaded hole's drill list may show | `predrillFormsOf`, `app/shared/hole-mode.ts` | -| how many each column alone holds back | `overridableTally`, same file | -| which column a rule is about | `columnOfRule`, `app/shared/tool-marks.ts` | -| the note a changed filter raises | `OverrideNotice`, `components/column-filter.tsx` | -| the press that confirms it | `OverrideToggle`, same file | -| `at most` meaning at most across a unit change | `BOUND_SLACK`, `app/shared/filter.ts` | -| which slots were filled against the rules | `overrides`, `assembly-tree.ts` | -| the words a warning says | `overrideNote`, `app/shared/tool-marks.ts` | -| what a whole assembly offers, over all its stacks | `groupActions`, same file | -| which stacks make up one assembly | `treeGroups` / `stacksOf`, `assembly-tree.ts` | -| what the bill already holds for a stack | `savedFor`, same file | -| putting a stack back to the bill's line | `restoreAssembly`, `assembly-tree.ts` | -| what a stack is called, and where a part stands | `assemblyName` / `heldIn`, same file | -| the name somebody gave a stack, and clearing it | `renameAssembly`, same file | -| the field a name is typed in, in both places | `app/components/name-field.tsx` | -| the length below the holder a stack needs | `belowHolder`, `app/shared/drawn-assembly.ts` | -| the words the clearance verdict is said in | `verdictNote`, `components/catalog-drawing.tsx` | -| the columns a holder and a collet are read on | `app/shared/component-columns.ts` | -| narrowing a rack by brand, type, family, a number | `app/shared/component-query.ts` | -| which of the three lists the table is | `listKind` / `chooseList`, `routes/part.tsx` | -| the tree on screen | `app/components/assembly-tree-panel.tsx` | -| the holder and collet tables | `app/components/component-table.tsx` | -| their filters | `app/components/component-filters.tsx` | -| the component being read | `app/components/assembly-panel.tsx` | -| everything wired together | `app/routes/part.tsx` | +| Rule | File | +| ------------------------------------------------- | ---------------------------------------------------- | +| what a tree holds, its slots, its storage | `app/shared/assembly-tree.ts` | +| what a threaded hole starts with | `defaultAssemblies`, same file | +| the stacks following the thread on the hole read | `forThread`, same file | +| how the stacks nest, and their drawing order | `treeRows`, same file | +| what narrows what | `app/shared/assembly-narrowing.ts` | +| the same narrowing under a filter's own counts | `MatchDemand.stack`, `app/shared/catalog-matcher.ts` | +| whether a chuck is worth offering with no collet | `holderMayTake`, `@toolpath/catalog-data` | +| why an offered chuck cannot be built today | `colletGap`, `app/shared/assembly-narrowing.ts` | +| whether the rack shows them, and how many | `holdersToOffer` `shown` / `stocked`, same file | +| the press that decides, and its words | `app/components/no-collet-toggle.tsx` | +| what a stack offers, and its button's words | `app/shared/assembly-actions.ts` | +| which press orders, and which Enter stands for | `isOrdering` / `orderingPress`, same file | +| which layer one press of Enter or Escape reaches | `useKeyLayer`, `app/shared/use-escape.ts` | +| whether the page stands down for an open filter | `columnFilterOpen`, same file | +| how tall a filter menu is, and which way it opens | `place`, `components/column-filter.tsx` | +| what overruling the rules offers | `overridableTools`, `app/shared/tool-fit.ts` | +| what an axis has that the list is not showing | `hiddenOn`, `app/routes/part.tsx` | +| the `…` row that offers it | `TermFilter`, `components/column-filter.tsx` | +| the form behind a Type phrase | `formOfTypeLabel`, `app/shared/tool-type.ts` | +| what a tick on Type asks the form filter | `formsAsking`, same file | +| the forms the type table stands down for | `asked`, `app/shared/judge.ts` | +| how many questions a group actually asks | `distinctQuestions`, `app/shared/tool-fit.ts` | +| folding one tool's verdicts as they are judged | `foldOnto`, `app/shared/judge.ts` | +| what a threaded hole's drill list may show | `predrillFormsOf`, `app/shared/hole-mode.ts` | +| how many each column alone holds back | `overridableTally`, same file | +| which column a rule is about | `columnOfRule`, `app/shared/tool-marks.ts` | +| the note a changed filter raises | `OverrideNotice`, `components/column-filter.tsx` | +| the press that confirms it | `OverrideToggle`, same file | +| `at most` meaning at most across a unit change | `BOUND_SLACK`, `app/shared/filter.ts` | +| which slots were filled against the rules | `overrides`, `assembly-tree.ts` | +| the words a warning says | `overrideNote`, `app/shared/tool-marks.ts` | +| what a whole assembly offers, over all its stacks | `groupActions`, same file | +| which stacks make up one assembly | `treeGroups` / `stacksOf`, `assembly-tree.ts` | +| what the bill already holds for a stack | `savedFor`, same file | +| putting a stack back to the bill's line | `restoreAssembly`, `assembly-tree.ts` | +| what a stack is called, and where a part stands | `assemblyName` / `heldIn`, same file | +| the name somebody gave a stack, and clearing it | `renameAssembly`, same file | +| the field a name is typed in, in both places | `app/components/name-field.tsx` | +| the length below the holder a stack needs | `belowHolder`, `app/shared/drawn-assembly.ts` | +| the words the clearance verdict is said in | `verdictNote`, `components/catalog-drawing.tsx` | +| the columns a holder and a collet are read on | `app/shared/component-columns.ts` | +| narrowing a rack by brand, type, family, a number | `app/shared/component-query.ts` | +| which of the three lists the table is | `listKind` / `chooseList`, `routes/part.tsx` | +| the tree on screen | `app/components/assembly-tree-panel.tsx` | +| the holder and collet tables | `app/components/component-table.tsx` | +| their filters | `app/components/component-filters.tsx` | +| the component being read | `app/components/assembly-panel.tsx` | +| everything wired together | `app/routes/part.tsx` | Each pure module owns its tests. The tree's own end-to-end coverage is the `the tool assembly tree` block in `tests/on-the-part.spec.ts`, against the cube From 1fcd78d4eb2f8208d4ef29ce5444bca751abf3a3 Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Fri, 11 Sep 2026 08:05:39 -0400 Subject: [PATCH 04/12] widened end mill pool for threaded holes --- .../app/client/catalog-matcher.worker.ts | 120 ++++++++++++--- .../app/shared/catalog-matcher.test.ts | 43 ++++++ apps/catalog/app/shared/catalog-matcher.ts | 144 +++++++++++++----- apps/catalog/app/shared/filter.ts | 21 ++- apps/catalog/tests/threaded-hole.spec.ts | 42 +++++ docs/TOOL-ASSEMBLY-TREE.md | 102 +++++++------ 6 files changed, 367 insertions(+), 105 deletions(-) diff --git a/apps/catalog/app/client/catalog-matcher.worker.ts b/apps/catalog/app/client/catalog-matcher.worker.ts index c29df15..9d8bf78 100644 --- a/apps/catalog/app/client/catalog-matcher.worker.ts +++ b/apps/catalog/app/client/catalog-matcher.worker.ts @@ -2,6 +2,7 @@ import type { CatalogTool } from '@toolpath/catalog-data' import { collets, holders, allTools } from '../shared/catalog' import { detailedMatch, + facetCountsFor, facetPool, matchKey, prepareMatch, @@ -79,11 +80,15 @@ const poolFor = ( const widened = { ...context, query: withoutFacets(context.query) } /* **The stack is left out of the key, deliberately.** What it narrows is the - counts taken *over* this pool, never the pool itself — `detailedMatch` - § `counted` is where it is applied — so a holder or a collet picked in the - tree must not evict a judging pass that would come back identical. - `stable` drops an undefined value, so this is the key a demand with no - stack writes. + counts taken *over* this pool, never the pool itself — `facetCountsFor` in + `shared/catalog-matcher.ts` is where it is applied — so a holder or a collet + picked in the tree must not evict a judging pass that would come back + identical. `stable` drops an undefined value, so this is the key a demand + with no stack writes. + + The `form` axis is out of it too, because `withoutFacets` clears it: one + pool now serves every type ticked, the predrill button and a thread being + chosen, where each of those used to rebuild it. */ const key = matchKey('table', widened, [{ ...demand, stack: undefined }]) const cached = pools.get(key) @@ -95,6 +100,65 @@ const poolFor = ( return built } +/** + * The table request whose counts have still to be worked out. + * + * **The counts are not the answer, and must not be paid for like one** (Paul, + * 2026-09-10). Widening the pool past the `form` axis is what makes a threaded + * hole's Type column say how many end mills work — and it is a judging pass + * over the whole catalog rather than the sixteen thousand drills and taps the + * filter admits: measured at 502 ms an answer against 170 ms without it. So the + * rows are posted first and this is what comes after them, a task later so a + * newer question can overtake it. + * + * A newer table request clears it, because counts for the question before last + * are numbers beside somebody else's checkboxes. + */ +let counting: number | null = null + +const countLater = (request: MatchRequest, answered: ReadonlyArray): void => { + counting = request.requestId + setTimeout(() => { + if (counting !== request.requestId) { + return + } + counting = null + try { + // Aligned by construction: `answered` is `request.demands` mapped. + const results = answered.map((already, at) => { + const demand = request.demands[at] + return demand === undefined + ? already + : { + ...already, + facetCounts: facetCountsFor( + request.context, + demand, + catalog, + poolFor(request.context, demand), + ), + } + }) + tables.set(request.key, results) + const response: MatchResponse = { + requestId: request.requestId, + kind: 'table', + key: request.key, + results, + } + self.postMessage(response) + } catch { + /* + **A count that fails is not an answer that failed.** The rows are on + screen already and this task cannot take them away — reporting an error + here would replace a good table with a message about the numbers beside + its checkboxes. The page falls back to counting its own rows, which is + what it does whenever the worker has nothing to say. + */ + } + }, 0) +} + self.onmessage = (event: MessageEvent) => { const incoming = event.data if (incoming.context.features.length > 0) { @@ -120,20 +184,25 @@ self.onmessage = (event: MessageEvent) => { } try { if (request.kind === 'table') { + // A question already answered has its counts with it, so nothing is left + // to work out and nothing is scheduled. const cached = tables.get(request.key) - const results = - cached ?? - (() => { - const prepared = prepareMatch(request.context, catalog) - const matched = request.demands.map((demand) => { - const pool = facetsNarrowing(request.context.query) - ? poolFor(request.context, demand) - : undefined - return detailedMatch(request.context, demand, catalog, prepared, pool) - }) - tables.set(request.key, matched) - return matched - })() + if (cached !== undefined) { + counting = null + const response: MatchResponse = { + requestId: request.requestId, + kind: 'table', + key: request.key, + results: cached, + } + self.postMessage(response) + return + } + const prepared = prepareMatch(request.context, catalog) + // `null` is the pool this pass does not build — `detailedMatch` says why. + const results = request.demands.map((demand) => + detailedMatch(request.context, demand, catalog, prepared, null), + ) const response: MatchResponse = { requestId: request.requestId, kind: 'table', @@ -141,6 +210,21 @@ self.onmessage = (event: MessageEvent) => { results, } self.postMessage(response) + /* + **Only a finished answer is cached.** An answer still waiting for its + counts would be handed back whole on the next ask of the same question, + and the counts would never be worked out at all — the cache hit returns + before anything is scheduled. So the entry is written by `countLater`, + and the one case with nothing to wait for is written here. The cost of + that is re-answering a question asked twice inside one task, which the + client's own key dedupe already makes hard to do. + */ + if (facetsNarrowing(request.context.query)) { + countLater(request, results) + } else { + counting = null + tables.set(request.key, results) + } return } diff --git a/apps/catalog/app/shared/catalog-matcher.test.ts b/apps/catalog/app/shared/catalog-matcher.test.ts index 518f432..cfceeef 100644 --- a/apps/catalog/app/shared/catalog-matcher.test.ts +++ b/apps/catalog/app/shared/catalog-matcher.test.ts @@ -544,6 +544,49 @@ describe('the counts an axis offers while it is narrowing', () => { ).toEqual({ Kennametal: 1, WIDIA: 1 }) }) + /** + * **The Type column asks the `form` axis too** (Paul, 2026-09-10: a threaded + * hole's drill list "showing zero compatible end mills currently, but + * checking any of the boxes shows there are end mills that do work"). A + * thread writes the drill and the taps into `form`, so the matcher had judged + * nothing else — and the feature's own type table turns a form away besides. + * The pool is widened past both, or the number beside a box is nought until + * the box is ticked. + */ + describe('the type a filter has never let the matcher see', () => { + const drill = { ...tool('drill-1', 6), form: 'drill' } as CatalogTool + const drilling = { tools: [tool('mill-1', 6), drill], holders: [], collets: [] } + // A pocket considers end mills and not drills, so `asked` is what lets the + // drill past the table — the same stand-down a tick on Type writes. + const drillsOnly = { + ...context([feature]), + query: { ...EMPTY_QUERY, terms: { form: ['drill'] } }, + } + + it('counts what ticking it would bring, not what the filter admits', () => { + const result = detailedMatch(drillsOnly, demand, drilling) + + expect(result.heldGuids).toEqual(['drill-1']) + expect(result.facetCounts?.type).toEqual({ Drill: 1, 'Flat end mill': 1 }) + }) + + it('keeps the forms standing for every other axis', () => { + const branded = { ...drill, guid: 'drill-2', brand: 'WIDIA', vendor: 'WIDIA' } as CatalogTool + const result = detailedMatch(drillsOnly, demand, { + ...drilling, + tools: [...drilling.tools, branded], + }) + + // "What would WIDIA bring" is asked with the drill filter standing: the + // end mill it is holding back is not a tool that vendor would add. + expect(result.facetCounts?.brand).toEqual({ Test: 1, WIDIA: 1 }) + }) + + it('says nothing at all when the caller asks for no pool', () => { + expect(detailedMatch(drillsOnly, demand, drilling, undefined, null).facetCounts).toBeNull() + }) + }) + it("leaves a caller's widened pool alone rather than judging a second time", () => { const pool = facetPool(asked({ brand: ['Kennametal'] }), demand, crib) diff --git a/apps/catalog/app/shared/catalog-matcher.ts b/apps/catalog/app/shared/catalog-matcher.ts index 26673cd..0909a1e 100644 --- a/apps/catalog/app/shared/catalog-matcher.ts +++ b/apps/catalog/app/shared/catalog-matcher.ts @@ -1,4 +1,11 @@ -import type { CatalogTool, Collet, Holder, HolderFilters, Margins } from '@toolpath/catalog-data' +import { + TOOL_FORMS, + type CatalogTool, + type Collet, + type Holder, + type HolderFilters, + type Margins, +} from '@toolpath/catalog-data' import type { PartFeature } from '@toolpath/part-contracts' import { formatLength, type UnitSystem } from '@toolpath/tool-support' import { withClampingLength, type ClampingRule } from './clamping-length' @@ -27,6 +34,15 @@ import { import { holeAt } from './hole-mode' import { RULES, type Knob } from './rules' +/** + * Every form there is, which is what a Type nobody has ticked could ask for. + * + * The catalog's vocabulary rather than a walk of the tools: `TOOL_FORMS` is the + * list the Type column's phrases are built out of, so a form no tool carries + * costs a name in this array and nothing else. + */ +const EVERY_FORM: ReadonlyArray = TOOL_FORMS.map((each) => each.value) + /** The serializable inputs which can affect a catalog answer. */ export interface MatchContext { readonly features: ReadonlyArray @@ -510,6 +526,21 @@ const matchDemand = ( demand: MatchDemand, catalog: MatcherCatalog, prepared: PreparedMatch, + /* + **A form the filter asks for is a form the question is about.** The type + table is the feature's default, and the `form` filter is the one place + that says which forms are being asked about — so a group added there + (`formsAsking`, `shared/tool-type.ts`) reaches the judging rather than being + removed by the type table under a filter that had just admitted it. It is + already in the context, so no cache key changes and no second state can + disagree with it. + + A parameter rather than only that, because {@link facetPool} judges a + question nobody has asked yet — every form, so that the count beside a Type + nobody has ticked says what ticking it would bring. It is the pool's alone: + the answer on screen always passes the filter's own forms. + */ + asked: ReadonlyArray = context.query.terms.form ?? [], ): DemandMatch => { const fitting = fittingTools( effectiveFeatures(context, demand), @@ -517,16 +548,7 @@ const matchDemand = ( prepared.considered, matcherFormat(context.unit), context.knobs, - /* - **A form the filter asks for is a form the question is about.** The type - table is the feature's default, and the `form` filter is the one place - that says which forms are being asked about — so a group added there - (`formsAsking`, `shared/tool-type.ts`) reaches the judging rather than being - removed by the type table under a filter that had just admitted it. It is - already in the context, so no cache key changes and no second state can - disagree with it. - */ - context.query.terms.form ?? [], + asked, ) const narrowed = fitting.fitting.filter((verdict) => prepared.admittedGuids.has(verdict.tool.guid), @@ -652,9 +674,25 @@ export const facetPool = ( catalog: MatcherCatalog, ): ReadonlyArray => { const widened = { ...context, query: withoutFacets(context.query) } - return matchDemand(widened, demand, catalog, prepareMatch(widened, catalog)).held.map( - (verdict) => verdict.tool, - ) + return matchDemand( + widened, + demand, + catalog, + prepareMatch(widened, catalog), + /* + **The type table stands down over the whole pool.** Clearing the `form` + term is only half of what a tick on Type does: the other half is standing + the feature's own table down for the form behind the phrase, and a + threaded hole's table is `tap right hand; drill`. Widened without this, + every end mill was let into the pool by the filter and removed again by + the table, and `Flat end mill` still read nought (Paul, 2026-09-10). + + Every rule the feature has still runs — this is the type table alone, the + same stand-down `judge.ts` § `JudgeOptions.asked` describes, over the one + question nobody has asked yet. + */ + EVERY_FORM, + ).held.map((verdict) => verdict.tool) } /** @@ -685,6 +723,19 @@ const forStack = ( : narrowTools(pool, { holder, collet }, catalog.collets) } +/** + * What one axis is measured against: every filter but its own. + * + * **Type takes `form` with it.** They are one question asked in two + * vocabularies — the phrase in the column and the form behind it — and a tick + * writes both, so counting types against a standing `form` reports every type + * that filter is not already holding as nought. Every other axis keeps it: how + * many Kennametal tools a pocket would bring is a question asked with the + * pocket's own forms standing. + */ +const againstOthers = (query: ToolQuery, axis: string): ToolQuery => + axis === 'type' ? withoutTerm(withoutTerm(query, 'type'), 'form') : withoutTerm(query, axis) + /** One pool read per axis, each against every filter but that axis's own. */ const countsOverPool = ( pool: ReadonlyArray, @@ -692,11 +743,41 @@ const countsOverPool = ( ): Record> => { const counts: Record> = {} for (const axis of FACET_AXES) { - counts[axis] = Object.fromEntries(countBy(filterTools(pool, withoutTerm(query, axis)), axis)) + counts[axis] = Object.fromEntries(countBy(filterTools(pool, againstOthers(query, axis)), axis)) } return counts } +/** + * The counts for one demand, over a pool the caller already holds. + * + * Exported because the worker works them out **after** it has answered + * (`countLater`), and there must be one derivation of them rather than one for + * the answer and one for the follow-up. + */ +export const facetCountsFor = ( + context: MatchContext, + demand: MatchDemand, + catalog: MatcherCatalog, + pool: ReadonlyArray, +): DetailedResult['facetCounts'] => { + /** + * The pool the counts are taken over, which is the pool the table draws. + * + * Narrowed here rather than in {@link facetPool} so the pool itself stays the + * facet-free question and the worker can keep one across every holder and + * collet tried in the stack — the judging pass is what that cache exists to + * save, and the stack does not change it. + */ + const counted = forStack(pool, demand, catalog) + /** + * Nothing held in the widened pool is nothing to say: the rows on screen + * are then the near misses standing in, and counting a value at nought + * against a list that is itself a stand-in would read as an answer. + */ + return counted.length === 0 ? null : countsOverPool(counted, context.query) +} + /** Runs the existing detailed table pipeline with only cloneable request inputs. */ export const detailedMatch = ( context: MatchContext, @@ -710,22 +791,21 @@ export const detailedMatch = ( * thing on this page that does not change when one is ticked — the pool is * the question with the facets *cleared*. Passing it in is what keeps a * second vendor from costing a second judging pass. + * + * **`null` is "not now".** Judging the pool is the expensive half of this + * function and none of it is the answer — the rows are. The worker answers + * with `null` here and works the counts out afterwards, so a feature's table + * lands in the time it always did and the numbers beside the checkboxes + * follow: `countLater` in `client/catalog-matcher.worker.ts` is the rule, and + * `facetCountsFor` above is what it calls. */ - pool?: ReadonlyArray, + pool?: ReadonlyArray | null, ): DetailedResult => { const matched = matchDemand(context, demand, catalog, prepared) - const widened = !facetsNarrowing(context.query) - ? null - : (pool ?? facetPool(context, demand, catalog)) - /** - * The pool the counts are taken over, which is the pool the table draws. - * - * Narrowed here rather than in {@link facetPool} so the pool itself stays the - * facet-free question and the worker can keep one across every holder and - * collet tried in the stack — the judging pass is what that cache exists to - * save, and the stack does not change it. - */ - const counted = widened === null ? null : forStack(widened, demand, catalog) + const widened = + pool === null || !facetsNarrowing(context.query) + ? null + : (pool ?? facetPool(context, demand, catalog)) return { demandKey: demand.demandKey, fitting: matched.fitting.map(compact), @@ -749,13 +829,7 @@ export const detailedMatch = ( ruleTally: ruleTally(matched.excluded), narrowedGuids: matched.narrowed.map((verdict) => verdict.tool.guid), heldGuids: matched.held.map((verdict) => verdict.tool.guid), - /** - * Nothing held in the widened pool is nothing to say: the rows on screen - * are then the near misses standing in, and counting a value at nought - * against a list that is itself a stand-in would read as an answer. - */ - facetCounts: - counted === null || counted.length === 0 ? null : countsOverPool(counted, context.query), + facetCounts: widened === null ? null : facetCountsFor(context, demand, catalog, widened), } } diff --git a/apps/catalog/app/shared/filter.ts b/apps/catalog/app/shared/filter.ts index 042d6d4..5cee097 100644 --- a/apps/catalog/app/shared/filter.ts +++ b/apps/catalog/app/shared/filter.ts @@ -462,14 +462,31 @@ export const FACET_AXES: ReadonlyArray = [ 'NOF', ] +/** + * The axes the pool is widened past, which is one more than the axes counted. + * + * **The Type column asks two of them** (Paul, 2026-09-10: a threaded hole's + * drill list offering `Flat end mill 0`, "checking any of the boxes shows there + * are end mills that do work"). A tick on Type writes the phrase to `type` and + * the form behind it to `form` — `formsAsking` in `shared/tool-type.ts` — and + * `form` is the axis a feature's suggestions and a chosen thread write for + * themselves. So the matcher had judged drills and taps and nothing else, every + * end mill read nought, and the only way to find out otherwise was to tick a + * box that said there was nothing behind it. + * + * `form` is widened past and never counted: it has no column of its own, and + * the Type column is where its values are read out in the trade's words. + */ +const POOL_AXES: ReadonlyArray = [...FACET_AXES, 'form'] + /** Whether any facet axis is narrowing, which is when a count needs widening. */ export const facetsNarrowing = (query: ToolQuery): boolean => - FACET_AXES.some((axis) => (query.terms[axis]?.length ?? 0) > 0) + POOL_AXES.some((axis) => (query.terms[axis]?.length ?? 0) > 0) /** The same query with every facet axis taken out — the pool a count is measured over. */ export const withoutFacets = (query: ToolQuery): ToolQuery => { const terms = { ...query.terms } - for (const axis of FACET_AXES) { + for (const axis of POOL_AXES) { delete terms[axis] } return { ...query, terms } diff --git a/apps/catalog/tests/threaded-hole.spec.ts b/apps/catalog/tests/threaded-hole.spec.ts index 48747ce..82cda2d 100644 --- a/apps/catalog/tests/threaded-hole.spec.ts +++ b/apps/catalog/tests/threaded-hole.spec.ts @@ -213,6 +213,48 @@ test('stands the mills in where no drill makes the predrill, and says so', async expect(forms).toContain('bull nose end mill') }) +/** + * **A count says whether ticking a box is worth it, so it cannot be nought + * while the answer is not** (Paul, 2026-09-10: "it is showing zero compatible + * end mills currently, but checking any of the boxes shows there are end mills + * that do work"). + * + * The matcher judges what the filters admit, and a threaded hole's `form` axis + * holds the drill and the taps — so no end mill had ever been put to this + * hole's rules and every one of them read nought. The feature's own type table + * turns them away besides: `ThreadedBlindHole` considers `tap right hand; + * drill`. The pool the counts are measured over is widened past both. + * + * Taking the type off the filter is what makes it visible here — the fixture's + * predrill has no drill that makes it, so the page turns the mills on by itself + * — and it is the same state either way: a type the list is not holding, over a + * feature it works on. The number must not move. + */ +test('says how many end mills work while the filter is holding none', async ({ page }) => { + await open(page, 'DRILL') + await page.getByRole('button', { name: 'Filter by Type', exact: true }).click() + const types = page.getByRole('group', { name: 'Type' }) + const flat = types.locator('[data-term-option="Flat end mill"]') + await expect(flat.locator('[data-term-count]')).toHaveText('1') + + await types.getByRole('checkbox', { name: 'Flat end mill' }).click() + + await expect + .poll(async () => new URL(page.url()).searchParams.getAll('form')) + .not.toContain('flat end mill') + /* + **The row that says how many are behind the list must not move.** Two of + the sample's types work on no hole at all and are behind it either way; + taking the mill off the filter used to put it there too, which is the + defect — and asserting the row rather than the count is what makes this a + sensor rather than a race, because the counts land a task after the rows + (`countLater`) and the panel shows what it last offered until they do. + */ + await expect(types.getByRole('button', { name: '… 2 more' })).toBeVisible() + // Off the list, and still the answer to "what would ticking this bring". + await expect(flat.locator('[data-term-count]')).toHaveText('1') +}) + /** * **Nothing on the control is marked** (Paul, 2026-09-09: "we also shouldn't * show the X on drills"). A red `✗` said *no standard drill makes this predrill diff --git a/docs/TOOL-ASSEMBLY-TREE.md b/docs/TOOL-ASSEMBLY-TREE.md index 3637269..269b15d 100644 --- a/docs/TOOL-ASSEMBLY-TREE.md +++ b/docs/TOOL-ASSEMBLY-TREE.md @@ -889,56 +889,58 @@ gets it. ## 7. Where the rules live -| Rule | File | -| ------------------------------------------------- | ---------------------------------------------------- | -| what a tree holds, its slots, its storage | `app/shared/assembly-tree.ts` | -| what a threaded hole starts with | `defaultAssemblies`, same file | -| the stacks following the thread on the hole read | `forThread`, same file | -| how the stacks nest, and their drawing order | `treeRows`, same file | -| what narrows what | `app/shared/assembly-narrowing.ts` | -| the same narrowing under a filter's own counts | `MatchDemand.stack`, `app/shared/catalog-matcher.ts` | -| whether a chuck is worth offering with no collet | `holderMayTake`, `@toolpath/catalog-data` | -| why an offered chuck cannot be built today | `colletGap`, `app/shared/assembly-narrowing.ts` | -| whether the rack shows them, and how many | `holdersToOffer` `shown` / `stocked`, same file | -| the press that decides, and its words | `app/components/no-collet-toggle.tsx` | -| what a stack offers, and its button's words | `app/shared/assembly-actions.ts` | -| which press orders, and which Enter stands for | `isOrdering` / `orderingPress`, same file | -| which layer one press of Enter or Escape reaches | `useKeyLayer`, `app/shared/use-escape.ts` | -| whether the page stands down for an open filter | `columnFilterOpen`, same file | -| how tall a filter menu is, and which way it opens | `place`, `components/column-filter.tsx` | -| what overruling the rules offers | `overridableTools`, `app/shared/tool-fit.ts` | -| what an axis has that the list is not showing | `hiddenOn`, `app/routes/part.tsx` | -| the `…` row that offers it | `TermFilter`, `components/column-filter.tsx` | -| the form behind a Type phrase | `formOfTypeLabel`, `app/shared/tool-type.ts` | -| what a tick on Type asks the form filter | `formsAsking`, same file | -| the forms the type table stands down for | `asked`, `app/shared/judge.ts` | -| how many questions a group actually asks | `distinctQuestions`, `app/shared/tool-fit.ts` | -| folding one tool's verdicts as they are judged | `foldOnto`, `app/shared/judge.ts` | -| what a threaded hole's drill list may show | `predrillFormsOf`, `app/shared/hole-mode.ts` | -| how many each column alone holds back | `overridableTally`, same file | -| which column a rule is about | `columnOfRule`, `app/shared/tool-marks.ts` | -| the note a changed filter raises | `OverrideNotice`, `components/column-filter.tsx` | -| the press that confirms it | `OverrideToggle`, same file | -| `at most` meaning at most across a unit change | `BOUND_SLACK`, `app/shared/filter.ts` | -| which slots were filled against the rules | `overrides`, `assembly-tree.ts` | -| the words a warning says | `overrideNote`, `app/shared/tool-marks.ts` | -| what a whole assembly offers, over all its stacks | `groupActions`, same file | -| which stacks make up one assembly | `treeGroups` / `stacksOf`, `assembly-tree.ts` | -| what the bill already holds for a stack | `savedFor`, same file | -| putting a stack back to the bill's line | `restoreAssembly`, `assembly-tree.ts` | -| what a stack is called, and where a part stands | `assemblyName` / `heldIn`, same file | -| the name somebody gave a stack, and clearing it | `renameAssembly`, same file | -| the field a name is typed in, in both places | `app/components/name-field.tsx` | -| the length below the holder a stack needs | `belowHolder`, `app/shared/drawn-assembly.ts` | -| the words the clearance verdict is said in | `verdictNote`, `components/catalog-drawing.tsx` | -| the columns a holder and a collet are read on | `app/shared/component-columns.ts` | -| narrowing a rack by brand, type, family, a number | `app/shared/component-query.ts` | -| which of the three lists the table is | `listKind` / `chooseList`, `routes/part.tsx` | -| the tree on screen | `app/components/assembly-tree-panel.tsx` | -| the holder and collet tables | `app/components/component-table.tsx` | -| their filters | `app/components/component-filters.tsx` | -| the component being read | `app/components/assembly-panel.tsx` | -| everything wired together | `app/routes/part.tsx` | +| Rule | File | +| --------------------------------------------------- | ---------------------------------------------------- | +| what a tree holds, its slots, its storage | `app/shared/assembly-tree.ts` | +| what a threaded hole starts with | `defaultAssemblies`, same file | +| the stacks following the thread on the hole read | `forThread`, same file | +| how the stacks nest, and their drawing order | `treeRows`, same file | +| what narrows what | `app/shared/assembly-narrowing.ts` | +| the same narrowing under a filter's own counts | `MatchDemand.stack`, `app/shared/catalog-matcher.ts` | +| the axes a count is widened past, `form` among them | `POOL_AXES`, `app/shared/filter.ts` | +| when those counts are worked out, and after what | `countLater`, `client/catalog-matcher.worker.ts` | +| whether a chuck is worth offering with no collet | `holderMayTake`, `@toolpath/catalog-data` | +| why an offered chuck cannot be built today | `colletGap`, `app/shared/assembly-narrowing.ts` | +| whether the rack shows them, and how many | `holdersToOffer` `shown` / `stocked`, same file | +| the press that decides, and its words | `app/components/no-collet-toggle.tsx` | +| what a stack offers, and its button's words | `app/shared/assembly-actions.ts` | +| which press orders, and which Enter stands for | `isOrdering` / `orderingPress`, same file | +| which layer one press of Enter or Escape reaches | `useKeyLayer`, `app/shared/use-escape.ts` | +| whether the page stands down for an open filter | `columnFilterOpen`, same file | +| how tall a filter menu is, and which way it opens | `place`, `components/column-filter.tsx` | +| what overruling the rules offers | `overridableTools`, `app/shared/tool-fit.ts` | +| what an axis has that the list is not showing | `hiddenOn`, `app/routes/part.tsx` | +| the `…` row that offers it | `TermFilter`, `components/column-filter.tsx` | +| the form behind a Type phrase | `formOfTypeLabel`, `app/shared/tool-type.ts` | +| what a tick on Type asks the form filter | `formsAsking`, same file | +| the forms the type table stands down for | `asked`, `app/shared/judge.ts` | +| how many questions a group actually asks | `distinctQuestions`, `app/shared/tool-fit.ts` | +| folding one tool's verdicts as they are judged | `foldOnto`, `app/shared/judge.ts` | +| what a threaded hole's drill list may show | `predrillFormsOf`, `app/shared/hole-mode.ts` | +| how many each column alone holds back | `overridableTally`, same file | +| which column a rule is about | `columnOfRule`, `app/shared/tool-marks.ts` | +| the note a changed filter raises | `OverrideNotice`, `components/column-filter.tsx` | +| the press that confirms it | `OverrideToggle`, same file | +| `at most` meaning at most across a unit change | `BOUND_SLACK`, `app/shared/filter.ts` | +| which slots were filled against the rules | `overrides`, `assembly-tree.ts` | +| the words a warning says | `overrideNote`, `app/shared/tool-marks.ts` | +| what a whole assembly offers, over all its stacks | `groupActions`, same file | +| which stacks make up one assembly | `treeGroups` / `stacksOf`, `assembly-tree.ts` | +| what the bill already holds for a stack | `savedFor`, same file | +| putting a stack back to the bill's line | `restoreAssembly`, `assembly-tree.ts` | +| what a stack is called, and where a part stands | `assemblyName` / `heldIn`, same file | +| the name somebody gave a stack, and clearing it | `renameAssembly`, same file | +| the field a name is typed in, in both places | `app/components/name-field.tsx` | +| the length below the holder a stack needs | `belowHolder`, `app/shared/drawn-assembly.ts` | +| the words the clearance verdict is said in | `verdictNote`, `components/catalog-drawing.tsx` | +| the columns a holder and a collet are read on | `app/shared/component-columns.ts` | +| narrowing a rack by brand, type, family, a number | `app/shared/component-query.ts` | +| which of the three lists the table is | `listKind` / `chooseList`, `routes/part.tsx` | +| the tree on screen | `app/components/assembly-tree-panel.tsx` | +| the holder and collet tables | `app/components/component-table.tsx` | +| their filters | `app/components/component-filters.tsx` | +| the component being read | `app/components/assembly-panel.tsx` | +| everything wired together | `app/routes/part.tsx` | Each pure module owns its tests. The tree's own end-to-end coverage is the `the tool assembly tree` block in `tests/on-the-part.spec.ts`, against the cube From 7ea98db202b1e0f1726b9559f7106d5bf8c2421e Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Fri, 11 Sep 2026 08:26:58 -0400 Subject: [PATCH 05/12] app-246 fix (remove individual holes from a group) --- .../app/shared/part-interaction.test.ts | 117 +++++++++++--- apps/catalog/app/shared/part-interaction.ts | 130 ++++++++++------ apps/catalog/tests/threaded-hole.spec.ts | 41 +++++ docs/FEATURE-LIST.md | 146 ++++++++++-------- 4 files changed, 304 insertions(+), 130 deletions(-) diff --git a/apps/catalog/app/shared/part-interaction.test.ts b/apps/catalog/app/shared/part-interaction.test.ts index 79c711b..18ce126 100644 --- a/apps/catalog/app/shared/part-interaction.test.ts +++ b/apps/catalog/app/shared/part-interaction.test.ts @@ -207,9 +207,10 @@ describe('a click while a group is being built', () => { { type: 'click', pick: face() }, ) - // The two holes are identical, so one click keeps both: a bolt circle is - // one decision wherever it is asked about. - expect(state.kept).toEqual(['hole-a', 'hole-b', 'pocket']) + // One feature per press, siblings or no siblings: the identical hole next + // to it joins because somebody asked for the group, not because a click + // landed on one of them (Paul, 2026-09-11). + expect(state.kept).toEqual(['hole-a', 'pocket']) expect(state.focused).toBe('pocket') }) @@ -239,7 +240,61 @@ describe('a click while a group is being built', () => { { type: 'click', pick: face() }, ) - expect(state.kept).toEqual(['hole-a', 'hole-b']) + expect(state.kept).toEqual(['hole-a']) + }) + + /** + * **And the press after that puts it back** (Paul, 2026-09-11: "as they are + * already selected, clicking on them again should deselect them"). One face + * pressed over and over is in, out, in — it used to clear the reading on the + * second press, so the third was spent picking the face up again. + */ + it('puts the face back on the press after that', () => { + const state = run( + { type: 'group' }, + { type: 'click', pick: face() }, + { type: 'click', pick: face() }, + { type: 'click', pick: face() }, + ) + + expect(state.kept).toEqual(['pocket']) + expect(state.focused).toBe('pocket') + }) + + /** + * **A hole taken out of a pre-selected group leaves the rest standing** + * (Paul, 2026-09-11: "removing one of the holes removes all of them from the + * list … I should be able to remove individual holes while keeping all the + * other selections"). The whole defect, in one assertion: the group opens on + * every identical hole, and clicking one of them takes one of them out. + */ + it('takes one hole out of the group it opened with', () => { + const opened = run({ type: 'click', pick: pickForRegion(5, ['hole-a']) }, { type: 'group' }) + expect(opened.kept).toEqual(['hole-a', 'hole-b']) + + const dropped = reduce(opened, { type: 'click', pick: pickForRegion(6, ['hole-b']) }) + expect(dropped.kept).toEqual(['hole-a']) + // And it goes dark: the part lights what is focused as well as what is kept. + expect(dropped.focused).toBeNull() + + // And back in on the next press, without costing its sibling. + expect(reduce(dropped, { type: 'click', pick: pickForRegion(6, ['hole-b']) }).kept).toEqual([ + 'hole-a', + 'hole-b', + ]) + }) + + /** + * **A click on nothing does not undo the group** (Paul, 2026-09-11). The set + * a group opens with is somebody's answer rather than a guess, and a guess is + * what a miss puts down — growing it into `guessed` meant two clicks on the + * background emptied a group of thirty-nine. + */ + it('keeps the group through a click on nothing', () => { + const opened = run({ type: 'click', pick: pickForRegion(5, ['hole-a']) }, { type: 'group' }) + + const missed = reduce(reduce(opened, { type: 'miss' }), { type: 'miss' }) + expect(missed.kept).toEqual(['hole-a', 'hole-b']) }) /** @@ -255,7 +310,7 @@ describe('a click while a group is being built', () => { { type: 'arm', direction: 1 }, ) - expect(state.kept).toEqual(['hole-a', 'hole-b', 'wall']) + expect(state.kept).toEqual(['hole-a', 'wall']) }) }) @@ -315,13 +370,23 @@ describe('what is kept by hand', () => { expect(dropped.kept).toEqual([]) }) - /** And inside a group it keeps and drops the whole set, as it always did. */ - it('keeps and drops a hole with its identical siblings while a group is being built', () => { - const kept = run({ type: 'group' }, { type: 'toggle', featureTag: 'hole-a' }) - expect(kept.kept).toEqual(['hole-a', 'hole-b']) + /** + * **And inside a group it still stands for itself** (Paul, 2026-09-11: "I + * should be able to add or remove individual holes, even if they are + * identical"). This is the X beside a hole in the group box: it used to take + * every hole identical to it out of the group at the same time. + */ + it('keeps and drops one hole at a time while a group is being built', () => { + const grouped = run({ type: 'click', pick: pickForRegion(5, ['hole-a']) }, { type: 'group' }) + expect(grouped.kept).toEqual(['hole-a', 'hole-b']) + + const dropped = reduce(grouped, { type: 'toggle', featureTag: 'hole-b' }) + expect(dropped.kept).toEqual(['hole-a']) - const dropped = reduce(kept, { type: 'toggle', featureTag: 'hole-b' }) - expect(dropped.kept).toEqual([]) + expect(reduce(dropped, { type: 'toggle', featureTag: 'hole-b' }).kept).toEqual([ + 'hole-a', + 'hole-b', + ]) }) }) @@ -337,9 +402,9 @@ describe('opening a group', () => { const grouped = reduce(read, { type: 'group' }) expect(grouped.kept).toEqual(['hole-a', 'hole-b']) - // The guess grows with it, so choosing a direction next replaces the whole - // set rather than leaving the sibling behind. - expect(grouped.guessed).toEqual(['hole-a', 'hole-b']) + // And what it grew is somebody's rather than a guess: a guess is what the + // next click, arrow or miss takes back out, all of it at once. + expect(grouped.guessed).toEqual([]) expect(grouped.collecting).toBe(true) }) @@ -349,11 +414,11 @@ describe('opening a group', () => { expect(grouped.kept).toEqual(['pocket']) }) - /** And from there a reading names its whole group, not one hole of it. */ - it('groups what is read from the list once it is open', () => { + /** And from there a reading names one hole, which is how one is taken back out. */ + it('reads one hole of a group without pulling its siblings in', () => { const state = run({ type: 'group' }, { type: 'read', featureTag: 'hole-a' }) - expect(state.kept).toEqual(['hole-a', 'hole-b']) + expect(state.kept).toEqual(['hole-a']) }) }) @@ -362,22 +427,32 @@ describe('opening a group', () => { * clicking another hole from there groups (Paul, 2026-09-09). */ describe('editing a row already on the list', () => { - it('groups on for a group and off for a feature', () => { + it('collects on for a group and off for a feature', () => { const group = run({ type: 'collect', tags: ['pocket'], collecting: true }) expect(group.collecting).toBe(true) - expect(reduce(group, { type: 'toggle', featureTag: 'hole-a' }).kept).toEqual([ + // A click on the part adds to a group rather than replacing what it holds. + expect(reduce(group, { type: 'click', pick: pickForRegion(5, ['hole-a']) }).kept).toEqual([ 'pocket', 'hole-a', - 'hole-b', ]) const feature = run({ type: 'collect', tags: ['hole-a'], collecting: false }) expect(feature.collecting).toBe(false) - expect(reduce(feature, { type: 'toggle', featureTag: 'hole-b' }).kept).toEqual([ + // And on a feature it is the question being asked, so the old one goes. + expect(reduce(feature, { type: 'click', pick: pickForRegion(6, ['hole-b']) }).kept).toEqual([ 'hole-a', 'hole-b', ]) }) + + /** Whichever it is, one hole in either stands for itself (Paul, 2026-09-11). */ + it('takes one hole at a time either way', () => { + const group = run({ type: 'collect', tags: ['hole-a', 'hole-b'], collecting: true }) + expect(reduce(group, { type: 'toggle', featureTag: 'hole-a' }).kept).toEqual(['hole-b']) + + const feature = run({ type: 'collect', tags: ['hole-a'], collecting: false }) + expect(reduce(feature, { type: 'toggle', featureTag: 'hole-a' }).kept).toEqual([]) + }) }) describe('putting things down', () => { diff --git a/apps/catalog/app/shared/part-interaction.ts b/apps/catalog/app/shared/part-interaction.ts index a842c56..7d3e55e 100644 --- a/apps/catalog/app/shared/part-interaction.ts +++ b/apps/catalog/app/shared/part-interaction.ts @@ -57,17 +57,16 @@ export interface Interaction { /** * Whether a **group** is being built. * - * **This is the one thing that turns identical-hole grouping on** (Paul, - * 2026-09-09: "the current hole grouping should only be applied in GROUP. In - * Add Feature, I should be able to select a single hole"). Grouping used to - * be unconditional — every path here expanded a tag into {@link groupOf}'s - * whole set — so a click on one hole of a bolt circle made a row holding all - * thirty-nine of them and there was no way to ask about one. + * **It says what a click on the part means, and nothing else** (Paul, + * 2026-09-02): outside a group a click asks about whatever was clicked last, + * and inside one it puts that feature in the group or takes it out. * - * Held in the state rather than passed on each action, because `click`, - * `read`, `arm`, `step` and `toggle` all expand and all have to agree: with - * the flag on `click` alone, choosing a direction mid-group re-read the face - * without its group and left the other thirty-eight in `kept` as orphans. + * **It no longer turns identical-hole grouping on.** Grouping happens once, + * in `group`, where somebody asks for it — never again while the group is + * open (Paul, 2026-09-11: "In this mode, I should be able to add or remove + * individual holes, even if they are identical"). It was the flag on this + * field from 2026-09-09 until then, which made a group of thirty-nine + * identical holes impossible to correct: every press took all thirty-nine. */ readonly collecting: boolean /** @@ -131,8 +130,9 @@ export type InteractionAction = /** * Whether what is being edited is a **group**. * - * A feature row and a group row are both edited this way and only one of - * them groups identical holes, so the kind travels with the tags. + * A feature row and a group row are both edited this way and a click on + * the part means a different thing in each — a group collects, a feature + * asks — so the kind travels with the tags. */ readonly collecting: boolean } @@ -166,12 +166,17 @@ export type InteractionPart = Pick, @@ -211,14 +216,20 @@ export const interactionFor = (part: InteractionPart) => { } /** - * What one tag stands for: itself, or every hole identical to it. + * What one tag stands for: **itself, always**. + * + * Grouping identical holes happens once, where somebody asks for it — the + * {@link groupOf} expansion in `group` — and never again afterwards (Paul, + * 2026-09-11: "In this mode, I should be able to add or remove individual + * holes, even if they are identical"). It used to be applied on every path + * here while {@link Interaction.collecting} was on, so a group opened on a + * bolt circle could not be corrected: clicking one hole of the thirty-nine, + * or pressing the X beside one, took all thirty-nine out at once. * - * The one place {@link groupOf} is reached from, so every path through this - * reducer answers the question the same way. Outside a group a tag stands for - * itself — that is what makes a single hole askable (Paul, 2026-09-09). + * Kept as a named step rather than inlined because *where* a tag would be + * expanded is the thing that was wrong, and this is the list of those places. */ - const expand = (state: Interaction, featureTag: string): Array => - state.collecting ? groupOf(part.features, featureTag) : [featureTag] + const expand = (featureTag: string): Array => [featureTag] /** A click on nothing puts the reading down, leaving what is kept by hand alone. */ const putDown = (state: Interaction): Interaction => ({ @@ -235,7 +246,7 @@ export const interactionFor = (part: InteractionPart) => { }) const read = (state: Interaction, featureTag: string): Interaction => { - const group = expand(state, featureTag) + const group = expand(featureTag) return { ...state, // Naming a reading from inside the list is an answer, not a new question: @@ -307,7 +318,7 @@ export const interactionFor = (part: InteractionPart) => { if (scoped.focused === state.focused) { return { ...state, activeDirection: next, chose: true } } - const group = expand(state, scoped.focused) + const group = expand(scoped.focused) return { ...state, activeDirection: next, @@ -334,7 +345,6 @@ export const interactionFor = (part: InteractionPart) => { } const selection = pickFace(state.selection, action.pick, preferArmed(state.activeDirection)) - const group = selection.focused === null ? [] : expand(state, selection.focused) if (state.collecting) { /** * **While a group is being built, a click is a toggle** (Paul, @@ -356,37 +366,57 @@ export const interactionFor = (part: InteractionPart) => { * genuinely new face lands on readings a held one already offered: * comparing those would have made the second feature of a group read * as the first being pressed twice, and taken it out again. + * + * So the same face means **the reading already open**, not the next + * of its readings: every press of one face puts it in and takes it + * out again, which is what "clicking on them in the model — as they + * are already selected, clicking on them again should deselect them" + * asks for (Paul, 2026-09-11). It used to drop whatever the last + * click had guessed and clear the reading, so a hole selected because + * the *group* was opened on it survived the press that was meant to + * take it out, and the press after that was spent re-picking it. */ const again = state.selection.picks.length === 1 && state.selection.picks[0]?.region === action.pick.region - if (again) { + const tag = again ? state.focused : selection.focused + if (tag === null) { + return state + } + // One hole, not its siblings: a group is corrected a feature at a + // time, whatever it was opened with (Paul, 2026-09-11). + if (state.kept.includes(tag)) { return { + /* + Taken out, and the part goes dark with it. `partHighlight` + lights what is *focused* as well as what is kept, so a hole + left under the reading it was dropped from goes on looking + exactly like the thirty-eight still in the group. + */ selection: NOTHING_SELECTED, focused: null, activeDirection: null, - kept: dropAll(state.kept, state.guessed), - guessed: [], collecting: true, + kept: dropAll(state.kept, [tag]), + guessed: [], chose: false, } } - if (group.length === 0) { - return state - } - const inAlready = group.every((tag) => state.kept.includes(tag)) return { - selection, - focused: selection.focused, + // The held face keeps the reading it had — walking to the next one + // is what the arrows are for while a group is being built. + selection: again ? state.selection : selection, + focused: tag, activeDirection: null, collecting: true, - kept: inAlready ? dropAll(state.kept, group) : keepAll(state.kept, group), + kept: keepAll(state.kept, [tag]), // What this face stands for, so choosing its direction replaces it // rather than leaving both readings in the group. - guessed: inAlready ? [] : group, + guessed: [tag], chose: false, } } + const group = selection.focused === null ? [] : expand(selection.focused) return { selection, focused: selection.focused, @@ -436,7 +466,14 @@ export const interactionFor = (part: InteractionPart) => { } case 'toggle': { - const group = expand(state, action.featureTag) + /* + One feature, in or out — the X beside a hole in the group box takes + that hole out and leaves the other thirty-eight standing (Paul, + 2026-09-11). It used to take the whole identical set out with it while + a group was being built, which is the same defect a click on the part + had. + */ + const group = expand(action.featureTag) const taking = !state.kept.includes(action.featureTag) return { ...state, @@ -457,13 +494,18 @@ export const interactionFor = (part: InteractionPart) => { case 'group': { /** - * **Grouping starts here.** Whatever was being asked about is expanded - * to its identical holes, because that is what a group means and what - * the offer beside a hole promises — pressing *Add all 39 as a group* - * with one hole read has to arrive at all thirty-nine. + * **Grouping starts here, and only here.** Whatever was being asked + * about is expanded to its identical holes, because that is what a + * group means and what the offer beside a hole promises — pressing + * *Add all 39 as a group* with one hole read has to arrive at all + * thirty-nine. Every path after this one takes a hole at a time. * - * The guess is grown with it, so choosing a direction afterwards - * replaces the whole set rather than leaving thirty-eight behind. + * **And what it grew is somebody's, not a guess** (Paul, 2026-09-11: + * "it should pre-select all of the holes, but I should be able to + * remove individual holes while keeping all the other selections"). + * The guess used to be grown with it, which is a set that any later + * click, arrow or click on nothing would take back out wholesale — the + * thirty-nine holes somebody asked for, gone to one press. */ const grow = (tags: ReadonlyArray): Array => keepAll( @@ -473,8 +515,8 @@ export const interactionFor = (part: InteractionPart) => { return { ...state, collecting: true, - kept: grow(state.kept), - guessed: grow(state.guessed), + kept: grow(keepAll(state.kept, state.guessed)), + guessed: [], } } diff --git a/apps/catalog/tests/threaded-hole.spec.ts b/apps/catalog/tests/threaded-hole.spec.ts index 82cda2d..327497d 100644 --- a/apps/catalog/tests/threaded-hole.spec.ts +++ b/apps/catalog/tests/threaded-hole.spec.ts @@ -268,3 +268,44 @@ test('marks neither way of making the thread', async ({ page }) => { await open(page, 'DRILL') await expect(chrome(page).locator('.text-danger')).toHaveCount(0) }) + +/** + * **A group of identical holes is corrected one hole at a time** (Paul, + * 2026-09-11: "when I create a group of all holes with the same diameter and + * depth, removing one of the holes removes all of them from the list. In this + * mode, I should be able to add or remove individual holes, even if they are + * identical"). + * + * The group opens on every hole like the one being read — that is what the + * offer beside it promises — and from there it is editable: the X beside a hole + * takes that hole out, and a press on a hole already in the group takes that + * hole out. Both used to take the whole set, so the first correction to a + * thirty-nine-hole group emptied it, and a group of all-but-one could not be + * asked for at all. + * + * Here rather than in `on-the-part.spec.ts` for the usual reason: the plain + * cube has no two identical holes, so the offer this begins with never appears. + */ +test('takes one hole out of a group of identical holes at a time', async ({ page }) => { + const offer = page.getByRole('button', { name: /^Add all \d+ as a group$/ }) + await expect(offer).toBeVisible() + const all = Number(/\d+/.exec((await offer.innerText()) ?? '')?.[0] ?? '0') + expect(all).toBeGreaterThan(2) + + await offer.click() + // Pre-selected: every hole identical to the one being read (Paul, 2026-09-11). + const chips = page.getByRole('button', { name: /^Take .+ out of the group$/ }) + await expect(chips).toHaveCount(all) + + /* + A press on the hole already selected takes it out — and only it. The click + lands on the face the group was opened from, which is the one hole here + that is certain to still be in the group. + */ + await at(page, FACE) + await expect(chips).toHaveCount(all - 1) + + // And so does the X beside one, which is the same rule from the other side. + await chips.first().click() + await expect(chips).toHaveCount(all - 2) +}) diff --git a/docs/FEATURE-LIST.md b/docs/FEATURE-LIST.md index b7f64b4..b85c4ec 100644 --- a/docs/FEATURE-LIST.md +++ b/docs/FEATURE-LIST.md @@ -59,10 +59,10 @@ current hole grouping should only be applied in GROUP. In Add Feature, I should be able to select a single hole"). A bolt circle of eight was one decision on every path: `groupOf` in `part-interaction` expanded a hole into its siblings whatever was being asked, so a click on one of them made a `feature` holding all -eight and a single hole could not be asked about at all. The expansion is now -gated on `Interaction.collecting`, which only a group turns on — so a `feature` -made by clicking a hole holds one tag, and one made from a group holds as many -as were picked. +eight and a single hole could not be asked about at all. The expansion now +happens once, where somebody asks for a group — so a `feature` made by clicking +a hole holds one tag, and one made from a group holds as many as were picked, +less any that were taken back out of it. What replaced the silent grouping is an **offer**: a hole with siblings raises a notice in the reading panel saying how many others are identical, with _Add all @@ -244,22 +244,37 @@ could see. A click on nothing (the whitespace) does the same. ### A click while a group is being built -**Identical holes group from here.** `{ type: 'group' }` is what turns -`collecting` on, and it also **grows what is already kept** into whole hole -groups — so _+ Group_ pressed over a previewed hole, and the offer beside that -hole, both arrive at the same thirty-nine. The flag is in the state rather than -on the action because `click`, `read`, `arm`, `step` and `toggle` all expand and -all have to agree: with it on `click` alone, choosing a direction mid-group -re-read the face without its group and left the rest of it in `kept` as orphans. - -A click itself is a **toggle**, and nothing else: +**Identical holes group when the group opens, and never again.** +`{ type: 'group' }` is what turns `collecting` on, and it is the one place a +hole is expanded into its siblings: it **grows what is already kept** into whole +hole groups, so _+ Group_ pressed over a previewed hole, and the offer beside +that hole, both arrive at the same thirty-nine. What it grew is kept as +somebody's answer rather than as a guess — a guess is what the next click, +arrow or click on nothing takes back out, all of it at once. + +**From there the group is edited a hole at a time** (Paul, 2026-09-11: "when I +create a group of all holes with the same diameter and depth, removing one of +the holes removes all of them from the list. In this mode, I should be able to +add or remove individual holes, even if they are identical"). The expansion used +to be applied on every path while `collecting` was on — `click`, `read`, `arm`, +`step` and `toggle` — which made the set a group opened with impossible to +correct: the X beside one hole, and a press on one hole, each took all +thirty-nine. A group of all-but-one could not be asked for at all. + +A click itself is a **toggle** of one feature, and nothing else: - a face not in the group goes in; -- a face already in comes out; -- pressing the **same** face again takes it out, asked the way `pickFace` asks - it — by the region held, not by which readings the click resolved to. A - reading can own several faces, so comparing readings made the second feature - of a group read as the first being pressed twice. +- a face already in comes out, and the reading goes with it — the part lights + what is focused as well as what is kept, so a hole left focused under the + press that dropped it goes on looking exactly like the ones still in; +- pressing the **same** face again means the reading already open rather than + the next of its readings, asked the way `pickFace` asks it — by the region + held, not by which readings the click resolved to. A reading can own several + faces, so comparing readings made the second feature of a group read as the + first being pressed twice. So one face pressed over and over is in, out, in. + +**The X beside a hole in the group box is the same rule from the other side**: +it is a `toggle`, and it takes that hole out and leaves the rest standing. **The arrows choose the reading.** `arm` swaps what the click guessed, which is the "after I select the direction, if applicable" half. @@ -888,53 +903,54 @@ true of the work the worker does: ## 11. Where the rules live -| Rule | File | -| ------------------------------------------- | ----------------------------------------------------- | -| what the list holds, names, ids, storage | `app/shared/feature-list.ts` | -| the name a shop gave an assembly row | `renameItem` / `defaultLabelOf`, same file | -| the field that name is typed in | `app/components/name-field.tsx` | -| what the bottom of the page is asked | `asked()`, same file | -| which key a row's lines are kept under | `sheetKeysOf`, same file | -| reading, writing and clearing those keys | `app/shared/order-list.ts` | -| whether a row has anything ordered for it | `isIncomplete`, same file | -| the order list both pages show | `orderAssemblies`, same file | -| what to buy, once each, and how many | `componentTotals`, same file | -| setting a component's whole order | `setComponentCount`, same file | -| the components view on the part | `app/components/component-tally.tsx` | -| the three presses that grow the list | `app/components/add-bar.tsx` | -| 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 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` | -| what _+ Feature_ does over a reading | `keepReading`, `app/routes/part.tsx` | -| which press closes the box, and Enter's | `isOrdering` / `orderingPress`, `assembly-actions.ts` | -| what Enter presses, over the whole box | `orderPress` / `applyStacks`, `routes/part.tsx` | -| a component standing in two stacks at once | `sharedWith` / `sharedPhrase`, `assembly-tree.ts` | -| how many of one assembly a row ordered | `Choice.total`, `app/shared/setup-sheet.ts` | -| a row's answer, and what opens | `app/shared/recommendations.ts` | -| what a click means | `app/shared/part-interaction.ts` | -| the list on screen | `app/components/feature-list-panel.tsx` | -| building a group | `app/components/group-editor.tsx` | -| a group's worst case, and whose it is | `app/shared/group-geometry.ts` | -| the one bore a group shares | `sharedHoleDiameter`, same file | -| whether identical holes group | `Interaction.collecting`, `part-interaction.ts` | -| whether the offer to group them is made | `app/shared/group-offer.ts` | -| the offer on screen, and both answers | `identical`, `components/selection-panel.tsx` | -| a feature row turned into a group | `changeToGroup`, `app/routes/part.tsx` | -| which key a reading's lines are kept under | `choiceKey`, same file | -| which holes a thread choice is written to | `writeThread` / `holesAt`, `shared/hole-mode.ts` | -| the reading and its thread | `app/components/selection-panel.tsx` | -| what a threaded hole is called | `threadedName`, `app/shared/threads.ts` | -| what the panel and the ⓘ dialog call it | `nameOf`, handed down by `part.tsx` | -| the tool table and its marks | `app/components/part-tool-table.tsx` | -| what overruling the rules offers | `overridableTools`, `shared/tool-fit.ts` | -| the warning and its confirm | `OverrideNotice`, `components/column-filter.tsx` | -| what a filter is not showing, and the `…` | `TermFilter`, `components/column-filter.tsx` | -| what a tick on Type asks of the forms | `formsAsking`, `app/shared/tool-type.ts` | -| which slots were filled against them | `overrides`, `shared/assembly-tree.ts` | -| everything wired together | `app/routes/part.tsx` | +| Rule | File | +| -------------------------------------------- | ----------------------------------------------------- | +| what the list holds, names, ids, storage | `app/shared/feature-list.ts` | +| the name a shop gave an assembly row | `renameItem` / `defaultLabelOf`, same file | +| the field that name is typed in | `app/components/name-field.tsx` | +| what the bottom of the page is asked | `asked()`, same file | +| which key a row's lines are kept under | `sheetKeysOf`, same file | +| reading, writing and clearing those keys | `app/shared/order-list.ts` | +| whether a row has anything ordered for it | `isIncomplete`, same file | +| the order list both pages show | `orderAssemblies`, same file | +| what to buy, once each, and how many | `componentTotals`, same file | +| setting a component's whole order | `setComponentCount`, same file | +| the components view on the part | `app/components/component-tally.tsx` | +| the three presses that grow the list | `app/components/add-bar.tsx` | +| 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 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` | +| what _+ Feature_ does over a reading | `keepReading`, `app/routes/part.tsx` | +| which press closes the box, and Enter's | `isOrdering` / `orderingPress`, `assembly-actions.ts` | +| what Enter presses, over the whole box | `orderPress` / `applyStacks`, `routes/part.tsx` | +| a component standing in two stacks at once | `sharedWith` / `sharedPhrase`, `assembly-tree.ts` | +| how many of one assembly a row ordered | `Choice.total`, `app/shared/setup-sheet.ts` | +| a row's answer, and what opens | `app/shared/recommendations.ts` | +| what a click means | `app/shared/part-interaction.ts` | +| the list on screen | `app/components/feature-list-panel.tsx` | +| building a group | `app/components/group-editor.tsx` | +| a group's worst case, and whose it is | `app/shared/group-geometry.ts` | +| the one bore a group shares | `sharedHoleDiameter`, same file | +| what a click on the part means, group or not | `Interaction.collecting`, `part-interaction.ts` | +| where identical holes are grouped, once | `{ type: 'group' }`, `part-interaction.ts` | +| whether the offer to group them is made | `app/shared/group-offer.ts` | +| the offer on screen, and both answers | `identical`, `components/selection-panel.tsx` | +| a feature row turned into a group | `changeToGroup`, `app/routes/part.tsx` | +| which key a reading's lines are kept under | `choiceKey`, same file | +| which holes a thread choice is written to | `writeThread` / `holesAt`, `shared/hole-mode.ts` | +| the reading and its thread | `app/components/selection-panel.tsx` | +| what a threaded hole is called | `threadedName`, `app/shared/threads.ts` | +| what the panel and the ⓘ dialog call it | `nameOf`, handed down by `part.tsx` | +| the tool table and its marks | `app/components/part-tool-table.tsx` | +| what overruling the rules offers | `overridableTools`, `shared/tool-fit.ts` | +| the warning and its confirm | `OverrideNotice`, `components/column-filter.tsx` | +| what a filter is not showing, and the `…` | `TermFilter`, `components/column-filter.tsx` | +| what a tick on Type asks of the forms | `formsAsking`, `app/shared/tool-type.ts` | +| which slots were filled against them | `overrides`, `shared/assembly-tree.ts` | +| everything wired together | `app/routes/part.tsx` | Each pure module owns its tests. `tests/on-the-part.spec.ts` walks the paths that begin with a click on the part, against the cube fixture — the only fixture that From 355d598b338b9166a4eb9fd1f54fffe84540c6c8 Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Fri, 11 Sep 2026 08:43:18 -0400 Subject: [PATCH 06/12] app-234 - Move hyperlink from vendor column to catalog number column --- .../app/components/component-table.test.tsx | 39 ++++++++++++++++- .../app/components/component-table.tsx | 37 +++++++++------- .../app/components/part-tool-table.test.tsx | 43 +++++++++++++++++++ .../app/components/part-tool-table.tsx | 37 +++++++++------- 4 files changed, 122 insertions(+), 34 deletions(-) diff --git a/apps/catalog/app/components/component-table.test.tsx b/apps/catalog/app/components/component-table.test.tsx index 030d56f..a729a34 100644 --- a/apps/catalog/app/components/component-table.test.tsx +++ b/apps/catalog/app/components/component-table.test.tsx @@ -30,12 +30,16 @@ const holder: Holder = { provenance: {}, } -const show = (onQuery = vi.fn(), gap?: (guid: string) => string | null) => { +const show = ( + onQuery = vi.fn(), + gap?: (guid: string) => string | null, + records: Array = [holder], +) => { render(
{ expect(screen.queryByText('no collet')).toBeNull() }) }) + +/** + * **The vendor's page is on the number** (Paul, 2026-09-11: move the vendor + * links from the vendor cells to the catalog number cells). The order list has + * read that way since 2026-09-01, and a link a cell away from the number it + * opens is the thing a shop reaches for by the number. + */ +describe('where a holder row carries the vendor link', () => { + const linked: Holder = { ...holder, productLink: 'https://example.com/BT30ER11060M' } + + it('hangs it off the catalog number', () => { + show(vi.fn(), undefined, [linked]) + + const link = screen.getByRole('link', { name: 'Open BT30ER11060M at the vendor' }) + expect(link).toHaveAttribute('href', 'https://example.com/BT30ER11060M') + expect(screen.getByText('BT30ER11060M').parentElement).toContainElement(link) + }) + + it('leaves the vendor cell with nothing but the vendor', () => { + show(vi.fn(), undefined, [linked]) + + const brand = screen.getByTitle('Kennametal') + expect(brand.parentElement?.querySelector('a')).toBeNull() + }) + + it('draws no link where the vendor published none', () => { + show() + + expect(screen.queryByRole('link', { name: /at the vendor/ })).toBeNull() + }) +}) diff --git a/apps/catalog/app/components/component-table.tsx b/apps/catalog/app/components/component-table.tsx index c8b7da9..2e00075 100644 --- a/apps/catalog/app/components/component-table.tsx +++ b/apps/catalog/app/components/component-table.tsx @@ -385,6 +385,25 @@ export const ComponentTable = ({ {column.code === 'catalogNumber' ? ( <> {record.catalogNumber} + {/* + **The vendor's page is on the number** (Paul, + 2026-09-11), as it already is on the order list: the + catalogue number is what a shop orders by and looks + up, so the link belongs beside it rather than a cell + away in Vendor. + */} + {record.productLink === null ? null : ( + event.stopPropagation()} + className="text-info ml-1 shrink-0" + > + + + )} {/* What is on the feature already, so a swap can be backed out of by eye as well as by the Cancel beside @@ -432,22 +451,8 @@ export const ComponentTable = ({ )} ) : column.code === 'brand' ? ( - - - {record.brand} - - {record.productLink === null ? null : ( - event.stopPropagation()} - className="shrink-0 text-info" - > - - - )} + + {record.brand} ) : ( diff --git a/apps/catalog/app/components/part-tool-table.test.tsx b/apps/catalog/app/components/part-tool-table.test.tsx index aa0021b..f0a7bbf 100644 --- a/apps/catalog/app/components/part-tool-table.test.tsx +++ b/apps/catalog/app/components/part-tool-table.test.tsx @@ -412,3 +412,46 @@ describe('the filters a heading asks', () => { expect(within(menu).queryByRole('textbox')).not.toBeInTheDocument() }) }) + +/** + * **The vendor's page is on the number** (Paul, 2026-09-11: move the vendor + * links from the vendor cells to the catalog number cells). The order list has + * read that way since 2026-09-01, and a link a cell away from the number it + * opens is the thing a shop reaches for by the number. + */ +describe('where a tool row carries the vendor link', () => { + const linked: CatalogTool = { ...first, productLink: 'https://example.com/T-20' } + + it('hangs it off the catalog number', () => { + show({ tools: [linked] }) + + const link = screen.getByRole('link', { name: 'Open T-20 at the vendor' }) + expect(link).toHaveAttribute('href', 'https://example.com/T-20') + expect(screen.getByText('T-20').parentElement).toContainElement(link) + }) + + it('leaves the vendor cell with nothing but the vendor', () => { + show({ tools: [linked] }) + + const brand = screen.getByTitle('Acme') + expect(brand.parentElement?.querySelector('a')).toBeNull() + }) + + /** + * And the press on it is not a press on the row: the number is where a row + * is clicked to select the tool, so the link has to stop there. + */ + it('does not choose the tool when the link is pressed', () => { + const onChoose = show({ tools: [linked] }) + + fireEvent.click(screen.getByRole('link', { name: 'Open T-20 at the vendor' })) + + expect(onChoose).not.toHaveBeenCalled() + }) + + it('draws no link where the vendor published none', () => { + show() + + expect(screen.queryByRole('link', { name: /at the vendor/ })).toBeNull() + }) +}) diff --git a/apps/catalog/app/components/part-tool-table.tsx b/apps/catalog/app/components/part-tool-table.tsx index 628f1eb..1618d32 100644 --- a/apps/catalog/app/components/part-tool-table.tsx +++ b/apps/catalog/app/components/part-tool-table.tsx @@ -619,6 +619,25 @@ export const PartToolTable = ({ {column.code === 'catalogNumber' ? ( <> {tool.catalogNumber} + {/* + **The vendor's page is on the number** (Paul, + 2026-09-11), as it already is on the order list: the + catalogue number is what a shop orders by and looks + up, so the link belongs beside it rather than a cell + away in Vendor. + */} + {tool.productLink === null ? null : ( + event.stopPropagation()} + className="text-info ml-1 shrink-0" + > + + + )} {here || elsewhere ? ( ) : column.code === 'brand' ? ( - - - {tool.brand} - - {tool.productLink === null ? null : ( - event.stopPropagation()} - className="shrink-0 text-info" - > - - - )} + + {tool.brand} ) : column.code === 'type' ? ( Date: Fri, 11 Sep 2026 09:01:38 -0400 Subject: [PATCH 07/12] app-228 (holder type column adjustment) and app-244 (edit group fix) --- AGENTS.md | 2 + .../app/components/component-table.test.tsx | 24 ++-- .../app/components/component-table.tsx | 12 +- apps/catalog/app/routes/part.tsx | 119 ++++++++++++++---- .../catalog/app/shared/column-filters.test.ts | 29 +++-- apps/catalog/app/shared/column-filters.ts | 23 ++-- .../app/shared/component-columns.test.ts | 18 +++ apps/catalog/app/shared/component-columns.ts | 24 +++- apps/catalog/app/shared/component-query.ts | 18 +-- apps/catalog/tests/on-the-part.spec.ts | 56 +++++++++ docs/FEATURE-LIST.md | 29 ++++- 11 files changed, 284 insertions(+), 70 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 19d4d45..7bd14ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -275,6 +275,8 @@ application unless that application says otherwise. | 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` | diff --git a/apps/catalog/app/components/component-table.test.tsx b/apps/catalog/app/components/component-table.test.tsx index a729a34..6a157ae 100644 --- a/apps/catalog/app/components/component-table.test.tsx +++ b/apps/catalog/app/components/component-table.test.tsx @@ -118,20 +118,28 @@ describe('the filters a holder heading asks', () => { }) /** - * **The type is a list of its own** (Paul, 2026-09-08: "I should be able to - * filter by holder type as a list … same with collet type"). It is three of - * a holder's columns said as one phrase — `BT30 ER11 collet chuck` — and - * that phrase is what a shop calls the thing; the three behind it still ask - * for themselves, so one press can take every BT30 or every BT30 ER11 collet - * chuck. + * **A holder's type is how it grips** (Paul, 2026-09-11: the Type field was + * incorrect and came off, and Clamping is what Type means now). It used to be + * a phrase glued out of three other columns — `BT30 ER11 collet chuck` — + * which repeated Taper and Collet series and could disagree with them; the + * heading writes the `clamping` axis now, and Taper still asks for itself. */ - it('offers the type a holder reads as, as a list', () => { + it('narrows how a holder grips from the Type heading', () => { const onQuery = show() fireEvent.click(screen.getByRole('button', { name: 'Filter by Type' })) fireEvent.click(screen.getByRole('checkbox', { name: 'BT30' })) - expect(onQuery).toHaveBeenCalledWith({ text: '', terms: { type: ['BT30'] }, bounds: {} }) + expect(onQuery).toHaveBeenCalledWith({ text: '', terms: { clamping: ['BT30'] }, bounds: {} }) + }) + + /** One Type heading, not two: the glued phrase is off the rack entirely. */ + it('shows the type once, as how it grips', () => { + show() + + expect(screen.getAllByRole('button', { name: 'Filter by Type' })).toHaveLength(1) + expect(screen.getByText('collet chuck')).toBeVisible() + expect(screen.queryByText('BT30 ER11 collet chuck')).toBeNull() }) }) diff --git a/apps/catalog/app/components/component-table.tsx b/apps/catalog/app/components/component-table.tsx index 2e00075..bce02f3 100644 --- a/apps/catalog/app/components/component-table.tsx +++ b/apps/catalog/app/components/component-table.tsx @@ -45,8 +45,8 @@ import { * table carries the rules' marks, the holding comboboxes and the bill's `on * list` badge, all of which are about a *tool*, and threading a row type * through them would have put every one of those behind a conditional to gain a - * shared shell. The four columns every component has — number, vendor, type, - * family — are fixed here for the same reason they are fixed there. + * shared shell. The columns every component has — number, vendor, family — are + * fixed here for the same reason they are fixed there. */ /** @@ -164,6 +164,9 @@ const WIDTH: Readonly> = { catalogNumber: '10rem', brand: '7rem', type: '11rem', + // The holder's Type: `end mill holder` in the default six is two lines of + // ellipsis. + clamping: '9rem', familyId: '9rem', } @@ -274,9 +277,8 @@ export const ComponentTable = ({ /** * One heading, with the two things it can do: sort, and narrow. * - * The type a holder reads as is the only heading that asks nothing — it is - * three of the other columns said as one phrase, so narrowing on it would be - * a fourth way to ask what Taper, Clamping and Collet series already ask. + * Which heading asks what is `shared/column-filters.ts`; a heading it says + * nothing for sorts and offers no funnel. */ const filterProps = (code: string, label: string): ColumnHeadingProps => { if (filtering === undefined) { diff --git a/apps/catalog/app/routes/part.tsx b/apps/catalog/app/routes/part.tsx index 4dd1aa9..dcd6e54 100644 --- a/apps/catalog/app/routes/part.tsx +++ b/apps/catalog/app/routes/part.tsx @@ -787,6 +787,19 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: const selectedItem = useMemo(() => itemNamed(list, selectedId), [list, selectedId]) + /** + * The row a draft is changing, where it is changing one rather than making + * one. + * + * **A row being edited is still that row** (Paul, 2026-09-11: "edit group + * does not show the tool assembly applied to the group"). Editing clears the + * selection — the box over the part is the question now, not the row — so + * every rule that reads {@link selectedItem} lost sight of a row that had not + * gone anywhere, and the tree beside the editor opened on empty stacks while + * the order list underneath it was showing that group's tools. + */ + const editedItem = useMemo(() => itemNamed(list, draft?.editing ?? null), [list, draft?.editing]) + /** * The rows the order list draws: every row on the list. * @@ -1577,6 +1590,14 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: */ const activeItem = useMemo( () => + /* + **A row being edited is the row in play**, whatever the draft's tags + have been dragged down to meanwhile: dropping a face out of a group + leaves the search below matching some other row that happens to hold + the rest, and the press under the stack would then write that row's + lines (Paul, 2026-09-11). + */ + editedItem ?? /* **An assembly being built is not a row, and is not any other row either** (Paul, 2026-09-08). It asks about no features, so the search @@ -1605,13 +1626,13 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: under the stack read as *add a tool to that feature* and the group was never made — the confirm button used to make it, and there is no confirm button any more. A group being *edited* is the row it - is editing, which the search below still finds. + is editing — `editedItem` above. */ draft?.kind === 'group' && draft.editing === null ? null : list.find((item) => askedNow.tags.every((tag) => item.tags.includes(tag)))) ?? null, - [selectedItem, list, askedNow.tags, draft?.kind, draft?.editing], + [editedItem, selectedItem, list, askedNow.tags, draft?.kind, draft?.editing], ) /* ----------------------- the tool assembly tree ------------------------- */ @@ -1683,6 +1704,17 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: */ const treeKey = /* + **A row being changed keeps its own stacks** (Paul, 2026-09-11: "edit + group does not show the tool assembly applied to the group"). Editing + opens a draft, and every draft below is keyed by what is being *built* — + so the tree beside the editor opened empty on a row the order list was + already answering, and the assembly somebody was there to change was not + on screen at all. The row exists, its stacks are keyed by its id, and an + edit is a change to those rather than a new thing beside them. + */ + editedItem !== null + ? editedItem.id + : /* **Only the group editor takes the plain key** (Paul, 2026-09-07: "new hole selections should be treated as new"). Its tags change under the mouse as faces are toggled, so a key that moved with them would reset the tree @@ -1691,32 +1723,32 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: built for one hole were still standing the next time anybody pressed *Add feature*, on a hole nobody had called threaded. */ - draft?.kind === 'group' - ? DRAFT_TREE - : /* + draft?.kind === 'group' + ? DRAFT_TREE + : /* **An assembly being built keeps its stacks under a key of its own.** It has no tags, so `draftKeyFor([])` is that key — and `carryDraftTree` reads the same one when the press makes the row, so nothing built before the press is lost by making it. */ - draft?.kind === 'assembly' - ? draftKeyFor([]) - : /* + draft?.kind === 'assembly' + ? draftKeyFor([]) + : /* **A part-level assembly is a tree with no question above it** (Paul, 2026-09-08). It asks about no feature, so `asking` is false for it — and the row is still the thing being built, keyed by its own id like every other row's tree. */ - selectedItem?.kind === 'assembly' - ? selectedItem.id - : // Nothing asked is nothing to assemble: the table is the whole catalog - // then, and a tree beside it would be answering for a feature nobody - // has selected. - !asking - ? null - : draft !== null - ? draftKeyFor(askedNow.tags) - : (activeItem?.id ?? draftKeyFor(askedNow.tags)) + selectedItem?.kind === 'assembly' + ? selectedItem.id + : // Nothing asked is nothing to assemble: the table is the whole catalog + // then, and a tree beside it would be answering for a feature nobody + // has selected. + !asking + ? null + : draft !== null + ? draftKeyFor(askedNow.tags) + : (activeItem?.id ?? draftKeyFor(askedNow.tags)) /** * The stacks for that row — what was built, or what the bill already holds. * @@ -1742,8 +1774,14 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: A draft opens on empty stacks rather than on the bill: it has no lines yet, and reading the focused feature's would show another row's answers under a feature being created. + + **A draft that is changing a row is the exception** (Paul, 2026-09-11): it + has lines, they are that row's, and opening empty in front of somebody who + came to change a holder is the bug. `editedItem` is the row, and the keys + below are its own. */ - return draft !== null || treeKey === DRAFT_TREE + const row = editedItem ?? selectedItem + return (draft !== null && editedItem === null) || treeKey === DRAFT_TREE ? defaultAssemblies(threaded) : treeFromLines( /* @@ -1752,13 +1790,13 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: on whatever face was last clicked. Every key of the row it is, because that is where the lines were written (`shared/order-list`). */ - linesOf(sheet, selectedItem === null ? [choiceKey] : sheetKeysOf(selectedItem)), + linesOf(sheet, row === null ? [choiceKey] : sheetKeysOf(row)), threaded, // Which line is the tap is a fact about the tool, and the catalog is // the route's to read. (toolGuid) => getTool(toolGuid)?.form.startsWith('tap ') ?? false, ) - }, [trees, treeKey, sheet, choiceKey, threadSpec, draft, selectedItem]) + }, [trees, treeKey, sheet, choiceKey, threadSpec, draft, selectedItem, editedItem]) /** * The slot open now: what was clicked while it still exists, else the first @@ -3556,6 +3594,41 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: [list], ) + /** + * A press on a row of the order list. + * + * **A group row opens the group** (Paul, 2026-09-11: clicking one "opens an + * individual feature dialog rather than the dialog for the group … it should + * work like right click *edit group* does"). Selecting a group read the first + * feature it holds, so what stood over the part was one hole's box — a + * question about one feature where the question about all of them belongs, + * and no way in to the faces the group is made of except the menu. There is + * one way into a group now, and the right-click item is the second door onto + * it rather than the only one. + * + * **The lines under the row are the same press** (Paul, 2026-09-11: "clicking + * on the tool assembly itself in the order list still brings me to a single + * feature. The tool assembly(s) added to the GROUP should open the GROUP + * dialog"). A group's answers are the group's, so pressing one asks the + * group's question: there is one way into a group, and every part of its row + * takes it. + * + * **A tag is the exception**, because it names something else — a feature + * inside a group opened for *one tool each* is its own question, and + * {@link selectRow} is what that press has always meant. + */ + const pressRow = useCallback( + (id: string | null, tag: string | null, toolGuid?: string) => { + const item = itemNamed(list, id) + if (item?.kind === 'group' && tag === null) { + startEdit(item.id) + return + } + selectRow(id, tag, toolGuid) + }, + [list, startEdit, selectRow], + ) + /** * The reading on screen, kept. * @@ -5852,8 +5925,10 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: items={orderRows} selectedId={selectedId} selectedTag={selectedTag} + /* A group row opens the group; everything + else selects what it names — `pressRow`. */ onSelect={(id, tag, toolGuid) => - selectRow(id, tag ?? null, toolGuid) + pressRow(id, tag ?? null, toolGuid) } chosenTool={chosenTool} /* diff --git a/apps/catalog/app/shared/column-filters.test.ts b/apps/catalog/app/shared/column-filters.test.ts index 958c388..4490881 100644 --- a/apps/catalog/app/shared/column-filters.test.ts +++ b/apps/catalog/app/shared/column-filters.test.ts @@ -113,22 +113,33 @@ describe('what a holder or collet column asks', () => { } }) - /** - * The type a holder reads as is its taper, its series and its clamping said - * as one phrase — and that phrase is what a shop calls the thing, so it is a - * list of its own (Paul, 2026-09-08). The three columns behind it still ask - * for themselves: one press for every BT30, or one for every BT30 ER11 - * collet chuck. - */ it('searches the catalog number, the one answer a shop arrives with', () => { expect(askOfComponentColumn('holder', 'catalogNumber')).toEqual({ shape: 'text' }) expect(askOfComponentColumn('collet', 'catalogNumber')).toEqual({ shape: 'text' }) }) - it('offers the type a component reads as, as a list', () => { - expect(askOfComponentColumn('holder', 'type')).toEqual({ shape: 'terms', axis: 'type' }) + /** + * A collet's type is its series said as a phrase — `ER20 collet` — and that + * phrase is what a shop calls the thing, so it is a list of its own (Paul, + * 2026-09-08). + */ + it('offers the type a collet reads as, as a list', () => { expect(askOfComponentColumn('collet', 'type')).toEqual({ shape: 'terms', axis: 'type' }) }) + + /** + * **A holder has no such column** (Paul, 2026-09-11): the phrase glued its + * taper and its collet series onto how it grips, so it said what Taper and + * Collet series already said and could disagree with them. How it grips is + * the holder's type, and `clamping` is the axis under that heading. + */ + it('asks a holder nothing for the type that came off', () => { + expect(askOfComponentColumn('holder', 'type')).toBeNull() + expect(askOfComponentColumn('holder', 'clamping')).toEqual({ + shape: 'terms', + axis: 'clamping', + }) + }) }) /** diff --git a/apps/catalog/app/shared/column-filters.ts b/apps/catalog/app/shared/column-filters.ts index d4bed94..0159f29 100644 --- a/apps/catalog/app/shared/column-filters.ts +++ b/apps/catalog/app/shared/column-filters.ts @@ -120,10 +120,9 @@ export const AXES_PARKED: ReadonlyArray = [ /** * What the header over a holder or collet column asks. * - * The type and the term axes first — type, brand and family are fixed columns - * rather than `columnsFor` entries, and each is narrowed on the words its cell - * shows — then every length, which is the same rule the filter panel used when - * it built a range control per length column. + * The type and the term axes first — type, brand and family are narrowed on the + * words their cells show — then every length, which is the same rule the filter + * panel used when it built a range control per length column. */ export const askOfComponentColumn = (kind: ComponentKind, code: string): ColumnAsk | null => { // The one column a shop arrives at already knowing the answer to — the same @@ -132,12 +131,18 @@ export const askOfComponentColumn = (kind: ComponentKind, code: string): ColumnA return { shape: 'text' } } /** - * The type is a column of its own before it is an axis: it is three of a - * holder's columns said as one phrase — `BT30 ER11 collet chuck` — and that - * phrase is what a shop calls the thing (Paul, 2026-09-08). `termOn` builds - * it, so the list a header offers is the words the column shows. + * A collet's type is a column of its own before it is an axis: `ER20 collet` + * is what a shop calls the thing (Paul, 2026-09-08), and `termOn` builds it, + * so the list a header offers is the words the column shows. + * + * A **holder has no such column** — the phrase glued its Taper and Collet + * series onto how it grips and was wrong for it (Paul, 2026-09-11). Its type + * is the `clamping` axis, under the heading now called Type. */ - if (code === 'type' || termAxesFor(kind).some((axis) => axis.code === code)) { + if ( + (code === 'type' && kind === 'collet') || + termAxesFor(kind).some((axis) => axis.code === code) + ) { return { shape: 'terms', axis: code } } const column = columnsFor(kind).find((each) => each.code === code) diff --git a/apps/catalog/app/shared/component-columns.test.ts b/apps/catalog/app/shared/component-columns.test.ts index 3a3ca9b..347f8c1 100644 --- a/apps/catalog/app/shared/component-columns.test.ts +++ b/apps/catalog/app/shared/component-columns.test.ts @@ -93,6 +93,24 @@ describe('reading a value', () => { }) describe('columns', () => { + /** + * **The holder Type field was incorrect and came off** (Paul, 2026-09-11). + * `BT30 ER16 collet chuck` was Taper, Collet series and Clamping glued into + * one phrase, so the rack said each of them twice; how a holder grips is the + * honest answer, and Clamping is the heading called Type now. + */ + it('reads a holder type as how it grips, under one heading', () => { + const labels = HOLDER_COLUMNS.filter((column) => column.label === 'Type') + expect(labels.map((column) => column.code)).toEqual(['clamping']) + expect(HOLDER_COLUMNS.some((column) => column.code === 'type')).toBe(false) + expect(holderValue(holder({}), 'type')).toBeNull() + }) + + /** A collet keeps its own, which is its series and nothing else: `ER20 collet`. */ + it('keeps the type a collet reads as', () => { + expect(COLLET_COLUMNS.some((column) => column.code === 'type')).toBe(true) + }) + it('hides the ones that are read only when something does not clear', () => { expect(hiddenByDefault(HOLDER_COLUMNS)).toContain('flangeDiameter') expect(hiddenByDefault(HOLDER_COLUMNS)).not.toContain('gaugeLength') diff --git a/apps/catalog/app/shared/component-columns.ts b/apps/catalog/app/shared/component-columns.ts index 46301a1..11c627a 100644 --- a/apps/catalog/app/shared/component-columns.ts +++ b/apps/catalog/app/shared/component-columns.ts @@ -41,14 +41,27 @@ export interface ComponentColumn { * headers the picker had never heard of — so a list could not be cut down to * the two things somebody was comparing, and the four columns this session * added or renamed were missing from the one place that lists columns. + * + * A *collet* still carries a derived Type — `ER20 collet` — because its series + * and its type are the same fact said twice. A **holder does not** (Paul, + * 2026-09-11: the holder Type field is incorrect): `BT30 ER16 collet chuck` was + * three other columns glued together, so it repeated Taper and Collet series + * and disagreed with them wherever one of the three was read differently. How a + * holder grips is the honest answer to "what type is it", and that is the + * Clamping column, which is called **Type** now. */ const IDENTITY: ReadonlyArray = [ { code: 'catalogNumber', label: 'Catalog number', kind: 'text', default: true }, { code: 'brand', label: 'Vendor', kind: 'text', default: true }, - { code: 'type', label: 'Type', kind: 'text', default: true }, - { code: 'familyId', label: 'Family', kind: 'text', default: true }, ] +const FAMILY: ComponentColumn = { + code: 'familyId', + label: 'Family', + kind: 'text', + default: true, +} + /** * What a holder is, then the nine numbers a vendor publishes. * @@ -58,8 +71,9 @@ const IDENTITY: ReadonlyArray = [ */ export const HOLDER_COLUMNS: ReadonlyArray = [ ...IDENTITY, + FAMILY, { code: 'taper', label: 'Taper', kind: 'text', default: true }, - { code: 'clamping', label: 'Clamping', kind: 'text', default: true }, + { code: 'clamping', label: 'Type', kind: 'text', default: true }, { code: 'colletSeries', label: 'Collet series', kind: 'text', default: true }, { code: 'gaugeLength', label: 'Gauge length', kind: 'length', default: true }, { code: 'projection', label: 'Projection', kind: 'length', default: true }, @@ -75,6 +89,8 @@ export const HOLDER_COLUMNS: ReadonlyArray = [ export const COLLET_COLUMNS: ReadonlyArray = [ ...IDENTITY, + { code: 'type', label: 'Type', kind: 'text', default: true }, + FAMILY, { code: 'series', label: 'Series', kind: 'text', default: true }, { code: 'clampMin', label: 'Grips from', kind: 'length', default: true }, { code: 'clampMax', label: 'Grips to', kind: 'length', default: true }, @@ -129,8 +145,6 @@ export const holderValue = (holder: Holder, code: string): number | string | nul return holder.catalogNumber case 'brand': return holder.brand - case 'type': - return holderTypeLabel(holder) case 'familyId': return familyLabel(holder.familyId) case 'taper': diff --git a/apps/catalog/app/shared/component-query.ts b/apps/catalog/app/shared/component-query.ts index ad87c93..d3fa625 100644 --- a/apps/catalog/app/shared/component-query.ts +++ b/apps/catalog/app/shared/component-query.ts @@ -45,7 +45,9 @@ export interface Bound { export const HOLDER_TERM_AXES: ReadonlyArray<{ code: string; label: string }> = [ { code: 'brand', label: 'Brand' }, { code: 'taper', label: 'Taper' }, - { code: 'clamping', label: 'Clamping' }, + // The heading over it says Type, and the button that clears filters names an + // axis by its column — `narrowingNames` — so the two have to agree. + { code: 'clamping', label: 'Type' }, { code: 'colletSeries', label: 'Collet series' }, { code: 'contact', label: 'Contact' }, { code: 'familyId', label: 'Family' }, @@ -157,15 +159,15 @@ export const termOn = ( return familyLabel(record.familyId) } /** - * **What a holder *is*, as one line of a list** (Paul, 2026-09-08: "I should + * **What a collet *is*, as one line of a list** (Paul, 2026-09-08: "I should * be able to filter by holder type as a list. Let's add that … same with - * collet type"). + * collet type"). `ER20 collet` is how a shop says it out loud, and it is the + * phrase the Type column shows. * - * `BT30 ER11 collet chuck` is how a shop says it out loud, and it is the - * phrase the Type column already shows. The three columns behind it — taper, - * collet series, clamping — still ask for themselves, because a shop that - * wants every BT30 it owns should not have to tick eleven phrases to get - * them; this is the shortcut through all three at once. + * A holder answered here too until 2026-09-11, with `BT30 ER11 collet chuck` + * — three of its columns glued into one phrase, and wrong for it. Its Type + * column is `clamping` now, which answers through `valueOf` like any other + * word, so nothing asks a holder this. */ if (code === 'type') { return kind === 'holder' ? holderTypeLabel(record as Holder) : colletTypeLabel(record as Collet) diff --git a/apps/catalog/tests/on-the-part.spec.ts b/apps/catalog/tests/on-the-part.spec.ts index 8226c2c..3814959 100644 --- a/apps/catalog/tests/on-the-part.spec.ts +++ b/apps/catalog/tests/on-the-part.spec.ts @@ -1076,6 +1076,62 @@ test('opens a group from a caret the width of the gutter', async ({ page }) => { expect(starts[0]).toBe(starts[1]) }) +/** + * **A press on a group row opens the group** (Paul, 2026-09-11: clicking one + * "opens an individual feature dialog rather than the dialog for the group … + * it should work like right click *edit group* does"), **and the group's + * assembly is in it** ("edit group does not show the tool assembly applied to + * the group"). + * + * Two halves of one way in, and each was broken on its own: selecting a group + * read the first feature it held, so the box over the part asked about one + * hole — and the editor, however it was opened, keyed its tree by what was + * being *built*, so the stack somebody came to change was not on screen at all + * while the row underneath went on listing it. + */ +test('opens a group, with the assembly it was ordered with, from its row', async ({ page }) => { + await ready(page) + await page.getByRole('button', { name: '+ Group' }).click() + await inTheGroup(page) + await pickTool(page) + + const tree = page.locator('[data-assembly-tree]') + // The catalog number the stack holds now, so the reopened tree can be checked + // against it rather than against whichever tool this fixture sorts first. The + // slot's own text is `TOOL` and then the number, so the label comes off it. + const slot = tree.getByRole('button', { name: /^TOOL for / }) + const tool = ((await slot.textContent()) ?? '').replace(/^TOOL/, '').trim() + expect(tool).not.toBe('') + await orderPress(page).click() + + // The press that orders closes the box; the row is the way back in. + await openRow(page) + + // The group's own box, not a feature's — the same one *Edit group…* opens. + await expect(page.getByText('Edit group')).toBeVisible() + await expect(page.getByRole('button', { name: 'Save group' })).toBeVisible() + // And what it was ordered with is standing in the tree beside it. + await expect(tree.getByRole('button', { name: /^TOOL for / })).toContainText(tool) + await expect(tree.getByRole('button', { name: 'Remove from order list' })).toBeVisible() + + /* + **And the line under the row is the same press** (Paul, 2026-09-11: + "clicking on the tool assembly itself in the order list still brings me to a + single feature"). A group's answers are the group's, so pressing one asks + the group's question rather than opening the first hole it holds. + */ + await page.getByRole('button', { name: 'Close this dialog' }).click() + await expect(page.getByText('Edit group')).toBeHidden() + + const list = await orderList(page) + // The row's own control is the first pressable thing on it; the line under it + // is the assembly it was ordered with. + await list.locator(':scope > li').first().locator('button[aria-pressed]').nth(1).click() + + await expect(page.getByText('Edit group')).toBeVisible() + await expect(tree.getByRole('button', { name: /^TOOL for / })).toContainText(tool) +}) + /** * **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 diff --git a/docs/FEATURE-LIST.md b/docs/FEATURE-LIST.md index b85c4ec..56241b1 100644 --- a/docs/FEATURE-LIST.md +++ b/docs/FEATURE-LIST.md @@ -465,6 +465,21 @@ nothing looks exactly like one answering a feature nobody can see any more. a thing being ordered, and nothing reaches the bill except because a row here put it there; the page in the header is the same list read the other way round. +**A press on a row opens what that row is.** A feature row is selected — the +table below is asked its question and the stack beside it is its answer. A +**group row opens the group** (Paul, 2026-09-11: clicking one "opens an +individual feature dialog rather than the dialog for the group … it should work +like right click _edit group_ does"): selecting a group read the first feature it +held, so the box over the part asked about one hole of thirty-nine and there was +no way into the faces the group is made of except the right-click menu. +**And the lines under a group row are the same press** (Paul, 2026-09-11: +"clicking on the tool assembly itself in the order list still brings me to a +single feature. The tool assembly(s) added to the GROUP should open the GROUP +dialog"). A group's answers are the group's, so there is one way into a group and +every part of its row takes it. `pressRow` in `routes/part.tsx` is the rule; the +one exception is a **tag**, which names something else — a feature inside a group +opened for _one tool each_ is its own question, and that press still selects it. + ### What the list holds **Every row, answered or not** (Paul, 2026-09-10: "I should be able to create a @@ -547,9 +562,10 @@ same keys, and `clearKeys` clears all of them. **One line per tool the row is answered with**, each with what it is held in beneath the catalog number. Pressing one asks that row's question in full — the tool table below fills with everything that fits — and opens _that_ tool in the -panel beside the table, which is where it is removed or re-held. Without that -press there is no way to reach the second tool of a feature, and no way to take -it off. +panel beside the table, which is where it is removed or re-held. **On a group the +line opens the group** (Paul, 2026-09-11), because a group's answers are the +group's — `pressRow`, § _A row_. Without that press there is no way to reach the +second tool of a feature, and no way to take it off. **The decision where there is one, the recommendation where there is not.** Once a tool is on the bill for a feature, that — with its holder and collet — is the @@ -582,7 +598,9 @@ nose end mill`, with the diameter at the right (Paul, 2026-09-09: "I'd like to ### Right-click -**Edit…** and **Remove**, fixed to the window at the click point. A part-level +**Edit…** and **Remove**, fixed to the window at the click point. On a group +_Edit group…_ is the second door onto what pressing the row already opens, not +the only one. A part-level assembly is offered **Rename…** and **Remove**: it holds no features, so there is nothing an editor could ask about — what it does have is a name, which is the second way in after the one the press that made it opened. Positioned @@ -939,6 +957,9 @@ true of the work the worker does: | whether the offer to group them is made | `app/shared/group-offer.ts` | | the offer on screen, and both answers | `identical`, `components/selection-panel.tsx` | | a feature row turned into a group | `changeToGroup`, `app/routes/part.tsx` | +| what a press on a row of the list opens | `pressRow`, same file | +| the row a draft is changing, where it is one | `editedItem`, same file | +| whose stacks the tree beside the box shows | `treeKey`, same file | | which key a reading's lines are kept under | `choiceKey`, same file | | which holes a thread choice is written to | `writeThread` / `holesAt`, `shared/hole-mode.ts` | | the reading and its thread | `app/components/selection-panel.tsx` | From 7da3ee386486c4c696604471fa462ff193ccfff8 Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Fri, 11 Sep 2026 09:24:28 -0400 Subject: [PATCH 08/12] app-241 increase contrast --- apps/catalog/app/components/part-viewer.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/catalog/app/components/part-viewer.tsx b/apps/catalog/app/components/part-viewer.tsx index f8c3161..11ace47 100644 --- a/apps/catalog/app/components/part-viewer.tsx +++ b/apps/catalog/app/components/part-viewer.tsx @@ -455,10 +455,19 @@ export const PartViewer = ({ ({ tag, color: 0x3f4650, weight: 0.55 }))} + // Under everything else: a feature with a tool kept for it + // reads as done rather than as chosen. Darker than it was and + // a little flatter (Paul, 2026-09-11) — 0x3f4650 at 0.55 was a + // shade of the part rather than a mark on it, and 0x1f232a at + // 1 was a black hole in it. + // + // **The weight is the shine.** The paint mixes into the + // material's diffuse colour, so whatever weight is left over + // keeps that fraction of the part's own white in the lit term + // — and the white is what catches the bright side of the + // hemisphere light. Hence a shade under 1: enough of the rig + // left to read the face as a surface, not enough to gloss it. + highlights={tooled.map((tag) => ({ tag, color: 0x333b46, weight: 0.85 }))} pickedRegions={heldRegions} hoveredFeatureIds={hovered === null ? [] : [hovered]} focusFeature={focus} From cdf64c134d433667f4ead8d9e95961b3473266de Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Fri, 11 Sep 2026 09:34:28 -0400 Subject: [PATCH 09/12] order list fixes/improvements --- .../app/components/component-tally.test.tsx | 23 +++++++++++++++ .../app/components/component-tally.tsx | 28 +++++++++++++++++-- .../app/components/feature-list-panel.tsx | 18 ++++++++++-- apps/catalog/app/routes/part.tsx | 10 ++++++- 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/apps/catalog/app/components/component-tally.test.tsx b/apps/catalog/app/components/component-tally.test.tsx index cc50a88..42c9edf 100644 --- a/apps/catalog/app/components/component-tally.test.tsx +++ b/apps/catalog/app/components/component-tally.test.tsx @@ -11,6 +11,7 @@ const holder = (over: Partial = {}): ComponentTallyRow => ({ catalogNumber: 'BT30-ER16', detail: 'BT30 collet chuck', count: 3, + productLink: null, uses: ['4 × Through Hole', 'Facing'], ...over, }) @@ -57,6 +58,28 @@ describe('the components view over the part', () => { expect(screen.getByTitle(/in 4 × Through Hole, Facing$/)).toBeInTheDocument() }) + /** + * **The vendor's page is on the number** (Paul, 2026-09-11), the way the + * order-list page has put it there since 2026-09-01: this is the view a shop + * buys from, so the thing it orders by is the thing that links out. + */ + it("links the part number to the vendor's page, where there is one", () => { + draw([holder({ productLink: 'https://example.com/BT30-ER16' })]) + + expect(screen.getByRole('link', { name: /BT30-ER16/ })).toHaveAttribute( + 'href', + 'https://example.com/BT30-ER16', + ) + }) + + /** A vendor that published none leaves the number plain rather than dead. */ + it('leaves the number plain where the vendor published no page', () => { + draw([holder()]) + + expect(screen.queryByRole('link')).not.toBeInTheDocument() + expect(screen.getByText('BT30-ER16')).toBeInTheDocument() + }) + it("says so in the page's own words when nothing is ordered", () => { draw([]) diff --git a/apps/catalog/app/components/component-tally.tsx b/apps/catalog/app/components/component-tally.tsx index 295171f..15e596a 100644 --- a/apps/catalog/app/components/component-tally.tsx +++ b/apps/catalog/app/components/component-tally.tsx @@ -1,4 +1,4 @@ -import { CaretDownIcon, CaretUpIcon } from '@phosphor-icons/react' +import { ArrowSquareOutIcon, CaretDownIcon, CaretUpIcon } from '@phosphor-icons/react' import { Button, cn } from '@toolpath/ui' import type { ReactNode } from 'react' import { @@ -35,6 +35,15 @@ export interface ComponentTallyRow extends SortableComponent { /** What kind of thing it is, for the column that says so. */ readonly kind: string readonly icon: ReactNode + /** + * The vendor's page for it, or null where the vendor published none. + * + * **On the number, the way the order-list page puts it there** (Paul, + * 2026-09-01: "vendor link should be in part ID cell"). The part number is + * what a shop orders by and looks up, so the link belongs on it rather than + * in a column of its own — and this view is the one a shop buys from. + */ + readonly productLink: string | null /** The assemblies it is in, for the row to say so when asked. */ readonly uses: ReadonlyArray } @@ -178,7 +187,22 @@ export const ComponentTally = ({ rows, empty, sort, descending, onSort }: Compon {row.brand} - {row.catalogNumber} + {row.productLink === null ? ( + + {row.catalogNumber} + + ) : ( + + {row.catalogNumber} + + )} {/* What it is, under what it is ordered by: the number keeps its width and the phrase takes the ellipsis. */} {row.detail} diff --git a/apps/catalog/app/components/feature-list-panel.tsx b/apps/catalog/app/components/feature-list-panel.tsx index 097c207..3603e80 100644 --- a/apps/catalog/app/components/feature-list-panel.tsx +++ b/apps/catalog/app/components/feature-list-panel.tsx @@ -415,7 +415,19 @@ export const FeatureListPanel = ({
+
+
    {(answer?.children.length ?? 0) > 0 ? answer?.children.map((child) => (
  • diff --git a/apps/catalog/app/routes/part.tsx b/apps/catalog/app/routes/part.tsx index dcd6e54..cedc3d3 100644 --- a/apps/catalog/app/routes/part.tsx +++ b/apps/catalog/app/routes/part.tsx @@ -3323,7 +3323,12 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: bill that quietly lost a line would report a part as tooled when it is not. */ - const gone = { brand: '—', catalogNumber: 'gone', detail: 'no longer in the catalog' } + const gone = { + brand: '—', + catalogNumber: 'gone', + detail: 'no longer in the catalog', + productLink: null, + } if (total.component === 'tool') { const tool = getTool(total.guid) return tool === null @@ -3333,6 +3338,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: icon: , brand: tool.brand, catalogNumber: tool.catalogNumber, + productLink: tool.productLink, detail: typeLabel(tool), } } @@ -3345,6 +3351,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: icon: , brand: holder.brand, catalogNumber: holder.catalogNumber, + productLink: holder.productLink, detail: holderTypeLabel(holder), } } @@ -3356,6 +3363,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: icon: , brand: collet.brand, catalogNumber: collet.catalogNumber, + productLink: collet.productLink, detail: colletTypeLabel(collet), } }), From 7b8776d7171a7cea27f44ab6f1332f750da2c4fb Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Fri, 11 Sep 2026 10:16:25 -0400 Subject: [PATCH 10/12] ui cleanup --- apps/catalog/app/components/column-filter.tsx | 6 +- .../app/components/feature-list-panel.tsx | 13 +- apps/catalog/app/components/filter-panel.tsx | 32 +- .../app/components/part-tool-table.tsx | 11 +- apps/catalog/app/components/part-viewer.tsx | 131 +- apps/catalog/app/routes/part.tsx | 1150 +++++++++-------- apps/catalog/tests/on-the-part.spec.ts | 18 +- 7 files changed, 782 insertions(+), 579 deletions(-) diff --git a/apps/catalog/app/components/column-filter.tsx b/apps/catalog/app/components/column-filter.tsx index e94a935..66168a0 100644 --- a/apps/catalog/app/components/column-filter.tsx +++ b/apps/catalog/app/components/column-filter.tsx @@ -1134,7 +1134,11 @@ export const ColumnPicker = ({ aria-expanded={open} title="Which columns to show" onClick={() => setOpen(!open)} - className="rounded p-1 text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-200" + /* **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" > diff --git a/apps/catalog/app/components/feature-list-panel.tsx b/apps/catalog/app/components/feature-list-panel.tsx index 3603e80..1eb9e7d 100644 --- a/apps/catalog/app/components/feature-list-panel.tsx +++ b/apps/catalog/app/components/feature-list-panel.tsx @@ -213,9 +213,18 @@ const Answer = ({ className={cn( STACKS, 'text-2xs flex w-full min-w-0 flex-col gap-0.5 rounded border px-1.5 py-0.5 text-left transition', + /* + **A stack stands on the part, not in a chip** (Paul, 2026-09-11: "make + the grey background from the tool assembly rows transparent"). The + kit's muted button fills its inner box with `dark:bg-zinc-800`, and + `cn` cannot merge that away with an unprefixed `bg-*` — the two are + different variants, so both survive and the dark one wins. Each + branch therefore says what it wants in the dark as well, which is the + row's own plate showing through rather than a grey tile on it. + */ here - ? 'border-info/60 bg-info/15 text-info' - : 'border-transparent text-zinc-400 hover:border-zinc-800 hover:bg-zinc-900/60 hover:text-zinc-200', + ? 'border-info/60 bg-info/15 text-info dark:bg-info/15' + : 'border-transparent bg-transparent text-zinc-400 hover:border-zinc-800 hover:bg-zinc-900/60 hover:text-zinc-200 dark:bg-transparent dark:group-hover:bg-zinc-900/60', )} > {/* diff --git a/apps/catalog/app/components/filter-panel.tsx b/apps/catalog/app/components/filter-panel.tsx index 9aff38c..f2df946 100644 --- a/apps/catalog/app/components/filter-panel.tsx +++ b/apps/catalog/app/components/filter-panel.tsx @@ -18,7 +18,7 @@ import { toggleTerm, type ToolQuery } from 'shared/filter' import { AXES_IN_TOOL_COLUMNS, AXES_PARKED } from 'shared/column-filters' import { useEscape } from 'shared/use-escape' import { Chip, ChipGroup } from './chip' -import { RangeFilter, type Bound, type Kind } from './column-filter' +import { menuRoom, RangeFilter, type Bound, type Kind } from './column-filter' import { DrillDeviationFields } from './drill-deviation' import { ColletIcon, @@ -430,7 +430,21 @@ const ToolbarFilterBody = ({ 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) { @@ -454,6 +468,10 @@ const ToolbarFilterBody = ({ ? right - menuRect.right : 0 setMenuOffset(correction) + const button = press.current?.getBoundingClientRect() + if (button !== undefined) { + setRoom(menuRoom(button, window.innerHeight)) + } } place() @@ -462,7 +480,7 @@ const ToolbarFilterBody = ({ }, [open]) return ( -
    +
    diff --git a/apps/catalog/app/components/part-tool-table.tsx b/apps/catalog/app/components/part-tool-table.tsx index 1618d32..3c03194 100644 --- a/apps/catalog/app/components/part-tool-table.tsx +++ b/apps/catalog/app/components/part-tool-table.tsx @@ -113,15 +113,18 @@ export const flexibleColumnWidth = (width: string): string => `minmax(${width}, const ROW = 33 /** - * Everything the rows share the panel with: the toolbar carrying the three list - * buttons and the filters, the column headings under it, and the hairline - * border of the card around the lot. + * Everything the rows share the panel with: the column headings, and the + * hairline border of the card around them. * * The border is two pixels and it is the difference between eight rows and * seven-and-a-bit — the panel's size is its outer box, and the rows get what is * inside it. + * + * **The toolbar is no longer one of them** (Paul, 2026-09-11): the three list + * buttons, the filters and the notes float over the bottom of the viewer now, + * so the 49 pixels they took out of the panel would open the list a row short. */ -const OVER_THE_ROWS = 49 + ROW + 2 +const OVER_THE_ROWS = ROW + 2 /** How many tools the list opens showing (Paul, 2026-09-10). */ export const TOOLS_ON_OPENING = 8 diff --git a/apps/catalog/app/components/part-viewer.tsx b/apps/catalog/app/components/part-viewer.tsx index 11ace47..c133c5a 100644 --- a/apps/catalog/app/components/part-viewer.tsx +++ b/apps/catalog/app/components/part-viewer.tsx @@ -148,6 +148,21 @@ export interface PartViewerProps { * corner rather than a panel beside it, so the part gets the whole width. */ readonly overlay?: ReactNode + /** + * A bar drawn along the bottom of the viewer, above the panel below it. + * + * **The list's chrome floats over the part** (Paul, 2026-09-11: "they should + * float in the 3d viewer above the table"). It is a slot rather than + * something the page lays over the top, because the viewer already owns this + * edge — the shelf of view controls stands on it, and the two have to be + * stacked by whoever knows about both. + * + * It starts where the questions end, for the reason the feature record does: + * the column over the left of the canvas takes the pointer for its whole + * height, so a bar running under it is a bar whose left end cannot be + * pressed. + */ + readonly bottomChrome?: ReactNode /** * Whether the overlay may grow past the bottom of the viewer. * @@ -187,6 +202,7 @@ export const PartViewer = ({ details, modal, overlay, + bottomChrome, overlaySpills = false, tooled = [], onCloseDetails, @@ -230,6 +246,32 @@ export const PartViewer = ({ return () => observer.disconnect() }, [overlay]) + /** + * How much of the bottom edge the shelf and the bar under it have taken. + * + * **The questions stop above it** (Paul, 2026-09-11: "it should be under the + * top left hand panel, not to the right of it"). The bar runs the full width + * of the viewer, and the column over the left of the canvas is full height + * and takes the pointer for all of it — so without this the bar's left end + * would be drawn under the list and could not be pressed at all. Measured + * rather than reserved: the bar wraps to two lines on a narrow window, and a + * guessed height is a gap on one screen and an overlap on the next. + */ + const bottom = useRef(null) + const [bottomRoom, setBottomRoom] = useState(0) + + useEffect(() => { + const box = bottom.current + if (box === null || typeof ResizeObserver === 'undefined') { + return + } + const measure = () => setBottomRoom(box.getBoundingClientRect().height) + measure() + const observer = new ResizeObserver(measure) + observer.observe(box) + return () => observer.disconnect() + }, [bottomChrome]) + const [showAids, setShowAids] = useState(false) // Off by default (Paul, 2026-08-30): the stack in the scene is a check, not the view. const [sectioning, setSectioning] = useState(false) @@ -329,51 +371,72 @@ export const PartViewer = ({ * stay transparent to the part underneath. */ className="pointer-events-none absolute top-3 bottom-3 left-3 z-40 flex gap-2" + /* Above the bar along the bottom, measured — see `bottomRoom`. */ + style={bottomRoom === 0 ? undefined : { bottom: bottomRoom + 20 }} > {overlay}
    ) : null} - {/* Along the bottom, centred: the corners belong to the view cube and to - what the part is waiting for, and a shelf in the middle of the bottom - edge is out of the way of the geometry above it. */} -
    - + setZoomTo(zoomTo === 'cursor' ? 'centre' : 'cursor')} > - setZoomTo(zoomTo === 'cursor' ? 'centre' : 'cursor')} - > - - - setShowAids(!showAids)} - > - - - {/* + + + setShowAids(!showAids)} + > + + + {/* **No wrench for now** (Paul, 2026-09-01: "remove the wrench icon in the viewer for now"). It put the drawn stack at the clicked feature; the drawing in the panel is where a stack is read today. The button is what has gone — `assembly` still draws one when a page asks. */} - { - setSectioning(!sectioning) - setPlane(null) - }} - > - - - + { + setSectioning(!sectioning) + setPlane(null) + }} + > + + + + + {/* Along the bottom, whatever chrome the page floats here. + + **The sheet takes no click**, and each thing on it says for itself + that it does — the rule the overlay column follows, and the defect + `tests/on-the-part.spec.ts` § "at a laptop width" exists for: an + invisible full-width box carrying the pointer is a curtain over the + part. */} +
    + {bottomChrome}
    {details ? ( diff --git a/apps/catalog/app/routes/part.tsx b/apps/catalog/app/routes/part.tsx index cedc3d3..bfcebb9 100644 --- a/apps/catalog/app/routes/part.tsx +++ b/apps/catalog/app/routes/part.tsx @@ -6,6 +6,7 @@ import { useReducer, useRef, useState, + type CSSProperties, type ReactNode, } from 'react' import { Link, useNavigate, useParams, useSearchParams } from 'react-router' @@ -5254,6 +5255,595 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: : 'Every collet in the crib' : listTitle + /** + * How tall the bar over the bottom of the part is. + * + * **The handle that resizes the table stays above it** (Paul, 2026-09-11: + * "the control point to resize that panel should still be at the top above + * the buttons"). The bar is drawn inside the viewer, so the seam between the + * two panels is now *under* it — and a drag handle under the thing it looks + * like it should be over is a handle nobody finds. It is lifted by what the + * bar measures, which is a transform rather than a margin: the seam is still + * where the layout says it is, and only the grab moves. + */ + const chromeBar = useRef(null) + const [chromeRoom, setChromeRoom] = useState(0) + + useEffect(() => { + const box = chromeBar.current + if (box === null || typeof ResizeObserver === 'undefined') { + return + } + const measure = () => setChromeRoom(box.getBoundingClientRect().height) + measure() + const observer = new ResizeObserver(measure) + observer.observe(box) + return () => observer.disconnect() + }, []) + + /** + * The bar over the bottom of the part: which list is on screen, what it is, + * what it is not showing, and the presses that narrow it. + * + * **It floats in the viewer above the table** (Paul, 2026-09-11: "they + * should float in the 3d viewer above the table … the text shown in + * different situations can also just overlay the viewer with a transparent + * background"). It was the table card's header bar, which is a strip of + * chrome the list pays for in rows: the same controls over the part cost + * the table nothing and stand where the part is being read. + * + * Held here rather than inline so the viewer is handed one node — it is + * five hundred lines of chrome, and threading it through the props of the + * component it is drawn over is where a JSX tree stops being readable. + */ + /* + A `div`, not a `p`: this is the list's header bar, and it holds two tab + buttons and the column picker. A `p` may hold phrasing content only, so the + picker's own `div` inside it was invalid nesting — which the browser + corrects by closing the paragraph early, and which React reports as a + hydration error because the tree it built is not the tree that came back + (2026-09-02). + */ + const listChrome = ( +
    + {/* + **One row, always** (Paul, 2026-09-11: "all information we show there + should be viewable in one row"). Wrapping, it grew a second line over + the part the moment two notes were true at once — and the bar is + measured to keep the questions above it, so a bar that grows is a + column that shortens under the mouse. Each note takes the ellipsis + instead; every one of them carries its full text as a hover. + */} +
    + {/* + **Which of the three lists this is, as three buttons** + (Paul, 2026-09-07: "I want the table tabs for tools, + holders, and collets back, just as buttons like the + filters button. The one that is active should be + highlighted"). They were a full-width tab row for an + afternoon and then nothing at all; what was wanted is the + switch the *table* needs — small, in its chrome, beside + the Filters button they are dressed as — and an indicator + of which list is on screen. + + They are the tree's slots, not a control beside it: + pressing one opens that slot on the open stack, so the + buttons and the tree cannot disagree about what the rows + below are for. Drawn only while there is a stack, because + with no feature there is no assembly for a holder to be + offered against and the table is the catalog. + */} + {!perFeature + ? SLOTS.map((slot) => { + const open = listKind === slot + return ( + + ) + }) + : null} + {/* + **The words wrap; the presses do not** (Paul, 2026-09-11: "use two + rows for the text if you need to — just try to fit it into the height + 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. + */} +
    + {/* + The heading of whichever list is on screen. A threaded + hole used to put two tabs here — taps, then drills — + and the pair of stacks in the tree is that pair of tabs + now, so there is one title and the stack says which + list it is over. + */} + {tableTitle} + {/* + **How the thread is made lives over the lists it + decides** (Paul, 2026-09-07: "we should no longer show + the 'cut tap' and 'form tap' rows in the feature dialog + when applying threads to a hole — it should just return + the right tap drills"). Saying what thread the hole is + for and saying whether it will be cut or rolled are two + decisions, and only the second one is about these lists: + the two predrills are half a millimetre apart on an M6, + and the drills are judged against whichever is chosen. + So it sits beside the list rather than on the dialog + somebody opens to name the thread. + + **Over the taps as well as the drills** (Paul, + 2026-09-09). It decides both — which taps the list holds + and which hole the drills are measured against — and one + control written to one mode is what keeps the pair of + stacks agreeing about the same thread. Not over a holder + or collet list, which it decides nothing about. + */} + {threadSpec === null || + perFeature || + componentSlot !== null || + holeDiameter === null ? null : ( + + writeThread({ mode, spec: threadSpec }, threadScope, holeDiameter) + } + holeDiameter={holeDiameter} + deviation={drillDeviation} + unit={unit} + /> + )} + {/* Nothing to count where nothing has been asked of this + panel: a number beside "nothing selected" reads as a + count of tools that are not there. */} + {/* One number beside the heading counted tools whichever + list was on screen; each button counts its own now. */} + {/* + **What the list is not showing is said where the list + is** — the rule this page follows for a rack the drawable + filter thinned and for the rules' own removals. The + button beside it counts what matched; this says how much + of that is on screen. + */} + {rowsHidden > 0 && componentSlot === null ? ( + + showing the first {String(tableRows.length)} — narrow the list to reach the other{' '} + {String(rowsHidden)} + + ) : null} + {tablePending ? ( + + + ) : null} + {tableError !== null && detailed !== null ? ( + + {tableError} + + ) : null} + {/* + **The notes are about the list on show** (Paul, + 2026-09-02). What the rules took off the drill list is + true of the drills and says nothing about the taps, and + it was printed over both. The tap tab says what its own + list was matched on, and what is wrong with it. + */} + {/* None of them are about a panel that is waiting to be + asked: they describe a list that is not on screen. */} + {/* + **What the drawable rule hid** (Paul, 2026-09-07). The + table is only the holders with a shape, so the ones that + fit and have no model have to be counted somewhere or an + empty list reads as "nothing in the rack fits this". + */} + {componentSlot === 'holder' ? ( + undrawableHolders > 0 ? ( + + {undrawableHolders} more fit but have no model to draw + + ) : null + ) : perFeature ? null : tapping && threadSpec !== null ? ( + <> + {/* + **Each list says what it was swept on, and they are + two different numbers** (Paul, 2026-09-09: "in + drills, the highlighted message should show the tap + or form drill size (the predrill size) it is looking + for"). The taps were matched on the thread's nominal + size; the drills on the predrill the chosen tap + starts from — ⌀0.089 in against ⌀0.0995 in on a + #4-40. Printing the tap's number over the drills + named a diameter no row in that list is near, on a + list whose whole sweep is the other one. + */} + + {holeChoice.mode === 'thread mill' + ? `inside the ⌀${formatLength(minorOf(threadSpec), unit)} minor diameter` + : tappingNow + ? threadNote(threadSpec, unit) + : shortOfDrills + ? millStandInNote(threadSpec, holeChoice.mode, unit, millsListed) + : predrillNote(threadSpec, holeChoice.mode, unit)} + + {/* Both are about the tap list: how far the taps reach + and whether the crib holds one. */} + {makers.short && tappingNow ? ( + + none reach the bottom — the closest are shown + + ) : null} + {makers.unheld && tappingNow ? ( + + nothing in the crib holds one at the stickout this needs + + ) : null} + + ) : ( + <> + {/* + **A corner no mill can leave** (Paul, 2026-09-01): the + model draws it sharp, and every cutter leaves its own + radius. Said once, plainly, rather than left for somebody + to work out from a list of tools that all miss it. + */} + {reading !== null && hasSharpCorner(reading) ? ( + + this feature has a sharp corner, and no milling tool can cut the geometry + + ) : null} + {closest.length > 0 ? ( + + nothing in the crib fits — the closest are shown, with what stops each + + ) : null} + {(detailed?.excludedCount ?? 0) > 0 && reading !== null ? ( + + {detailed?.excludedCount ?? 0} removed by the rules + {tightest ? ` — most by ${tightest}` : ''} + + ) : null} + {fitting.length > narrowed.length ? ( + + {fitting.length - narrowed.length} that fit are hidden by the filters + + ) : null} + {/* + **A live override is said where the list is, and + changed where it was made** (Paul, 2026-09-08: "the + override the rules button should be in the filter + dialog rather than always shown"). This is the note, + not the control: the tick that confirms one is in + the column's own dialog, because that is the rule it + overrules. + */} + {overriding.length > 0 && overrideTools.length > 0 ? ( + + {overrideTools.length} the {overridden} rules turn down are listed + {/* + **No silent caps.** The list cannot draw more + rows than this whether they are overridden or + not, and a truncated answer that reads as the + whole one is what sent somebody looking for + half-inch cutters that were never on it. + */} + {(detailed?.overridableCount ?? 0) > overrideTools.length + ? ` of ${String(detailed?.overridableCount ?? 0)} — narrow the filters to reach the rest` + : ''} + + ) : null} + {unheld > 0 ? ( + + {unheld} with no holder that clears + + ) : null} + + )} +
    + {/* + **The picker edits the list that is open** (Paul, + 2026-09-02: "allow me to use those columns if I edit the + tap table"). A tap offers the seven numbers it states and + the tool list offers its own, so which set is on offer — + and which hidden set a tick lands in — follows the tab. + */} +
    + setNoCollet((current) => !current)} + /> + ) : undefined + } + onClear={() => { + if (componentSlot === 'holder') { + setHolderQuery(NO_QUERY) + return + } + if (componentSlot === 'collet') { + setColletQuery(NO_QUERY) + return + } + if (tappingNow) { + clearTapFilters() + return + } + setNumberSearch('') + apply(EMPTY_QUERY) + }} + /* + **What is narrowing the list, named where it can be + cleared** (Paul, 2026-09-08, and 2026-09-09: "in Tap, it + shows 'Clear 4 filters' but I only see tool type. What + are the 4 filters active? It needs to be visible."). + Most of the filters are column headers now, and a header + on a column somebody has since hidden is a filter with + nothing on screen pointing at it — so the count includes + them and the press names every one of them. + + **Per list, because a filter is only a filter over the + rows it reaches.** The tap list is swept out of the + catalog by the thread, so the drill half of the form + filter and every range the rules put on a drill narrow + nothing on it: counting them there named three filters + that table does not have and cannot show. It counts the + two `askOfTapColumn` asks. + */ + set={ + componentSlot === 'holder' + ? narrowingNames(holderQuery, HOLDER_COLUMNS) + : componentSlot === 'collet' + ? narrowingNames(colletQuery, COLLET_COLUMNS) + : tappingNow + ? narrowingNames( + { + text: numberSearch, + terms: { type: shownTapTypes }, + /* + **The bounds the part set count too** + (Paul, 2026-09-09: "button should show to + clear 3 filters not 1 in this situation"). + They narrow the list and they are drawn on + it, so leaving them out made the figure + disagree with the funnels a second time — + the same defect from the other end. Grey + rather than lit is what says a number is + not yours to type; it was never a reason + to stop counting it. + */ + bounds: tapRanges, + }, + TAP_COLUMNS, + ) + : narrowingNames( + { + text: numberSearch || query.text, + terms: query.terms, + bounds: query.ranges, + }, + TOOL_COLUMNS, + ) + } + filters={ + /* + **A holder list has no filter buttons at all.** Every + question about a holder or a collet is a question + about one of its columns, so all of them are asked in + the headings; the tool list keeps the few no column + shows. + */ + componentSlot !== null ? undefined : ( + + ) + } + actions={ + /* + **The picker edits the list that is open**, and under + the tree a list can be holders or collets as well as + tools. The three sets are kept apart for the reason the + tap set is: a code hidden in one means nothing in + another, and a nose diameter is not a column a tap has. + */ + componentSlot !== null ? ( + column.code, + ), + componentSlot === 'holder' ? holderColumnOrder : colletColumnOrder, + ).flatMap((code) => + (componentSlot === 'holder' ? HOLDER_COLUMNS : COLLET_COLUMNS) + .filter((column) => column.code === code) + .map((column) => ({ code: column.code, label: column.label })), + )} + shown={(componentSlot === 'holder' ? HOLDER_COLUMNS : COLLET_COLUMNS) + .filter( + (column) => + !( + componentSlot === 'holder' ? hiddenHolderColumns : hiddenColletColumns + ).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} + /> + ) : ( + column.code), + tappingNow ? tapColumnOrder : columnOrder, + ).flatMap((code) => + (tappingNow ? TAP_COLUMNS : TOOL_COLUMNS) + .filter((column) => column.code === code) + .map((column) => ({ code: column.code, label: column.label })), + )} + shown={(tappingNow ? TAP_COLUMNS : TOOL_COLUMNS) + .filter( + (column) => + !(tappingNow ? hiddenTapColumns : hiddenColumns).includes(column.code), + ) + .map((column) => 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} + /> + ) + } + /> +
    + ) + return (
    + {/* **Eight tools tall to begin with** (Paul, 2026-09-10: "the default @@ -6098,537 +6715,6 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: {/* The panel measures itself here: `Card` takes no ref. */}
    - {/* - A `div`, not a `p`: this is the list's header bar, and it - holds two tab buttons and the column picker. A `p` may hold - phrasing content only, so the picker's own `div` inside it - was invalid nesting — which the browser corrects by closing - the paragraph early, and which React reports as a hydration - error because the tree it built is not the tree that came - back (2026-09-02). - */} -
    -
    - {/* - **Which of the three lists this is, as three buttons** - (Paul, 2026-09-07: "I want the table tabs for tools, - holders, and collets back, just as buttons like the - filters button. The one that is active should be - highlighted"). They were a full-width tab row for an - afternoon and then nothing at all; what was wanted is the - switch the *table* needs — small, in its chrome, beside - the Filters button they are dressed as — and an indicator - of which list is on screen. - - They are the tree's slots, not a control beside it: - pressing one opens that slot on the open stack, so the - buttons and the tree cannot disagree about what the rows - below are for. Drawn only while there is a stack, because - with no feature there is no assembly for a holder to be - offered against and the table is the catalog. - */} - {!perFeature - ? SLOTS.map((slot) => { - const open = listKind === slot - return ( - - ) - }) - : null} - {/* - The heading of whichever list is on screen. A threaded - hole used to put two tabs here — taps, then drills — - and the pair of stacks in the tree is that pair of tabs - now, so there is one title and the stack says which - list it is over. - */} - {tableTitle} - {/* - **How the thread is made lives over the lists it - decides** (Paul, 2026-09-07: "we should no longer show - the 'cut tap' and 'form tap' rows in the feature dialog - when applying threads to a hole — it should just return - the right tap drills"). Saying what thread the hole is - for and saying whether it will be cut or rolled are two - decisions, and only the second one is about these lists: - the two predrills are half a millimetre apart on an M6, - and the drills are judged against whichever is chosen. - So it sits beside the list rather than on the dialog - somebody opens to name the thread. - - **Over the taps as well as the drills** (Paul, - 2026-09-09). It decides both — which taps the list holds - and which hole the drills are measured against — and one - control written to one mode is what keeps the pair of - stacks agreeing about the same thread. Not over a holder - or collet list, which it decides nothing about. - */} - {threadSpec === null || - perFeature || - componentSlot !== null || - holeDiameter === null ? null : ( - - writeThread({ mode, spec: threadSpec }, threadScope, holeDiameter) - } - holeDiameter={holeDiameter} - deviation={drillDeviation} - unit={unit} - /> - )} - {/* Nothing to count where nothing has been asked of this - panel: a number beside "nothing selected" reads as a - count of tools that are not there. */} - {/* One number beside the heading counted tools whichever - list was on screen; each button counts its own now. */} - {/* - **What the list is not showing is said where the list - is** — the rule this page follows for a rack the drawable - filter thinned and for the rules' own removals. The - button beside it counts what matched; this says how much - of that is on screen. - */} - {rowsHidden > 0 && componentSlot === null ? ( - - showing the first {String(tableRows.length)} — narrow the list to reach - the other {String(rowsHidden)} - - ) : null} - {tablePending ? ( - - - ) : null} - {tableError !== null && detailed !== null ? ( - - {tableError} - - ) : null} - {/* - **The notes are about the list on show** (Paul, - 2026-09-02). What the rules took off the drill list is - true of the drills and says nothing about the taps, and - it was printed over both. The tap tab says what its own - list was matched on, and what is wrong with it. - */} - {/* None of them are about a panel that is waiting to be - asked: they describe a list that is not on screen. */} - {/* - **What the drawable rule hid** (Paul, 2026-09-07). The - table is only the holders with a shape, so the ones that - fit and have no model have to be counted somewhere or an - empty list reads as "nothing in the rack fits this". - */} - {componentSlot === 'holder' ? ( - undrawableHolders > 0 ? ( - - {undrawableHolders} more fit but have no model to draw - - ) : null - ) : perFeature ? null : tapping && threadSpec !== null ? ( - <> - {/* - **Each list says what it was swept on, and they are - two different numbers** (Paul, 2026-09-09: "in - drills, the highlighted message should show the tap - or form drill size (the predrill size) it is looking - for"). The taps were matched on the thread's nominal - size; the drills on the predrill the chosen tap - starts from — ⌀0.089 in against ⌀0.0995 in on a - #4-40. Printing the tap's number over the drills - named a diameter no row in that list is near, on a - list whose whole sweep is the other one. - */} - - {holeChoice.mode === 'thread mill' - ? `inside the ⌀${formatLength(minorOf(threadSpec), unit)} minor diameter` - : tappingNow - ? threadNote(threadSpec, unit) - : shortOfDrills - ? millStandInNote(threadSpec, holeChoice.mode, unit, millsListed) - : predrillNote(threadSpec, holeChoice.mode, unit)} - - {/* Both are about the tap list: how far the taps reach - and whether the crib holds one. */} - {makers.short && tappingNow ? ( - - none reach the bottom — the closest are shown - - ) : null} - {makers.unheld && tappingNow ? ( - - nothing in the crib holds one at the stickout this needs - - ) : null} - - ) : ( - <> - {reading === null ? ( - - click a feature on the part for the ones that cut it - - ) : null} - {/* - **A corner no mill can leave** (Paul, 2026-09-01): the - model draws it sharp, and every cutter leaves its own - radius. Said once, plainly, rather than left for somebody - to work out from a list of tools that all miss it. - */} - {reading !== null && hasSharpCorner(reading) ? ( - - this feature has a sharp corner, and no milling tool can cut the - geometry - - ) : null} - {closest.length > 0 ? ( - - nothing in the crib fits — the closest are shown, with what stops each - - ) : null} - {(detailed?.excludedCount ?? 0) > 0 && reading !== null ? ( - - {detailed?.excludedCount ?? 0} removed by the rules - {tightest ? ` — most by ${tightest}` : ''} - - ) : null} - {fitting.length > narrowed.length ? ( - - {fitting.length - narrowed.length} that fit are hidden by the filters - - ) : null} - {/* - **A live override is said where the list is, and - changed where it was made** (Paul, 2026-09-08: "the - override the rules button should be in the filter - dialog rather than always shown"). This is the note, - not the control: the tick that confirms one is in - the column's own dialog, because that is the rule it - overrules. - */} - {overriding.length > 0 && overrideTools.length > 0 ? ( - - {overrideTools.length} the {overridden} rules turn down are listed - {/* - **No silent caps.** The list cannot draw more - rows than this whether they are overridden or - not, and a truncated answer that reads as the - whole one is what sent somebody looking for - half-inch cutters that were never on it. - */} - {(detailed?.overridableCount ?? 0) > overrideTools.length - ? ` of ${String(detailed?.overridableCount ?? 0)} — narrow the filters to reach the rest` - : ''} - - ) : null} - {unheld > 0 ? ( - - {unheld} with no holder that clears - - ) : null} - - )} - {/* - **The picker edits the list that is open** (Paul, - 2026-09-02: "allow me to use those columns if I edit the - tap table"). A tap offers the seven numbers it states and - the tool list offers its own, so which set is on offer — - and which hidden set a tick lands in — follows the tab. - */} -
    - setNoCollet((current) => !current)} - /> - ) : undefined - } - onClear={() => { - if (componentSlot === 'holder') { - setHolderQuery(NO_QUERY) - return - } - if (componentSlot === 'collet') { - setColletQuery(NO_QUERY) - return - } - if (tappingNow) { - clearTapFilters() - return - } - setNumberSearch('') - apply(EMPTY_QUERY) - }} - /* - **What is narrowing the list, named where it can be - cleared** (Paul, 2026-09-08, and 2026-09-09: "in Tap, it - shows 'Clear 4 filters' but I only see tool type. What - are the 4 filters active? It needs to be visible."). - Most of the filters are column headers now, and a header - on a column somebody has since hidden is a filter with - nothing on screen pointing at it — so the count includes - them and the press names every one of them. - - **Per list, because a filter is only a filter over the - rows it reaches.** The tap list is swept out of the - catalog by the thread, so the drill half of the form - filter and every range the rules put on a drill narrow - nothing on it: counting them there named three filters - that table does not have and cannot show. It counts the - two `askOfTapColumn` asks. - */ - set={ - componentSlot === 'holder' - ? narrowingNames(holderQuery, HOLDER_COLUMNS) - : componentSlot === 'collet' - ? narrowingNames(colletQuery, COLLET_COLUMNS) - : tappingNow - ? narrowingNames( - { - text: numberSearch, - terms: { type: shownTapTypes }, - /* - **The bounds the part set count too** - (Paul, 2026-09-09: "button should show to - clear 3 filters not 1 in this situation"). - They narrow the list and they are drawn on - it, so leaving them out made the figure - disagree with the funnels a second time — - the same defect from the other end. Grey - rather than lit is what says a number is - not yours to type; it was never a reason - to stop counting it. - */ - bounds: tapRanges, - }, - TAP_COLUMNS, - ) - : narrowingNames( - { - text: numberSearch || query.text, - terms: query.terms, - bounds: query.ranges, - }, - TOOL_COLUMNS, - ) - } - filters={ - /* - **A holder list has no filter buttons at all.** Every - question about a holder or a collet is a question - about one of its columns, so all of them are asked in - the headings; the tool list keeps the few no column - shows. - */ - componentSlot !== null ? undefined : ( - - ) - } - actions={ - /* - **The picker edits the list that is open**, and under - the tree a list can be holders or collets as well as - tools. The three sets are kept apart for the reason the - tap set is: a code hidden in one means nothing in - another, and a nose diameter is not a column a tap has. - */ - componentSlot !== null ? ( - column.code, - ), - componentSlot === 'holder' ? holderColumnOrder : colletColumnOrder, - ).flatMap((code) => - (componentSlot === 'holder' ? HOLDER_COLUMNS : COLLET_COLUMNS) - .filter((column) => column.code === code) - .map((column) => ({ code: column.code, label: column.label })), - )} - shown={(componentSlot === 'holder' ? HOLDER_COLUMNS : COLLET_COLUMNS) - .filter( - (column) => - !( - componentSlot === 'holder' - ? hiddenHolderColumns - : hiddenColletColumns - ).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 - } - /> - ) : ( - column.code, - ), - tappingNow ? tapColumnOrder : columnOrder, - ).flatMap((code) => - (tappingNow ? TAP_COLUMNS : TOOL_COLUMNS) - .filter((column) => column.code === code) - .map((column) => ({ code: column.code, label: column.label })), - )} - shown={(tappingNow ? TAP_COLUMNS : TOOL_COLUMNS) - .filter( - (column) => - !(tappingNow ? hiddenTapColumns : hiddenColumns).includes( - column.code, - ), - ) - .map((column) => 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} - /> - ) - } - /> -
    { +/** + * **The bar floats over the bottom of the part** (Paul, 2026-09-11: "they + * should float in the 3d viewer above the table"). It was the table card's + * header, and this checked it was under the viewer; the same controls now stand + * over the last strip of the canvas, above the rows they narrow. What has not + * changed is which of them is there: the questions no column asks are on the + * bar, and everything a column can ask is asked on that column. + */ +test('filters open from the bar floating over the bottom of the part', async ({ page }) => { const viewer = page.locator('canvas') const toolbar = page.locator('[data-part-tool-table-toolbar]') + const rows = page.locator('[data-part-tool-table]').first() await expect(toolbar).toBeVisible() await expect(async () => { const viewerBox = await viewer.boundingBox() const toolbarBox = await toolbar.boundingBox() + const rowsBox = await rows.boundingBox() expect(viewerBox).not.toBeNull() expect(toolbarBox).not.toBeNull() - expect(toolbarBox!.y).toBeGreaterThan(viewerBox!.y + viewerBox!.height) + expect(rowsBox).not.toBeNull() + // Over the canvas, in its bottom half, and above the rows. + expect(toolbarBox!.y).toBeGreaterThan(viewerBox!.y + viewerBox!.height / 2) + expect(toolbarBox!.y + toolbarBox!.height).toBeLessThanOrEqual(viewerBox!.y + viewerBox!.height) + expect(toolbarBox!.y + toolbarBox!.height).toBeLessThanOrEqual(rowsBox!.y) }).toPass() const tableScroll = page.locator('[data-part-tool-table] .hide-scrollbar').first() From abb68b57d436d2b759b6613b6e4b0535245cebc2 Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Fri, 11 Sep 2026 12:20:48 -0400 Subject: [PATCH 11/12] branded, text cleanup, keyboard list nav focus fix, feature/group dialog cleanup --- AGENTS.md | 16 +- apps/catalog/app/components/app-header.tsx | 5 +- .../components/assembly-tree-panel.test.tsx | 70 +++- .../app/components/assembly-tree-panel.tsx | 191 ++++++---- .../app/components/column-filter.test.tsx | 192 ++++++---- apps/catalog/app/components/column-filter.tsx | 357 ++++++++++-------- .../app/components/column-heading.test.tsx | 2 +- .../catalog/app/components/column-heading.tsx | 3 + .../app/components/component-table.test.tsx | 23 +- .../app/components/component-table.tsx | 15 +- .../app/components/component-tally.tsx | 21 +- .../app/components/feature-details.tsx | 17 +- .../app/components/feature-list-panel.tsx | 2 +- apps/catalog/app/components/filter-panel.tsx | 9 +- apps/catalog/app/components/filter-rail.tsx | 3 +- .../app/components/fusion-export-dialog.tsx | 13 +- .../app/components/group-editor.test.tsx | 63 ++++ apps/catalog/app/components/group-editor.tsx | 106 ++++-- apps/catalog/app/components/order-dialog.tsx | 17 +- .../app/components/part-tool-table.tsx | 39 +- .../app/components/part-upload-overlay.tsx | 3 +- .../app/components/part-viewer.test.tsx | 32 ++ apps/catalog/app/components/part-viewer.tsx | 21 +- .../app/components/predrill-choice.tsx | 3 +- .../app/components/selection-panel.test.tsx | 50 +++ .../app/components/selection-panel.tsx | 45 ++- apps/catalog/app/components/thread-picker.tsx | 14 +- apps/catalog/app/components/tool-details.tsx | 2 +- apps/catalog/app/components/toolpath-logo.tsx | 37 ++ apps/catalog/app/root.tsx | 5 +- apps/catalog/app/routes/order-list.tsx | 89 +++-- apps/catalog/app/routes/part.tsx | 301 +++++++++++---- apps/catalog/app/shared/arrow-target.test.ts | 112 ++++++ apps/catalog/app/shared/arrow-target.ts | 100 +++++ .../app/shared/assembly-actions.test.ts | 6 +- apps/catalog/app/shared/assembly-actions.ts | 32 +- apps/catalog/app/shared/assembly-tree.ts | 5 +- apps/catalog/app/shared/filter.test.ts | 34 ++ apps/catalog/app/shared/filter.ts | 33 ++ apps/catalog/app/shared/order-list.test.ts | 76 +++- apps/catalog/app/shared/order-list.ts | 57 ++- .../app/shared/part-interaction.test.ts | 9 - apps/catalog/app/shared/part-interaction.ts | 19 +- apps/catalog/app/shared/range-entry.test.ts | 127 +++++++ apps/catalog/app/shared/range-entry.ts | 199 ++++++++++ apps/catalog/app/shared/setup-sheet.test.ts | 72 ++++ apps/catalog/app/shared/setup-sheet.ts | 96 ++++- apps/catalog/app/shared/type.ts | 94 +++++ apps/catalog/app/type-scale.test.ts | 161 +++++++- apps/catalog/tests/on-the-part.spec.ts | 331 +++++++++++++--- apps/catalog/tests/threaded-hole.spec.ts | 59 +++ apps/catalog/tsconfig.tsbuildinfo | 1 + docs/FEATURE-LIST.md | 62 +++ docs/TOOL-ASSEMBLY-TREE.md | 104 +++-- docs/TOOL-CATALOG-PLAN.md | 28 +- 55 files changed, 2867 insertions(+), 716 deletions(-) create mode 100644 apps/catalog/app/components/toolpath-logo.tsx create mode 100644 apps/catalog/app/shared/arrow-target.test.ts create mode 100644 apps/catalog/app/shared/arrow-target.ts create mode 100644 apps/catalog/app/shared/range-entry.test.ts create mode 100644 apps/catalog/app/shared/range-entry.ts create mode 100644 apps/catalog/app/shared/type.ts create mode 100644 apps/catalog/tsconfig.tsbuildinfo diff --git a/AGENTS.md b/AGENTS.md index 7bd14ba..3fef690 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,11 +192,14 @@ application unless that application says otherwise. chosen first and narrows the rest. The table is whichever list the open slot asks for — three buttons in its chrome switch it, each counting its own list, and with no feature they read the three catalogs — and picking a row - there fills that slot without walking on to the next one. **Each assembly carries one context-aware button** — _Add to order list_, _Change holder from - A to B_, _Remove from order list_ — covering every stack of it, so a threaded - hole's tap and the drill under it are one press — and that press is the only way a component + there fills that slot without walking on to the next one. **One context-aware + button sits under the whole box** (Paul, 2026-09-11) — _Add to order list_, + _Change holder from A to B_, _Remove from order list_ — covering every stack + in it, so a threaded hole's tap and the drill under it, and a pocket's rougher + and finisher, are one press — and that press is the only way a component reaches the bill: picking a row in a table selects it into the stack and - nothing more. `docs/TOOL-ASSEMBLY-TREE.md` is the spec, including + nothing more. _Add assembly_ is a row of the tree above that press, on the + last card only. `docs/TOOL-ASSEMBLY-TREE.md` is the spec, including _Where the rules live_ and _Not built_, and § 6 for what the flag's removal took with it. `openCube` in the Playwright fixture takes no flags — there is one shape, so a spec says nothing and gets it. @@ -265,12 +268,14 @@ application unless that application says otherwise. | ---------------------------------------------------- | ---------------------------------------------------- | | 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` | @@ -283,11 +288,14 @@ application unless that application says otherwise. | 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` | diff --git a/apps/catalog/app/components/app-header.tsx b/apps/catalog/app/components/app-header.tsx index 34f865e..b66b579 100644 --- a/apps/catalog/app/components/app-header.tsx +++ b/apps/catalog/app/components/app-header.tsx @@ -1,10 +1,12 @@ import { NavLink, useNavigate, useParams, useSearchParams } from 'react-router' import { Badge, IconButton, cn } from '@toolpath/ui' import { Chip, ChipGroup } from 'components/chip' +import { ToolpathLogo } from 'components/toolpath-logo' import { UNIT_ABBREVIATION, UNIT_SYSTEMS, type UnitSystem } from '@toolpath/tool-support' import { MoonIcon, SunIcon, UploadSimpleIcon } from '@phosphor-icons/react' import { forgetPart, openPart, orderListHref, partHref, usePartSession } from 'shared/part-session' import { useTheme } from 'shared/use-theme' +import { HEADING } from 'shared/type' const tabClass = ({ isActive }: { isActive: boolean }) => cn( @@ -37,7 +39,8 @@ export const AppHeader = ({ unit, onUnit, toolCount, onUploadPart }: AppHeaderPr return (
    -

    Tool catalog

    + +

    Toolpath Tool 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/assembly-tree-panel.test.tsx b/apps/catalog/app/components/assembly-tree-panel.test.tsx index 6ef3a73..720adcf 100644 --- a/apps/catalog/app/components/assembly-tree-panel.test.tsx +++ b/apps/catalog/app/components/assembly-tree-panel.test.tsx @@ -24,7 +24,6 @@ const panel = ( actionsFor={() => []} onAdd={() => undefined} onRemove={() => undefined} - title="Cuts the pocket" {...over} /> ) @@ -366,3 +365,72 @@ describe('a component standing in two stacks of one tree', () => { expect(screen.queryByText(/used in Assembly/)).not.toBeInTheDocument() }) }) + +/** + * **One press for everything in the box** (Paul, 2026-09-11: "the button should + * be below BOTH of them, and add both assemblies to the order list — we only + * need one button and it just adds everything on the list"). + * + * It used to be drawn inside every card, so a rougher and a finisher were two + * presses and ordering the pair meant pressing both — while Enter on the same + * box had written all of them since 2026-09-10. The two disagreed about what + * finishing the box means, and the button was the one that was wrong. + */ +describe('the press under the whole box', () => { + const two = addAssembly(defaultAssemblies(false)) + + it('is asked once, about every stack in the tree', () => { + const asked: Array = [] + draw(two, { + actionsFor: (stacks) => { + asked.push(stacks.length) + return [] + }, + }) + + // Once, over both stacks — not once per card. + expect(asked).toEqual([2]) + }) + + it('draws one button for two assemblies, not one each', () => { + draw(two, { + actionsFor: () => [ + { key: 'add', kind: 'add' as const, label: 'Add to order list', onClick: () => undefined }, + ], + }) + + expect(screen.getAllByRole('button', { name: 'Add to order list' })).toHaveLength(1) + }) + + /** + * **And the press for another assembly is a row above it** (Paul, 2026-09-11: + * "add assembly should be above the add feature to list or add to order list + * buttons — like another row with a plus button in the list of components"). + * It is drawn on the last card only: it makes a card rather than a row in + * this one, so one per card would be the same press drawn as many times as + * there are stacks. + */ + it('offers another assembly once, under the last stack and over the press', () => { + draw(two, { + actionsFor: () => [ + { key: 'add', kind: 'add' as const, label: 'Add to order list', onClick: () => undefined }, + ], + }) + + const add = screen.getAllByRole('button', { name: 'Add assembly' }) + expect(add).toHaveLength(1) + + /* + Asserted as document order rather than as a parent: what the rule is about + is where somebody's eye lands — after the last stack's rows, before the + press that finishes the box — and the boxes around it are layout. + */ + const order = (element: Element): number => + Array.from(document.querySelectorAll('button')).indexOf(element as HTMLButtonElement) + const tools = screen.getAllByRole('button', { name: /^TOOL for/ }) + const press = screen.getByRole('button', { name: 'Add to order list' }) + + expect(order(add[0] as Element)).toBeGreaterThan(order(tools[1] as Element)) + expect(order(add[0] as Element)).toBeLessThan(order(press)) + }) +}) diff --git a/apps/catalog/app/components/assembly-tree-panel.tsx b/apps/catalog/app/components/assembly-tree-panel.tsx index 33ec86b..65acfc6 100644 --- a/apps/catalog/app/components/assembly-tree-panel.tsx +++ b/apps/catalog/app/components/assembly-tree-panel.tsx @@ -19,6 +19,7 @@ import { type TreeNode, } from 'shared/assembly-tree' import { NameField } from './name-field' +import { SECTION_LABEL } from 'shared/type' /** * The stacks a feature is answered with, as a tree beside the tool table. @@ -122,17 +123,6 @@ export interface AssemblyTreePanelProps { * which is every tree drawn before this existed. */ readonly onRename?: (assemblyId: string, name: string) => void - /** What the tree is for, drawn above it. */ - readonly title: string - /** - * Whether the feature this answers is a row on the list yet. - * - * **A stack built for a feature that does not exist has to say so** (Paul, - * 2026-09-07). Everything here is being tried and nothing is on a feature - * until a tick says so, and a tree that looks identical either way is one - * somebody reads as already done. - */ - readonly confirmed?: boolean } const SlotRow = ({ @@ -364,8 +354,6 @@ export const AssemblyTreePanel = ({ onAdd, onRemove, onRename, - title, - confirmed = true, }: AssemblyTreePanelProps) => { /** * The stack being named, where one is. @@ -386,6 +374,12 @@ export const AssemblyTreePanel = ({ */ const [naming, setNaming] = useState(null) + /* Read once: the card asks how many there are as well as drawing each of + them, and the press for another one goes on the last of them. */ + const groups = treeGroups(assemblies) + /* Every stack in the box, for the one press under all of them. */ + const everyStack = groups.flatMap((group) => stacksOf(group)) + return (
    + {/* + **The label, and nothing under it** (Paul, 2026-09-11). A line reading + "Cuts the pocket" stood here, which is what the card above it already + says — the reading is named at the top of the same box — and what the + list under the part is headed by. `data-list-chrome` still carries it, + which is the one place it is over something that would otherwise be + unlabelled. + */}
    -

    - Tool assemblies -

    -

    - {title} -

    - {confirmed ? null :

    not on the list yet

    } +

    Tool assemblies

    - {treeGroups(assemblies).map((group) => { + {groups.map((group, position) => { const stacks = stacksOf(group) const index = assemblies.indexOf(group.root) return ( @@ -470,7 +466,7 @@ export const AssemblyTreePanel = ({ next read makes a stack of its own: a hole drilled for a thread nobody is cutting. */} - {treeGroups(assemblies).length > 1 || !stacks.every(isEmpty) ? ( + {groups.length > 1 || !stacks.every(isEmpty) ? ( ( -
    - - {/* - **What else the press moves is said before it is pressed.** A - holder change takes the collet with it, and a button that drops - one somebody chose without a word is the defect this line - exists to prevent. - */} - {action.note === undefined ? null : ( -

    {action.note}

    - )} -
    - ))} + + + Add assembly + + )}
    ) })} {/* - **Another stack is always one press away** (Paul, 2026-09-07, on the - "multiple tools for one feature" question). A pocket is a rougher and a - finisher, and the page had no way to say so at all. - */} - {onAdd === undefined ? null : ( - - )} + **One press for everything in the box** (Paul, 2026-09-08: "there should + only be one 'add to order list' button for the full assembly", and + 2026-09-11: "the button should be below BOTH of them, and add both + assemblies to the order list — we only need one button and it just adds + everything on the list"). + + It was drawn inside each card, so a rougher and a finisher were two + presses and ordering the pair meant pressing both — while Enter on the + same box had written all of them since 2026-09-10. The two disagreed + about what finishing the box means, and the button was the one that was + wrong. `groupActions` already reads a list of stacks; this hands it every + stack in the tree rather than one card's. + */} + {actionsFor(everyStack).map((action) => ( +
    + + {/* + **What else the press moves is said before it is pressed.** A holder + change takes the collet with it, and a button that drops one somebody + chose without a word is the defect this line exists to prevent. + */} + {action.note === undefined ? null : ( +

    {action.note}

    + )} +
    + ))}
    ) } diff --git a/apps/catalog/app/components/column-filter.test.tsx b/apps/catalog/app/components/column-filter.test.tsx index 1a80c61..39f0922 100644 --- a/apps/catalog/app/components/column-filter.test.tsx +++ b/apps/catalog/app/components/column-filter.test.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { act, fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import type { UnitSystem } from '@toolpath/tool-support' import { columnFilterOpen } from 'shared/use-escape' @@ -9,7 +9,6 @@ import { OverrideNotice, RangeFilter, TermFilter, - boundFor, compareOf, menuRoom, optionsMatching, @@ -51,55 +50,47 @@ const Harness = ({ ) } -const operator = () => screen.getByRole('combobox', { name: 'How to compare Diameter' }) -const choose = (compare: string) => { - const labels: Record = { - any: 'Any', - under: '≤ at most', - over: '≥ at least', - equals: '= exactly', - range: 'between', - } - fireEvent.click(operator()) - fireEvent.click(screen.getByRole('option', { name: labels[compare] })) +const box = (side: 'min' | 'max' = 'min') => + screen.getByRole('textbox', { name: `Diameter — ${side}` }) +const type = (raw: string, side: 'min' | 'max' = 'min') => + fireEvent.change(box(side), { target: { value: raw } }) +/** Finishing with a box, which is when shorthand is written back out in longhand. */ +const leave = (side: 'min' | 'max' = 'min') => { + fireEvent.blur(box(side)) + fireEvent.focusOut(box(side)) } -const box = (name = 'value') => screen.getByRole('textbox', { name: `Diameter — ${name}` }) -const type = (raw: string, name = 'value') => - fireEvent.change(box(name), { target: { value: raw } }) describe('asking about one number', () => { /** - * The defect: the operator was derived from the bound, so ≤ with nothing - * typed yet wrote `{ max: undefined }` — which is `{}`, which is "Any" — and - * the box to type into never appeared. Choosing an operator has to be enough. + * The defect this control replaced: the menu opened on an operator list set + * to "Any", "Any" drew no box, and narrowing a column cost four presses + * before the first keystroke (Paul, 2026-09-11). */ - it('shows a box to type in as soon as an operator is chosen', () => { + it('shows both ends from the start, with nothing to choose first', () => { render() - expect(screen.queryByRole('textbox')).not.toBeInTheDocument() - - choose('under') - expect(box()).toBeInTheDocument() + expect(box('min')).toBeInTheDocument() + expect(box('max')).toBeInTheDocument() + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() }) it('writes the number in millimetres, whatever unit it was typed in', () => { const onBound = vi.fn() render() - choose('under') - type('1.25') + type('1.25', 'max') expect(onBound).toHaveBeenLastCalledWith({ max: 31.75 }) expect(screen.getByText('in')).toBeInTheDocument() }) /** - * The other defect: a controlled number box that re-formatted through - * millimetres on every keystroke turned "1." into "1.000" under the cursor. + * The other defect the boxes were built around: a controlled number box that + * re-formatted through millimetres on every keystroke turned "1." into + * "1.000" under the cursor. */ it('keeps what was typed, half-typed numbers included', () => { render() - choose('over') type('1.') expect(box()).toHaveValue('1.') @@ -108,27 +99,64 @@ describe('asking about one number', () => { expect(box()).toHaveValue('1.2') }) - it('takes two numbers for a range', () => { + it('takes one end from each box', () => { const onBound = vi.fn() render() - choose('range') - type('3', 'from') - type('6', 'to') + type('3', 'min') + type('6', 'max') expect(onBound).toHaveBeenLastCalledWith({ min: 3, max: 6 }) }) - it('writes one number as both ends for exactly', () => { + /** The shorthand, and the box it settles into: `6-12` is a range wherever it is typed. */ + it('splits a range typed into one box across both of them', () => { const onBound = vi.fn() render() - choose('equals') - type('4') + type('6-12') + expect(onBound).toHaveBeenLastCalledWith({ min: 6, max: 12 }) + + leave() + expect(box('min')).toHaveValue('6.00') + expect(box('max')).toHaveValue('12.00') + }) + + it('moves an end typed into the wrong box over to the right one', () => { + const onBound = vi.fn() + render() + + type('<12') + expect(onBound).toHaveBeenLastCalledWith({ max: 12 }) + + leave() + expect(box('min')).toHaveValue('') + expect(box('max')).toHaveValue('12.00') + }) + + it('writes one number into both ends for =', () => { + const onBound = vi.fn() + render() + + type('=4') expect(onBound).toHaveBeenLastCalledWith({ min: 4, max: 4 }) }) + /** + * Settling is the leaving box's own business. Rewriting the far box while + * the caret is arriving in it is the "1." to "1.000" defect wearing a hat. + */ + it('leaves the text in the other box alone when one is finished with', () => { + render() + + type('3.', 'max') + leave('min') + + expect(box('min')).toHaveValue('2.00') + expect(box('max')).toHaveValue('3.') + }) + /** A suggestion, a saved filter, Clear: the stored bound moves, and the boxes follow. */ it('starts from a bound set elsewhere, and follows one that changes', () => { const shown = (bound: Bound | undefined) => ( @@ -141,59 +169,64 @@ describe('asking about one number', () => { /> ) const { rerender } = render(shown({ max: 6 })) - expect(box()).toHaveValue('6.00') + expect(box('min')).toHaveValue('') + expect(box('max')).toHaveValue('6.00') rerender(shown({ min: 2, max: 6 })) - expect(box('from')).toHaveValue('2.00') - expect(box('to')).toHaveValue('6.00') + expect(box('min')).toHaveValue('2.00') + expect(box('max')).toHaveValue('6.00') rerender(shown(undefined)) - expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(box('min')).toHaveValue('') + expect(box('max')).toHaveValue('') }) - it('puts the number down on Any', () => { + it('puts the number down once both boxes are empty', () => { const onBound = vi.fn() render() - choose('any') + type('', 'max') expect(onBound).toHaveBeenLastCalledWith(undefined) }) - /** Emptying the box is on the way to the next number, not a change of mind about the operator. */ - it('keeps the operator while the box is empty', () => { - render() - - type('') - - expect(box()).toBeInTheDocument() - }) - it('never converts a count, and gives it no unit', () => { const onBound = vi.fn() render() - choose('over') - type('4') + type('4', 'min') expect(onBound).toHaveBeenLastCalledWith({ min: 4 }) expect(screen.queryByText('in')).not.toBeInTheDocument() }) -}) -describe('what an operator and its numbers add up to', () => { - it('is nothing until there is a number', () => { - expect(boundFor('under', undefined, undefined)).toBeUndefined() - expect(boundFor('range', undefined, undefined)).toBeUndefined() + /** Opened *by* a press, so the caret is already where the number goes. */ + it('takes the caret into the lower box when it is the dialog somebody opened', async () => { + render( + , + ) + + // On the next frame: the press that opened it takes the focus first. + await waitFor(() => expect(box('min')).toHaveFocus()) }) +}) - /** Every operator survives the round trip through the bound it writes. */ - it('reads back as the operator it was written from', () => { - expect(compareOf(boundFor('under', 6, undefined))).toBe('under') - expect(compareOf(boundFor('over', 6, undefined))).toBe('over') - expect(compareOf(boundFor('equals', 6, undefined))).toBe('equals') - expect(compareOf(boundFor('range', 3, 6))).toBe('range') - expect(compareOf(boundFor('any', 6, 6))).toBe('any') +describe('the shape a stored bound has', () => { + /** Not what any control is set to any more — what the column is asking. */ + it('reads back the question the two ends add up to', () => { + expect(compareOf(undefined)).toBe('any') + expect(compareOf({})).toBe('any') + expect(compareOf({ min: 6 })).toBe('over') + expect(compareOf({ max: 6 })).toBe('under') + expect(compareOf({ min: 6, max: 6 })).toBe('equals') + expect(compareOf({ min: 3, max: 6 })).toBe('range') }) }) @@ -476,12 +509,37 @@ describe('changing a number the geometry set', () => { expect(screen.queryByRole('note')).not.toBeInTheDocument() }) - it('says nothing where no bound is set at all', () => { - render() + it('says nothing where no bound is set and the geometry asked for none', () => { + render( + , + ) expect(screen.queryByRole('note')).not.toBeInTheDocument() }) + /** + * **An empty box is an answer too, where the geometry put a number there** + * (Paul, 2026-09-11: "when I remove a value for min or max, it is not showing + * tools down to the smallest or largest tool in the library"). Clearing is + * the loosest thing a column can say, so `part.tsx` § `released` sets its + * rules aside on the spot — and this is the sentence saying so. Gated on the + * bound alone, the dialog said nothing at all in the one state where the list + * had just widened underneath it. + */ + it('speaks for a number the geometry set and somebody took away', () => { + render() + + const note = screen.getByRole('note') + expect(note).toHaveTextContent('The diameter rules are set aside: 3 tools they turn down') + expect(note).toHaveTextContent('Putting the diameter back to at most 8 mm takes them off again') + // And not the sentence that contradicts it. + expect(note).not.toHaveTextContent('does not change the rules') + }) + /** * A bound typed where the geometry suggested none is somebody's answer too — * gating on a suggestion left a column the sheet happens not to bound diff --git a/apps/catalog/app/components/column-filter.tsx b/apps/catalog/app/components/column-filter.tsx index 66168a0..c9bb990 100644 --- a/apps/catalog/app/components/column-filter.tsx +++ b/apps/catalog/app/components/column-filter.tsx @@ -1,4 +1,4 @@ -import { Button, Checkbox, Combobox, IconButton, Input, cn } from '@toolpath/ui' +import { Button, Checkbox, IconButton, Input, cn } from '@toolpath/ui' import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react' import { createPortal } from 'react-dom' import { @@ -17,23 +17,36 @@ import { } from '@toolpath/tool-support' import { movedBy, movedTo } from 'shared/column-order' import { sameBound } from 'shared/filter' +import { readEntry, readRange, type Side } from 'shared/range-entry' import { LAYER_COLUMN_FILTER, useEscape, useKeyLayer } from 'shared/use-escape' -import { CatalogComboboxButton } from './catalog-combobox-button' +import { SECTION_LABEL } from 'shared/type' /** - * Asking about one number: an operator and a number, or two for a range. + * Asking about one number: its two ends, and no operator to choose first. * - * **The operator is what somebody chose, not what the bound implies.** The - * first version derived it from `{ min, max }`, so pressing ≤ with nothing - * typed yet wrote `{ max: undefined }`, which is `{}`, which read back as - * "Any" — and the box to type into never appeared. The operator is held here - * and the bound is written from it, never the other way round. + * **The operator is typed, not picked off a list** (Paul, 2026-09-11: "it's + * weird showing the drop down then having to enter text"). Narrowing Diameter + * to "at least 6" cost four presses before the first keystroke — the funnel, + * the operator list, the operator, and the box that only then existed, because + * the menu opened on "Any" and "Any" draws no box. Both ends are now on screen + * from the start and the caret is already in the lower one, so the whole of the + * gesture is: open it, type. * - * **The box holds what was typed, not what was stored.** A controlled input - * that re-formats through millimetres on every keystroke turns "1." into - * "1.000" under the cursor. So each box keeps its own text and commits when - * the text is a number; the stored value only writes back into a box when it - * has actually changed — a suggestion, a saved filter, Clear. + * Everything that list offered is still sayable, in the shorthand a shop + * already writes: `6-12`, `>6`, `<12`, `=6`. `shared/range-entry.ts` is the + * rule, and it reads a box that states the *other* box's end — `<12` typed + * into the lower one — so the cursor never has to be in the right place first. + * That list was also the one popover inside `FilterMenu`, and the reason both + * its press-outside and its Enter handler carry a `[data-base-ui-portal]` + * exception. Nothing a filter draws opens a kit popover any more; the two + * exceptions stay because the next filter to need one would need them again. + * + * **A box holds what was typed, not what was stored.** A controlled input that + * re-formats through millimetres on every keystroke turns "1." into "1.000" + * under the cursor. So each box keeps its own text and commits when the text + * says a number; the stored value only writes back into a box when it has + * actually changed — a suggestion, a saved filter, Clear — or when the box is + * left, which is where shorthand is written back out in longhand. */ export interface Bound { @@ -46,7 +59,13 @@ export type Compare = 'any' | 'equals' | 'over' | 'range' | 'under' /** Counts, angles and ratios are not lengths, and are never converted. */ export type Kind = 'length' | 'count' | 'deg' | 'ratio' -/** The shape a stored bound has, which is where the operator starts from. */ +/** + * The shape a stored bound has. + * + * No longer what any control is set to — nothing on screen has a shape any + * more — but still what a column is *asking*, which is what fills its funnel + * and what decides whether there is a rule left to overrule. + */ export const compareOf = (bound: Bound | undefined): Compare => { if (!bound || (bound.min === undefined && bound.max === undefined)) { return 'any' @@ -57,14 +76,6 @@ export const compareOf = (bound: Bound | undefined): Compare => { return bound.max === undefined ? 'over' : 'under' } -const COMPARES: ReadonlyArray<{ value: Compare; label: string }> = [ - { value: 'any', label: 'Any' }, - { value: 'under', label: '≤ at most' }, - { value: 'over', label: '≥ at least' }, - { value: 'equals', label: '= exactly' }, - { value: 'range', label: 'between' }, -] - /** A stored value as text in the unit being read in, for a box that has none yet. */ const toDraft = (value: number | undefined, unit: UnitSystem, kind: Kind): string => { if (value === undefined) { @@ -76,46 +87,25 @@ const toDraft = (value: number | undefined, unit: UnitSystem, kind: Kind): strin return String(value) } -/** What a box's text means in the dataset's own unit, or nothing while it is not a number. */ -const parse = (raw: string, unit: UnitSystem, kind: Kind): number | undefined => { - if (raw.trim() === '') { - return undefined - } - const parsed = Number(raw) - if (!Number.isFinite(parsed)) { - return undefined - } - return kind === 'length' ? convertLength(parsed, unit, 'millimeters') : parsed -} - -/** Whether a box already says this value, so its text is left alone. */ -const says = (draft: string, value: number | undefined, unit: UnitSystem, kind: Kind): boolean => { - const meant = parse(draft, unit, kind) +/** Whether a box already states this end, so its text is left alone. */ +const says = ( + draft: string, + side: Side, + value: number | undefined, + unit: UnitSystem, + kind: Kind, +): boolean => { + const entry = readEntry(draft, side, unit, kind) + const meant = side === 'min' ? entry.min : entry.max if (meant === undefined || value === undefined) { return meant === value } return Math.abs(meant - value) < 1e-9 } -/** The bound an operator and one or two numbers add up to. */ -export const boundFor = ( - compare: Compare, - one: number | undefined, - other: number | undefined, -): Bound | undefined => { - switch (compare) { - case 'any': - return undefined - case 'under': - return one === undefined ? undefined : { max: one } - case 'over': - return one === undefined ? undefined : { min: one } - case 'equals': - return one === undefined ? undefined : { min: one, max: one } - case 'range': - return one === undefined && other === undefined ? undefined : { min: one, max: other } - } -} +/** What the boxes take besides a number, said in the characters a keyboard has. */ +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' export interface RangeFilterProps { readonly label: string @@ -123,79 +113,135 @@ export interface RangeFilterProps { readonly onBound: (bound: Bound | undefined) => void readonly unit: UnitSystem readonly kind: Kind + /** + * Whether the lower box takes the caret as it is drawn. + * + * True where this filter *is* the dialog somebody just opened, and false in + * the filter panel, where a dozen of these are mounted at once and one of + * them stealing the focus would be a page that scrolls itself on load. + */ + readonly opened?: boolean } -export const RangeFilter = ({ label, bound, onBound, unit, kind }: RangeFilterProps) => { - const [compare, setCompare] = useState(() => compareOf(bound)) - /** The one box, or the lower of two. */ - const [one, setOne] = useState(() => - toDraft(compareOf(bound) === 'under' ? bound?.max : bound?.min, unit, kind), - ) - /** The upper box of a range. */ - const [other, setOther] = useState(() => - toDraft(compareOf(bound) === 'range' ? bound?.max : undefined, unit, kind), - ) +export const RangeFilter = ({ + label, + bound, + onBound, + unit, + kind, + opened = false, +}: 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 const max = bound?.max + useEffect(() => { + if (!opened) { + return + } + /* + Opened *by* a press somebody has just made, so the caret belongs in it — + the rule `name-field.tsx` states for the same reason. Selected rather than + appended to, because the press after "not 6" is usually "8". + + **On the next frame, because that press is still in flight.** The funnel + opens this menu on `pointerdown` (see `FilterFunnel` for why), and the + browser focuses the funnel itself as the default action of the `mousedown` + that follows — after this effect has run. Focusing straight away put the + caret in the box and the press took it back out, so the dialog opened on a + box nobody could type into. + */ + const frame = requestAnimationFrame(() => { + first.current?.focus() + first.current?.select() + }) + return () => cancelAnimationFrame(frame) + }, [opened]) + /** * The stored bound changed under us — a suggestion, a saved filter, Clear. * * **A bound this component wrote is left exactly as it is.** The test is - * whether the operator and boxes on screen add up to what is stored; if they - * do, the store is only echoing them and nothing moves. That is what keeps a - * half-typed range as a range — `{ min: 3 }` on its own *reads* as ≥, and - * adopting that shape took the second box away mid-entry — and what keeps the - * operator when a box is emptied on the way to the next number. + * whether the two boxes on screen add up to what is stored; if they do, the + * store is only echoing them and nothing moves. That is what keeps a + * half-typed `6-` where it was typed, and what keeps the other box's text + * while one of them is emptied on the way to the next number. * - * Only a bound that could not have come from this screen is adopted: its - * shape becomes the operator, and its values write into the boxes that do not - * already say them. + * Only a bound that could not have come from this screen is adopted, and + * then only into the boxes that do not already say it. */ useEffect(() => { const stored = min === undefined && max === undefined ? undefined : { min, max } - const mine = boundFor(compare, parse(one, unit, kind), parse(other, unit, kind)) - if (sameBound(mine, stored)) { - return - } - - const shape = compareOf(stored) - setCompare(shape) - if (shape === 'any') { - setOne('') - setOther('') + if (sameBound(readRange(lower, upper, unit, kind), stored)) { return } - const lead = shape === 'under' ? max : min - if (!says(one, lead, unit, kind)) { - setOne(toDraft(lead, unit, kind)) + if (!says(lower, 'min', min, unit, kind)) { + setLower(toDraft(min, unit, kind)) } - const trail = shape === 'range' ? max : undefined - if (!says(other, trail, unit, kind)) { - setOther(toDraft(trail, unit, kind)) + if (!says(upper, 'max', max, unit, kind)) { + setUpper(toDraft(max, unit, kind)) } - // The operator and drafts are read, not depended on: this runs when the - // *stored* bound moves, and re-running it on every keystroke is the bug it - // exists to fix. + // The drafts are read, not depended on: this runs when the *stored* bound + // moves, and re-running it on every keystroke is the bug it exists to fix. }, [min, max]) - const commit = (nextCompare: Compare, nextOne: string, nextOther: string) => { - setCompare(nextCompare) - setOne(nextOne) - setOther(nextOther) - onBound(boundFor(nextCompare, parse(nextOne, unit, kind), parse(nextOther, unit, kind))) + const commit = (nextLower: string, nextUpper: string) => { + setLower(nextLower) + setUpper(nextUpper) + onBound(readRange(nextLower, nextUpper, unit, kind)) } - const box = (name: string, value: string, onValue: (raw: string) => void) => ( + /** + * What a box settles on once it is finished with. + * + * The bound is already right — every keystroke commits one — so this is only + * the text: `6-12` written back out as a 6 in one box and a 12 in the other, + * and `1.` as `1.00`. **Only the box being left is rewritten**, unless what + * it said belongs somewhere else, because canonicalising the far box while + * the caret is arriving in it is the "1." to "1.000" defect wearing a hat. + */ + const settle = (side: Side) => { + const entry = readEntry(side === 'min' ? lower : upper, side, unit, kind) + const displaced = side === 'min' ? entry.max !== undefined : entry.min !== undefined + const read = readRange(lower, upper, unit, kind) + if (displaced || side === 'min') { + setLower(toDraft(read?.min, unit, kind)) + } + if (displaced || side === 'max') { + setUpper(toDraft(read?.max, unit, kind)) + } + } + + const box = (side: Side, value: string, onValue: (raw: string) => void) => ( onValue(next ?? '')} + onBlur={() => settle(side)} + onKeyDown={(event) => { + /* + Inside a `FilterMenu` this never fires: that dialog answers Enter on + the document, on the way down, and takes the press for itself. Here + for the panel, where these boxes stand on the page with no dialog + over them and Enter is the only way to finish without moving the + mouse. + */ + if (event.key === 'Enter') { + settle(side) + } + }} variant="ghost" size="md" textEnd @@ -204,46 +250,27 @@ export const RangeFilter = ({ label, bound, onBound, unit, kind }: RangeFilterPr ) return ( -
    - each.value)} - value={compare} - onValueChange={(next) => { - if (typeof next === 'string') { - commit(next as Compare, one, other) - } - }} - itemToStringLabel={(value) => COMPARES.find((each) => each.value === value)?.label ?? ''} - size="md" - variant="ghost" - > - - - - {COMPARES.map((each) => ( - - {each.label} - - - ))} - - - - - {compare === 'any' ? null : ( - <> - {box(compare === 'range' ? 'from' : 'value', one, (raw) => commit(compare, raw, other))} - {compare === 'range' ? ( - <> - – - {box('to', other, (raw) => commit(compare, one, raw))} - - ) : null} - {kind === 'length' ? ( - {UNIT_ABBREVIATION[unit]} - ) : null} - - )} +
    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}
    ) } @@ -285,7 +312,7 @@ export interface ColumnOverride { } /** - * Whether this column has a number of somebody's own in it. + * Whether this column has an answer of somebody's own in it. * * **A filter is somebody's answer the moment it is not the geometry's.** Typing * a bound where the geometry suggested one is the case this started from; typing @@ -293,17 +320,24 @@ export interface ColumnOverride { * meant a column the sheet happens not to bound could never be overruled at all * even while its rules were holding tools off the list. * - * So: a bound is set, and it is not simply the suggestion left untouched. With - * no bound at all there is nothing to overrule — the rules are the only thing - * narrowing that number, which is the ordinary state of the page. + * **And an empty box is an answer too, where the geometry put a number there** + * (Paul, 2026-09-11: "when I remove a value for min or max, it is not showing + * tools down to the smallest or largest tool in the library"). Clearing is the + * loosest thing a column can say, so `part.tsx` § `released` sets that column's + * rules aside on the spot — and this is what puts the sentence saying so on + * screen. Gated on `compareOf` alone, the dialog said nothing at all in the one + * state where the list had just widened underneath it. + * + * With no bound and no suggestion there is nothing to overrule: the rules are + * the only thing narrowing that number, which is the ordinary state of the page. */ export const overrideOffered = ( bound: Bound | undefined, override: ColumnOverride | undefined, ): override is ColumnOverride => override !== undefined && - compareOf(bound) !== 'any' && - !(override.suggested !== undefined && sameBound(bound, override.suggested)) + (compareOf(bound) !== 'any' || override.suggested !== undefined) && + !sameBound(bound, override.suggested) /** * The sentence under the boxes: what the geometry asked for, what changing it @@ -342,9 +376,17 @@ export const OverrideNotice = ({ data-column-override className="text-2xs mt-2 max-w-64 border-l border-zinc-700 pl-2 leading-snug text-zinc-400" > + {/* + "Changing it does not change the rules" is true only while they still + hold: once they are set aside the sentence below says so, and saying + both was the dialog contradicting itself in the one state where the + list had just widened underneath it. + */} {suggested === undefined ? `The rules still judge the ${named} whatever this says.` - : `The geometry asked for ${say(suggested)}. Changing it does not change the rules.`}{' '} + : on + ? `The geometry asked for ${say(suggested)}.` + : `The geometry asked for ${say(suggested)}. Changing it does not change the rules.`}{' '} {available === 0 ? ( <>Nothing is being held back by the {named} rules alone. ) : on ? ( @@ -581,9 +623,10 @@ export const FilterMenu = ({ } /* **A dropdown opened from inside this menu is inside it.** The kit draws - a `Combobox` popover in a portal of its own, so choosing "≥ at least" - for a range read as a press on the page and shut the filter before the - box to type in had been drawn. + a popover in a portal of its own, so choosing an operator for a range + read as a press on the page and shut the filter before the box to type + in had been drawn. That operator list is gone — `RangeFilter` says why + — and this stays for the next filter that opens one. */ if (target.closest('[data-base-ui-portal]') !== null) { return @@ -626,10 +669,10 @@ export const FilterMenu = ({ * default runs after the press has finished propagating, so a listener on the * document is still in time to cancel it. * - * The one press left alone is one inside a popover the kit drew: choosing - * "≥ at least" from the operator list is that list's Enter, and the popover - * is a portal of its own rather than anything inside this box — the same - * escape hatch the press-outside rule above needs, for the same reason. + * The one press left alone is one inside a popover the kit drew, which is a + * portal of its own rather than anything inside this box — the same escape + * hatch the press-outside rule above needs, and left standing for the same + * reason: no filter draws such a popover today, and the next one would. */ useKeyLayer(true, { name: LAYER_COLUMN_FILTER, @@ -676,7 +719,7 @@ export const FilterMenu = ({ that is indistinguishable from a misclick. */}
    -

    {label}

    +

    {label}

    {/* **And a way back out that is not a guess either** (Paul, 2026-09-09: "can I get an X next to the check mark to clear all filters"). It diff --git a/apps/catalog/app/components/column-heading.test.tsx b/apps/catalog/app/components/column-heading.test.tsx index fa2ece5..d78e54b 100644 --- a/apps/catalog/app/components/column-heading.test.tsx +++ b/apps/catalog/app/components/column-heading.test.tsx @@ -321,7 +321,7 @@ describe('Enter, from inside a filter menu', () => { ) const before = onClose.mock.calls.length - fireEvent.keyDown(screen.getByRole('textbox', { name: 'Diameter — value' }), { key: 'Enter' }) + fireEvent.keyDown(screen.getByRole('textbox', { name: 'Diameter — max' }), { key: 'Enter' }) expect(onOverride).toHaveBeenCalled() expect(onClose.mock.calls.length).toBe(before + 1) diff --git a/apps/catalog/app/components/column-heading.tsx b/apps/catalog/app/components/column-heading.tsx index 74265ad..16d5afa 100644 --- a/apps/catalog/app/components/column-heading.tsx +++ b/apps/catalog/app/components/column-heading.tsx @@ -336,6 +336,9 @@ export const ColumnFilterMenu = ({ onBound={what.onBound} unit={what.unit} kind={what.kind} + /* This filter *is* the dialog somebody just opened, so the caret + belongs in it rather than behind one more press. */ + opened /> {props.override === undefined ? null : ( diff --git a/apps/catalog/app/components/component-table.test.tsx b/apps/catalog/app/components/component-table.test.tsx index 6a157ae..6d6e7df 100644 --- a/apps/catalog/app/components/component-table.test.tsx +++ b/apps/catalog/app/components/component-table.test.tsx @@ -98,23 +98,24 @@ describe('the filters a holder heading asks', () => { }) /** - * **A dropdown opened from inside the filter is inside it.** The kit draws a - * `Combobox` popover in a portal of its own, outside the menu's own box, so - * the press that chose an operator read as a press on the page and shut the - * filter before the box to type in had been drawn. The press is dispatched - * rather than clicked because that rule is written against `pointerdown`. + * **The filter opens on somewhere to type, and stays open while it is typed + * in** (Paul, 2026-09-11). It used to open on an operator list reading "Any", + * which drew no box at all, and the press that chose an operator landed in a + * portal outside the menu's own box and read as a press on the page — so the + * filter shut before the box to type in had been drawn. Both ends are now on + * screen from the start and there is no popover in the way of them. */ - it('stays open while the compare dropdown is used', () => { + it('opens on both ends of the number, and stays open while one is typed', () => { show() fireEvent.click(screen.getByRole('button', { name: 'Filter by Gauge length' })) - fireEvent.click(screen.getByRole('combobox', { name: 'How to compare Gauge length' })) - const option = screen.getByRole('option', { name: '≥ at least' }) - fireEvent.pointerDown(option) - fireEvent.click(option) + const box = screen.getByRole('textbox', { name: 'Gauge length — min' }) + expect(screen.getByRole('textbox', { name: 'Gauge length — max' })).toBeInTheDocument() + + fireEvent.change(box, { target: { value: '60' } }) expect(screen.getByRole('group', { name: 'Gauge length' })).toBeInTheDocument() - expect(screen.getByRole('textbox', { name: 'Gauge length — value' })).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'Gauge length — min' })).toHaveValue('60') }) /** diff --git a/apps/catalog/app/components/component-table.tsx b/apps/catalog/app/components/component-table.tsx index bce02f3..dde339f 100644 --- a/apps/catalog/app/components/component-table.tsx +++ b/apps/catalog/app/components/component-table.tsx @@ -23,6 +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 { ColumnFilterMenu, ColumnHeading, @@ -185,11 +186,7 @@ const ValueCell = ({ const value = valueOf(kind, record, column.code) return ( {formatValue(value, column.kind, unit)} @@ -351,7 +348,11 @@ export const ComponentTable = ({ ) return ( -
    +
    {column.code === 'catalogNumber' ? ( <> - {record.catalogNumber} + {record.catalogNumber} {/* **The vendor's page is on the number** (Paul, 2026-09-11), as it already is on the order list: the diff --git a/apps/catalog/app/components/component-tally.tsx b/apps/catalog/app/components/component-tally.tsx index 15e596a..0d8c489 100644 --- a/apps/catalog/app/components/component-tally.tsx +++ b/apps/catalog/app/components/component-tally.tsx @@ -8,6 +8,7 @@ import { type SortableComponent, } from 'shared/order-list' import type { Component } from 'shared/setup-sheet' +import { SECTION_LABEL, TABLE_FACE, TABLE_INK } from 'shared/type' /** * The order list added up by component: what to buy, and how many. @@ -120,7 +121,8 @@ const Heading = ({ title={column.title} onClick={() => onSort(column.by)} className={cn( - 'text-2xs inline-flex items-center gap-0.5 rounded px-0.5 tracking-wide uppercase transition', + SECTION_LABEL, + 'inline-flex items-center gap-0.5 rounded px-0.5 transition', here ? 'text-info' : 'text-zinc-500 hover:text-zinc-200', )} > @@ -153,7 +155,10 @@ export const ComponentTally = ({ rows, empty, sort, descending, onSort }: Compon `tests/on-the-part.spec.ts` § "at a laptop width" exists for. */
    -
    +
    @@ -185,19 +190,17 @@ export const ComponentTally = ({ rows, empty, sort, descending, onSort }: Compon {row.icon} - + ))} diff --git a/apps/catalog/app/components/feature-details.tsx b/apps/catalog/app/components/feature-details.tsx index 6cdede1..8d8829d 100644 --- a/apps/catalog/app/components/feature-details.tsx +++ b/apps/catalog/app/components/feature-details.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' import { CheckIcon, CopyIcon } from '@phosphor-icons/react' -import { IconButton, Tooltip } from '@toolpath/ui' +import { IconButton, Tooltip, cn } from '@toolpath/ui' import type { PartFeature } from '@toolpath/part-contracts' import { asRecord } from '@toolpath/part-contracts/datasheet' import { @@ -11,6 +11,7 @@ import { import { featureSummary, kindOf, rawDatasheet } from '@toolpath/part-contracts/report' import type { UnitSystem } from '@toolpath/tool-support' import { KindIcon, MeasurementIcon } from './feature-icons' +import { HEADING, SECTION_LABEL } from 'shared/type' /** * Everything Toolpath has to say about one feature. @@ -47,7 +48,7 @@ const flatten = (value: unknown, prefix = ''): Array<[string, string]> => { const Section = ({ title, children }: { title: string; children: ReactNode }) => (
    -

    {title}

    +

    {title}

    {children}
    ) @@ -137,7 +138,7 @@ export const FeatureDetails = ({ return (
    -

    +

    {name ?? summary.type} {siblings > 1 ? ( @@ -188,7 +189,7 @@ export const FeatureDetails = ({ {row.label} ⓘ -
    +
    {row.value} {/* The other unit, quietly. A shop reads in one and buys tooling in the other, and the sum between them is the kind somebody @@ -203,9 +204,7 @@ export const FeatureDetails = ({
    - - All datasheet fields - + All datasheet fields
    {flatten(feature.datasheet).map(([path, value]) => (
    @@ -220,9 +219,7 @@ export const FeatureDetails = ({
    - - Raw API record - + Raw API record
    diff --git a/apps/catalog/app/components/feature-list-panel.tsx b/apps/catalog/app/components/feature-list-panel.tsx index 1eb9e7d..c248980 100644 --- a/apps/catalog/app/components/feature-list-panel.tsx +++ b/apps/catalog/app/components/feature-list-panel.tsx @@ -233,7 +233,7 @@ const Answer = ({ troubled to call a stack something is a shop that will look for it here. */} {assembly === null ? null : ( - {assembly} + {assembly} )} diff --git a/apps/catalog/app/components/filter-panel.tsx b/apps/catalog/app/components/filter-panel.tsx index f2df946..a2900ad 100644 --- a/apps/catalog/app/components/filter-panel.tsx +++ b/apps/catalog/app/components/filter-panel.tsx @@ -27,6 +27,7 @@ import { ReducedShankIcon, ToolTypeIcon, } from './tool-icons' +import { SECTION_LABEL } from 'shared/type' /** * What each ISO 513 letter means at the machine. @@ -153,7 +154,7 @@ const monogram = (brand: string): string => { } const Monogram = ({ brand }: { brand: string }) => ( - + {monogram(brand)} ) @@ -386,7 +387,7 @@ const Field = ({ children: ReactNode }) => (
    -

    +

    {icon} {label}

    @@ -990,9 +991,7 @@ const TilePicker = ({ {groups.map(([group, members]) => (
    {group ? ( -
    - {group} -
    +
    {group}
    ) : null}
    {members.map(tile)} diff --git a/apps/catalog/app/components/filter-rail.tsx b/apps/catalog/app/components/filter-rail.tsx index ef1554f..98acc07 100644 --- a/apps/catalog/app/components/filter-rail.tsx +++ b/apps/catalog/app/components/filter-rail.tsx @@ -5,6 +5,7 @@ import { formatLength, type UnitSystem } from '@toolpath/tool-support' import type { ToolQuery } from 'shared/filter' import { useEscape } from 'shared/use-escape' import { FilterPanel, QUICK_FILTERS, type FilterPanelProps } from './filter-panel' +import { SECTION_LABEL } from 'shared/type' /** * The filters as a rail of buttons over the part. @@ -181,7 +182,7 @@ export const RailBubble = ({ > {icon} - {label} + {label} {set ? ( {answers.slice(0, SHOWN).join(', ')} diff --git a/apps/catalog/app/components/fusion-export-dialog.tsx b/apps/catalog/app/components/fusion-export-dialog.tsx index 4d983c8..980ef22 100644 --- a/apps/catalog/app/components/fusion-export-dialog.tsx +++ b/apps/catalog/app/components/fusion-export-dialog.tsx @@ -3,6 +3,7 @@ import { Button, Card, Input } from '@toolpath/ui' import { PRETOOL_MATERIALS, type PretoolMaterial } from 'shared/pretool-presets' import type { FusionExportDiagnostic, FusionExportSettings } from 'shared/fusion-library' import { useEscape } from 'shared/use-escape' +import { SECTION_LABEL } from 'shared/type' export interface FusionExportDialogProps { readonly initialMaterial: PretoolMaterial | null @@ -80,9 +81,7 @@ export const FusionExportDialog = ({
    - - Workpiece material - + Workpiece material
    {PRETOOL_MATERIALS.map((each) => (

    Components to order
    {row.brand}{row.brand} {row.productLink === null ? ( - - {row.catalogNumber} - + {row.catalogNumber} ) : ( {row.catalogNumber} - - ×{row.count} - + ×{row.count}
    {column.code === 'catalogNumber' ? ( <> - {tool.catalogNumber} + {tool.catalogNumber} {/* **The vendor's page is on the number** (Paul, 2026-09-11), as it already is on the order list: the @@ -663,21 +677,18 @@ export const PartToolTable = ({ )} ) : column.code === 'brand' ? ( - + {tool.brand} ) : column.code === 'type' ? ( - + {tool.type} ) : column.code === 'family' ? ( - + {tool.family} ) : ( diff --git a/apps/catalog/app/components/part-upload-overlay.tsx b/apps/catalog/app/components/part-upload-overlay.tsx index 2c2bfb7..1768d0c 100644 --- a/apps/catalog/app/components/part-upload-overlay.tsx +++ b/apps/catalog/app/components/part-upload-overlay.tsx @@ -3,6 +3,7 @@ import { Button, Card } from '@toolpath/ui' import { CAD_EXTENSIONS } from '@toolpath/part-contracts' import type { UploadStatus } from 'client/use-part-upload' import { useEscape } from 'shared/use-escape' +import { HEADING } from 'shared/type' export interface ReplacementAnalysis { readonly message: string @@ -81,7 +82,7 @@ export const PartUploadOverlay = ({
    -

    {title}

    +

    {title}

    {description}

    {!busy && onClose ? ( diff --git a/apps/catalog/app/components/part-viewer.test.tsx b/apps/catalog/app/components/part-viewer.test.tsx index e959684..7468516 100644 --- a/apps/catalog/app/components/part-viewer.test.tsx +++ b/apps/catalog/app/components/part-viewer.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import type { PublicInspectionReport } from '@toolpath/part-contracts' +import { layerCount } from 'shared/use-escape' /** * What the viewer hands the viewer package, pinned. @@ -282,3 +283,34 @@ describe('an overlay taller than the viewer', () => { expect(section()?.className).toContain('z-50') }) }) + +/** + * **Escape puts the record away, and leaves the box it came from alone** (Paul, + * 2026-09-11: "hitting escape should close feature details but keep the feature + * dialog open and as is"). The record opens *over* the questions with no layer + * of its own, so the press walked the page's own step instead — the reading + * behind it was dropped and the record stayed open on a feature nothing was + * reading. It is the newest thing on the screen, so `use-escape.ts` gives it + * the press. + */ +describe('the feature record and Escape', () => { + it('answers the press while it is open', () => { + const onCloseDetails = vi.fn() + show({ details:

    the record

    , onCloseDetails }) + + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(onCloseDetails).toHaveBeenCalled() + }) + + /** Nothing open, nothing to take the press — the page keeps it. */ + it('takes no press while it is closed', () => { + const onCloseDetails = vi.fn() + show({ details: null, onCloseDetails }) + + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(onCloseDetails).not.toHaveBeenCalled() + expect(layerCount()).toBe(0) + }) +}) diff --git a/apps/catalog/app/components/part-viewer.tsx b/apps/catalog/app/components/part-viewer.tsx index c133c5a..6df1af2 100644 --- a/apps/catalog/app/components/part-viewer.tsx +++ b/apps/catalog/app/components/part-viewer.tsx @@ -15,6 +15,8 @@ import { GridFourIcon, MagnifyingGlassPlusIcon, SquareHalfIcon, XIcon } from '@p import type { PartReport, PublicInspectionReport } from '@toolpath/part-contracts' import { readingTheme } from 'shared/reading-colors' import { FrameInset } from 'components/frame-inset' +import { SECTION_LABEL } from 'shared/type' +import { useEscape } from 'shared/use-escape' /** * The part, and the directions it can be cut from. @@ -221,6 +223,21 @@ export const PartViewer = ({ */ const questions = useRef(null) const [questionsWidth, setQuestionsWidth] = useState(null) + + /** + * Escape puts the record away, and leaves the box it was opened from alone. + * + * **The press belonged to the page** (Paul, 2026-09-11: "hitting escape + * should close feature details but keep the feature dialog open and as is"). + * The record is opened *over* the questions with no layer of its own, so one + * press walked the page's own step — dropping the reading behind it — and the + * record stayed open on a feature nothing was reading any more. It is the + * newest thing on the screen, so it takes the press: `use-escape.ts` is the + * stack, and this layer is pushed only while the record is up. + */ + useEscape(Boolean(details) && onCloseDetails !== undefined, () => { + onCloseDetails?.() + }) /** * How far in the boxes over the part reach — what the camera was told. * @@ -461,9 +478,7 @@ export const PartViewer = ({ style={questionsWidth === null ? undefined : { left: questionsWidth + 20 }} >
    - - Feature details - + Feature details Thread + Thread { diff --git a/apps/catalog/app/components/selection-panel.test.tsx b/apps/catalog/app/components/selection-panel.test.tsx index 81ebe58..3d3a341 100644 --- a/apps/catalog/app/components/selection-panel.test.tsx +++ b/apps/catalog/app/components/selection-panel.test.tsx @@ -116,3 +116,53 @@ describe('the offer to group identical holes', () => { expect(screen.queryByText(/identical/)).not.toBeInTheDocument() }) }) + +/** + * **The thread is read before the numbers, not after them** (Paul, 2026-09-11: + * "the option to add a thread should be more prominent — put it directly + * underneath the group bubble in a similar bubble with grey background"). + * + * Whether a hole is tapped decides which catalog the table below is even + * showing, and it sat at the foot of the panel under a hairline rule, below + * every measurement — which is where this page puts a detail. + */ +describe('where the thread is asked', () => { + const both = { + identical: { count: 8, onGroup: () => undefined, onDismiss: () => undefined }, + thread: { + holeDiameter: 5, + mode: 'plain' as const, + spec: null, + onChange: () => undefined, + }, + } + + it('puts it directly under the grouping offer, in a bubble of its own', () => { + draw({ siblings: 8, ...both }) + + const offer = screen + .getByRole('button', { name: 'Add all 8 as a group' }) + .closest('div.rounded') + const thread = screen.getByText('Modeled hole diameter:').closest('div.rounded') + + expect(offer).not.toBeNull() + expect(thread).not.toBeNull() + // The next box down, rather than the last thing on the panel. + expect(offer?.nextElementSibling).toBe(thread) + // Grey, where the offer above it is the page's blue: a standing question + // about the hole rather than something to answer now. + expect(thread?.className).toContain('bg-zinc-800/60') + }) + + /** + * **And nothing points at the arrows** (Paul, 2026-09-11: "once a feature is + * selected, it should no longer show 'click an arrow for machining + * direction'"). It was advice for picking a way up, under a box already + * naming the one being read. + */ + it('says nothing about clicking an arrow', () => { + draw({ siblings: 8, ...both }) + + expect(screen.queryByText(/click an arrow/)).not.toBeInTheDocument() + }) +}) diff --git a/apps/catalog/app/components/selection-panel.tsx b/apps/catalog/app/components/selection-panel.tsx index 9f990ed..8894528 100644 --- a/apps/catalog/app/components/selection-panel.tsx +++ b/apps/catalog/app/components/selection-panel.tsx @@ -265,11 +265,16 @@ export const SelectionPanel = ({ )}
    - {/* Two ways to say the same thing, and the arrows are the one that says - *which way up* — so the field points at them (Paul, 2026-08-31). */} - {ways.size > 1 ? ( -

    click an arrow for machining direction

    - ) : null} + {/* + **No line pointing at the arrows** (Paul, 2026-09-11: "once a feature is + selected, it should no longer show 'click an arrow for machining + direction'"). It was advice for picking a way up, and it sat under a + box that already names the one being read — so it read as an + instruction to do something about a decision that had been made. The + picker above still says *Select a direction* while nothing has been + chosen, which is the same sentence in the one place it is still a + question (`asking`). + */} {/* **The offer, not the grouping** (Paul, 2026-09-09). A hole used to be @@ -298,6 +303,24 @@ export const SelectionPanel = ({
    ) : null} + {/* + Hole mode: a hole is drawn as a hole whether or not it is threaded, so + the panel asks (Paul, 2026-08-31) — **and it asks here**, under the + grouping offer rather than below the readings at the foot of the panel + (Paul, 2026-09-11). Whether the hole is tapped decides which list the + table is even showing, so it is read before the numbers rather than + after them. `thread-picker.tsx` is the bubble it is drawn in. + */} + {thread ? ( + + ) : null} + {/* The measurement row keeps its height while it is empty, for the same reason: the panel below it should not move when a feature is picked. */} {readings.length > 0 ? ( @@ -335,18 +358,6 @@ export const SelectionPanel = ({ ))} ) : null} - - {/* Hole mode: a hole is drawn as a hole whether or not it is threaded, - so the panel asks (Paul, 2026-08-31). */} - {thread ? ( - - ) : null}
    ) } diff --git a/apps/catalog/app/components/thread-picker.tsx b/apps/catalog/app/components/thread-picker.tsx index 0b5d0f1..e17cb0d 100644 --- a/apps/catalog/app/components/thread-picker.tsx +++ b/apps/catalog/app/components/thread-picker.tsx @@ -13,6 +13,7 @@ import { type ThreadSpec, } from 'shared/threads' import { CatalogComboboxButton } from './catalog-combobox-button' +import { SECTION_LABEL } from 'shared/type' /** * How this hole is made, and for what thread. @@ -65,7 +66,16 @@ export const ThreadPicker = ({ holeDiameter, mode, spec, onChange, unit }: Threa const plain = matchesThreadSearch(query, PLAIN) return ( -
    + /* + **A bubble, not a footnote** (Paul, 2026-09-11: "the option to add a + thread should be more prominent — put it directly underneath the group + bubble in a similar bubble with grey background"). A hairline rule at the + bottom of the panel is what the page does with a detail, and whether a + hole is tapped is the decision that picks the tool. Same shape as the + identical-holes offer above it, in grey rather than the offer's blue: it + is a standing question about the hole rather than something to answer now. + */ +
    {/* **Every number on this panel says what it is** (Paul, 2026-09-01: "it's not really clear what the boxes are showing — tap drill diameter, @@ -82,7 +92,7 @@ export const ThreadPicker = ({ holeDiameter, mode, spec, onChange, unit }: Threa hole", and one way to say a thing is enough (Paul, same day). */}
    - + {spec === null ? 'Thread' : `${spec.name} threaded hole`}
    diff --git a/apps/catalog/app/components/tool-details.tsx b/apps/catalog/app/components/tool-details.tsx index b202c0a..0f575b7 100644 --- a/apps/catalog/app/components/tool-details.tsx +++ b/apps/catalog/app/components/tool-details.tsx @@ -252,7 +252,7 @@ export const ToolDetails = ({ - + {tool.catalogNumber} diff --git a/apps/catalog/app/components/toolpath-logo.tsx b/apps/catalog/app/components/toolpath-logo.tsx new file mode 100644 index 0000000..4a72b41 --- /dev/null +++ b/apps/catalog/app/components/toolpath-logo.tsx @@ -0,0 +1,37 @@ +/** + * Toolpath's own mark, traced from `toolpath.com/img/logo.svg`. + * + * Inline rather than an imported `.svg`: the catalog's dev-server adapter has + * to be told about every new kind of imported asset + * (`apps/catalog/dev-server-exclude.ts`), and a two-colour tile is not worth a + * request. The green tile carries the glyph, so it reads the same in either + * theme and needs no palette role of its own. + */ +export interface ToolpathLogoProps { + readonly className?: string +} + +export const ToolpathLogo = ({ className }: ToolpathLogoProps) => ( + +) diff --git a/apps/catalog/app/root.tsx b/apps/catalog/app/root.tsx index b9939bd..aa9341d 100644 --- a/apps/catalog/app/root.tsx +++ b/apps/catalog/app/root.tsx @@ -8,7 +8,8 @@ import { isRouteErrorResponse, useRouteError, } from 'react-router' -import { LinkProvider } from '@toolpath/ui' +import { LinkProvider, cn } from '@toolpath/ui' +import { HEADING } from './shared/type' import { RouterLink } from './components/router-link' import appCss from './styles.css?url' @@ -135,7 +136,7 @@ export const ErrorBoundary = () => { return (
    -

    {title}

    +

    {title}

    Reload the page. If it comes back to this, restart the dev server ( pnpm dev:catalog) — a rebuild can leave it serving stale modules. diff --git a/apps/catalog/app/routes/order-list.tsx b/apps/catalog/app/routes/order-list.tsx index 1c9c8e8..673f2a3 100644 --- a/apps/catalog/app/routes/order-list.tsx +++ b/apps/catalog/app/routes/order-list.tsx @@ -18,6 +18,7 @@ import { ColletIcon, HolderIcon, ToolTypeIcon, formLabel } from './../components import { allTools, getCollet, getHolder, getTool } from 'shared/catalog' import { addChoice, + lineUnder, quantityOf, removeChoice, setQuantity, @@ -44,6 +45,7 @@ import { recallPart } from 'shared/part-session' import { useUnit } from 'shared/use-unit' import { usePartMaterial } from 'shared/use-preferences' import type { PretoolMaterial } from 'shared/pretool-presets' +import { SECTION_LABEL, TABLE_FACE, TABLE_INK } from 'shared/type' /** * The order list: what has been decided for this part, in one list. @@ -249,7 +251,8 @@ const SortHeading = ({ title={column.title} onClick={() => onSort(column.by)} className={cn( - 'text-2xs inline-flex items-center gap-0.5 rounded tracking-wide uppercase transition', + SECTION_LABEL, + 'inline-flex items-center gap-0.5 rounded transition', here ? 'text-info' : 'text-zinc-400 hover:text-zinc-100', )} > @@ -296,7 +299,7 @@ const Count = ({ variant="ghost" size="md" textEnd - className="inline-flex w-14 rounded border border-zinc-700 font-mono text-xs text-zinc-100" + className="inline-flex w-14 rounded border border-zinc-700 text-xs text-zinc-100" /> ) @@ -384,7 +387,7 @@ const Row = ({ label={`How many ${line.catalogNumber} (${KIND[line.component].toLowerCase()}) per assembly`} /> {total === 1 ? null : ( - = {String(total * quantity)} + = {String(total * quantity)} )} {/* One component off the assembly, rather than the whole of it. */} -

    + {/* **The vendor's page is on the number** (Paul, 2026-09-01: "vendor link should be in part ID cell, and we need to make sure it's working"). The @@ -414,23 +417,21 @@ const Row = ({ */} - + ) @@ -662,12 +663,17 @@ const Bom = () => { total, and a single field over them could only guess which of the three a shop meant. */ -
    {line.brand}{line.brand} {line.productLink === null ? ( - - {line.catalogNumber} - + {line.catalogNumber} ) : ( {line.catalogNumber} )} {line.detail}{line.detail}
    +
    - + {/* **Every column is a way to read it** (Paul, 2026-09-09, of the same view on the part). Buying, the list is walked @@ -739,7 +745,7 @@ const Bom = () => {
    Every component on the order list, and how many to order
    {line.productLink === null ? ( - + {line.catalogNumber} ) : ( @@ -748,7 +754,7 @@ const Bom = () => { target="_blank" rel="noreferrer noopener" title={`${line.catalogNumber} on the vendor's site`} - className="text-info/90 hover:text-info focus-visible:ring-info/60 inline-flex items-center gap-1 rounded font-mono whitespace-nowrap underline-offset-2 hover:underline focus-visible:ring-1 focus-visible:outline-none" + className="text-info/90 hover:text-info focus-visible:ring-info/60 inline-flex items-center gap-1 rounded whitespace-nowrap underline-offset-2 hover:underline focus-visible:ring-1 focus-visible:outline-none" > {line.catalogNumber}
    ) : ( - +
    - + @@ -802,7 +813,7 @@ const Bom = () => { {assemblies.flatMap( - ({ key, choice, rows: machines, keys, featureless, title }) => { + ({ key, choice, rows: machines, keys, ids, featureless, title }) => { const tool = getTool(choice.toolGuid) ?? undefined const holder = choice.holderGuid == null @@ -818,8 +829,14 @@ const Bom = () => { * features it machines. */ const across = ( - change: (sheetSoFar: SetupSheet, tag: string) => SetupSheet, - ) => commit(keys.reduce(change, sheet)) + change: (sheetSoFar: SetupSheet, tag: string, id: string) => SetupSheet, + ) => + commit( + keys.reduce( + (soFar, tag) => ids.reduce((held, id) => change(held, tag, id), soFar), + sheet, + ), + ) /** * The bill **reads out** what was decided; it is not where * the deciding happens (Paul, 2026-08-31). The holder is @@ -845,13 +862,9 @@ const Bom = () => { features: machines, total: totalOf(choice), onTotal: (many: number) => - across((sheetSoFar, tag) => - setTotal(sheetSoFar, tag, choice.toolGuid, many), - ), + across((sheetSoFar, tag, id) => setTotal(sheetSoFar, tag, id, many)), onRemove: () => - across((sheetSoFar, tag) => - removeChoice(sheetSoFar, tag, choice.toolGuid), - ), + across((sheetSoFar, tag, id) => removeChoice(sheetSoFar, tag, id)), } if (lines.length === 0) { return ( @@ -877,21 +890,33 @@ const Bom = () => { total={totalOf(choice)} quantity={quantityOf(choice, line.component)} onQuantity={(many) => - across((sheetSoFar, tag) => - setQuantity(sheetSoFar, tag, choice.toolGuid, line.component, many), + across((sheetSoFar, tag, id) => + setQuantity(sheetSoFar, tag, id, line.component, many), ) } onRemove={() => - across((sheetSoFar, tag) => - line.component === 'tool' - ? removeChoice(sheetSoFar, tag, choice.toolGuid) + across((sheetSoFar, tag, id) => { + if (line.component === 'tool') { + return removeChoice(sheetSoFar, tag, id) + } + /* + The line as this feature holds it, rather than + the one the assembly was drawn from: a stack + ordered by two rows is one row here and two + lines on the sheet, and writing one of them back + under the other's key would leave that feature + holding a line belonging to a stack of another. + */ + const had = lineUnder(sheetSoFar, tag, id) + return had === null + ? sheetSoFar : addChoice(sheetSoFar, tag, { - ...choice, + ...had, ...(line.component === 'holder' ? { holderGuid: undefined, colletGuid: undefined } : { colletGuid: undefined }), - }), - ) + }) + }) } /> )) diff --git a/apps/catalog/app/routes/part.tsx b/apps/catalog/app/routes/part.tsx index bfcebb9..219d086 100644 --- a/apps/catalog/app/routes/part.tsx +++ b/apps/catalog/app/routes/part.tsx @@ -37,10 +37,12 @@ import { addChoice, clearChoice, chosenFor, + lineId, removeChoice, - setTotal, + removeTool, totalOf, useSetupSheet, + type Choice, type SetupSheet, } from 'shared/setup-sheet' import { PartViewer } from 'components/part-viewer' @@ -68,6 +70,7 @@ import { type Results, } from 'shared/feature-list' import { recommendationRows, type RecommendationAnswer } from 'shared/recommendations' +import { SECTION_LABEL } from 'shared/type' import { clearKeys, componentTotals, @@ -110,6 +113,15 @@ import { } from 'shared/catalog' import { columnFilterOpen, useEscape } from 'shared/use-escape' import { assemblyPressEnabled, pressesShown, rowsShown } from 'shared/part-chrome' +import { + COMPONENT_LIST, + TOOL_LIST, + arrowTarget, + focusList, + handToList, + insideList, + type ArrowTarget, +} from 'shared/arrow-target' import { DRAFT_TREE, assemblyName, @@ -174,6 +186,7 @@ import { countBy, countsByAxis, ownBounds, + releasedBounds, stillOffered, filterTools, queryFromSearch, @@ -563,9 +576,15 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: * filter panel opened over the part takes the press instead. Both used to * fire on one press, which put the panel away *and* dropped the reading * behind it. + * + * **A field keeps it; the tool list does not** (2026-09-11). The list used to + * be exempt along with them, on the grounds that it answers Escape for itself + * by dropping its selected row — and that was harmless only while nothing put + * the focus there. The focus goes to the list the moment anything is selected + * now, so exempting it meant Escape no longer backed out of the box at all. */ useEscape(true, (event) => { - if (busyTyping(event)) { + if (typingInto(event)) { return } escapeRef.current() @@ -627,13 +646,28 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: return } } - if (busyTyping(event)) { - return - } - + /* + **The arrows are the list's, and never the features'** (Paul, + 2026-09-11). The focus is already in the list by the time one is + pressed — selecting anything puts it there — so this is what repairs a + press that arrived anywhere else, and a list reading no row yet. + `shared/arrow-target.ts` is the rule. + + Ahead of `busyTyping`, because that gate is the tool list's half of this + same question and `arrowTarget` now owns both halves. + */ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + if (typingInto(event)) { + return + } + if (arrowsRef.current(insideList(event.target)) === null) { + return + } event.preventDefault() - stepRef.current(event.key === 'ArrowDown' ? 1 : -1) + handListRef.current() + return + } + if (busyTyping(event)) { return } // Space puts the row being read on the list, or takes it off — the same @@ -1545,6 +1579,16 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: () => ownBounds(query.ranges, suggestions.ranges), [query.ranges, suggestions], ) + /** + * The columns whose number somebody took away, which releases their rules — + * `releasedBounds` in `shared/filter.ts` is the rule, and says why. + */ + const released = useMemo( + () => releasedBounds(query.ranges, suggestions.ranges), + [query.ranges, suggestions], + ) + /** Every column whose rules are set aside: the ticked ones and the emptied ones. */ + const forgiven = useMemo(() => [...new Set([...overriding, ...released])], [overriding, released]) /* The tree, the row it belongs to and the key it is stored under stand ahead of the matcher because the matcher is told about them (2026-09-10): the @@ -1844,7 +1888,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: holderFilters, margins, thresholds, - overrides: overriding, + overrides: forgiven, ownRanges: own, }), [ @@ -1856,7 +1900,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: holderFilters, margins, thresholds, - overriding, + forgiven, own, ], ) @@ -1966,11 +2010,11 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: /** The overridden columns in the words their headers use, for the note above the list. */ const overridden = useMemo( () => - overriding + forgiven .map((code) => TOOL_COLUMNS.find((column) => column.code === code)?.label.toLowerCase()) .filter((label): label is string => label !== undefined) .join(' and '), - [overriding], + [forgiven], ) /** What makes the thread: taps for either tapping mode, mills for milling. */ @@ -2370,7 +2414,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: return { suggested, available: detailed?.overridableByCode[code] ?? 0, - on: overriding.includes(code), + on: forgiven.includes(code), /** * **The number and the forgiveness are one decision** (Paul, * 2026-09-08: "if override rules is off, it should go back to the @@ -2390,7 +2434,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: sayBound(ask?.shape === 'range' ? ask.kind : 'length', bound, unit), } }, - [asking, suggestions, detailed, overriding, overrideOn, unit], + [asking, suggestions, detailed, forgiven, overrideOn, unit], ) /** @@ -3554,6 +3598,43 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: } }, [focused, selectedItem, draft, list, sheet, groupOf, aloneFor, startAddGroup, changeToGroup]) + /** + * The same offer, inside the group being built. + * + * **A group asks it too** (Paul, 2026-09-11: "the group dialog should ask if + * I want to add identical holes if I select one, just like the feature + * dialog"). Clicking a hole while a group is open used to take every + * identical hole with it; that expansion came out on 2026-09-11 so a group + * could be corrected a hole at a time — and with it went the only way to pick + * up a bolt circle without thirty-nine clicks. `groupOffer` is deliberately + * silent here, because the offer it answers *opens* a group; this one grows + * the one already open, which is a different press with the same sentence. + * + * Withheld once every sibling is already in: an offer to add what is there + * reads as a press that does nothing. + */ + const offerSiblings = useMemo(() => { + if (draft?.kind !== 'group' || focused === null || aloneFor === focused) { + return null + } + const siblings = groupOf(focused) + const missing = siblings.filter((each) => !kept.includes(each)) + if (siblings.length < 2 || missing.length === 0) { + return null + } + return { + count: siblings.length, + // One `toggle` each, which is the same press as clicking each of them on + // the part — so a hole taken out afterwards comes out on its own. + onGroup: () => { + for (const tag of missing) { + dispatch({ type: 'toggle', featureTag: tag }) + } + }, + onDismiss: () => setAloneFor(focused), + } + }, [draft, focused, aloneFor, groupOf, kept, dispatch]) + /** * A tool assembly of the part's own, begun. * @@ -4209,6 +4290,22 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: const lookedUpCollet = looking && componentSlot === 'collet' ? (lookedUp === null ? null : getCollet(lookedUp)) : null + /** + * The row that list is reading, whichever list it is. + * + * Read twice: by the table, for the row it marks, and by the arrows, which + * land on a list's first row rather than on nothing where it is reading + * nothing — `shared/arrow-target.ts` says why the kit needs that. + */ + const rowRead = + componentSlot === null + ? (assembly?.toolGuid ?? null) + : looking + ? lookedUp + : componentSlot === 'holder' + ? (assembly?.holderGuid ?? null) + : (assembly?.colletGuid ?? null) + /** * Whether the tree is drawn at all. * @@ -4598,64 +4695,42 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: } } /** - * How many stacks of this row stand on the bill as one cutter. + * **Every stack writes its own line, named after itself** (Paul, + * 2026-09-11: "when I have two (or more) tool assemblies on a feature or + * group, both need to be shown in the order list. Only the first is being + * shown right now"). * - * **Two stacks of one cutter are two of it to buy** (Paul, 2026-09-10: - * "duplicates are now showing up as separate line items … that should show - * 2 assemblies and a count of two of each component"). The sheet keys a - * line by its tool, so a row cannot hold the same cutter on two lines — - * what it holds instead is `total`, how many of that assembly — and - * `componentTotals` already multiplies every component by it. Nothing was - * writing it, so a second identical assembly wrote the same line again and - * the bill said one. + * The sheet used to key a line by its tool, so a row could hold one line + * per cutter and no more: a second assembly given the first's cutter wrote + * over it — its holder along with it — and what stood in for the two of + * them was `total`, a count on one line saying "twice" about a stack the + * bill had only one description of. Two stacks with different holders were + * the same defect without the count: the second one's holder simply + * replaced the first's. * - * Counted off the tree once it is marked, because the tree is the row's - * answer: every stack of it standing as that cutter is one of it to set up. + * `assemblyId` on the line is the fix and this is where it is written: + * `lineId` in `shared/setup-sheet.ts` is the whole of the new rule, and + * `addChoice` replaces a line with the same id rather than the same tool. */ - const ordered = (toolGuid: string): number => - marked.filter((each) => each.orderedTool === toolGuid).length let next = sheet - for (const { line, had } of written) { + for (const { stack, line, had } of written) { + const mine: Choice = { ...line, assemblyId: stack.id } /* - **A replacement takes the old line off before the new one goes on.** - The sheet keys a line by its tool, so a stack that swapped cutters - would otherwise leave the tool it was ordered as sitting on the - feature beside the one that replaced it (Paul, 2026-09-07). + **A replacement takes the old line off before the new one goes on** — + for a line written before a line carried the stack's id, which is + keyed by its tool and so is a different line from this one (Paul, + 2026-09-07). A line this stack already wrote is replaced where it + stands, so there is nothing to take off. */ const off = kind === 'remove' - ? (had?.toolGuid ?? line.toolGuid) - : had !== null && had.toolGuid !== line.toolGuid - ? had.toolGuid + ? lineId(had ?? mine) + : had !== null && had.assemblyId !== stack.id + ? lineId(had) : null - /* - Unless another stack of this row is still standing as it: taking one of - two identical assemblies off leaves one, not none, and the line is - where that one lives. - */ - const kept = off !== null && ordered(off) > 0 for (const tag of tags) { - next = off === null || kept ? next : removeChoice(next, tag, off) - next = kind === 'remove' ? next : addChoice(next, tag, line) - } - } - /* - And then how many of each, over every cutter this press touched — the - one it wrote and the one it took off, which are different on a swap. - */ - const touched = new Set( - written.flatMap(({ line, had }) => [ - line.toolGuid, - ...(had === null ? [] : [had.toolGuid]), - ]), - ) - for (const toolGuid of touched) { - const many = ordered(toolGuid) - if (many === 0) { - continue - } - for (const tag of tags) { - next = setTotal(next, tag, toolGuid, many) + next = off === null ? next : removeChoice(next, tag, off) + next = kind === 'remove' ? next : addChoice(next, tag, mine) } } commit(next) @@ -4886,6 +4961,59 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: */ const tappingNow = assembly !== null ? assembly.role === 'tap' : tapping + /** + * Whether the tool list is drawn, rather than a notice or the skeleton. + * + * One derivation read twice — by the table area, for which of the four things + * it draws, and by the focus and the arrows, which have nothing to reach + * while it is false. Two of them would be two chances to disagree about what + * is on screen. + */ + const toolListDrawn = !perFeature && !(asking && detailed === null && !tappingNow) + + /** + * Which list the arrows and the focus are for, and `null` while there is none. + * + * The rack or the drawer where a slot has one open, the tools otherwise — and + * nothing at all unless a box is open over the part, because the arrows + * belong to a question being asked rather than to the catalog at rest. + */ + const listOnScreen = + !dialogOpen || (componentSlot === null && !toolListDrawn) + ? null + : componentSlot === null + ? TOOL_LIST + : COMPONENT_LIST + + /** + * **The focus follows the question** (Paul, 2026-09-11: "the focus should go + * to the table as soon as a feature is selected"). Selecting anything is what + * puts somebody in front of a list of tools, so it is what hands them the + * keys to it — an arrow press of their own is one press too late, and this + * application walks no features with the arrows for it to compete with. + * + * Keyed on what is being asked rather than on what the list holds: a filter + * typed into, a row picked, an answer arriving late — none of those are a new + * question, and taking the focus back on each of them would be taking it away + * from whatever somebody was doing. + */ + const askingNow = + listOnScreen === null + ? null + : [ + listOnScreen, + selectedId ?? '', + draft?.kind ?? '', + focused ?? '', + node?.assemblyId ?? '', + node?.slot ?? '', + ].join('|') + useEffect(() => { + if (askingNow !== null && listOnScreen !== null) { + focusList(listOnScreen) + } + }, [askingNow, listOnScreen]) + /** * Which of this row's other stacks a component is standing in, by name. * @@ -5042,7 +5170,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: */ remove: () => { commit( - askedNow.tags.reduce((current, tag) => removeChoice(current, tag, panelTool.guid), sheet), + askedNow.tags.reduce((current, tag) => removeTool(current, tag, panelTool.guid), sheet), ) }, } @@ -5141,8 +5269,18 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: }, [showTree, node, assemblies, treeActionsFor, applyStacks]) const escapeRef = useRef(() => {}) - const stepRef = useRef((_step: number) => {}) const keepRef = useRef(() => {}) + /** `arrowTarget`, asked with everything but where the press landed. */ + const arrowsRef = useRef<(inList: boolean) => ArrowTarget>(() => null) + arrowsRef.current = (inList: boolean) => + arrowTarget({ boxOpen: listOnScreen !== null, inList, readingARow: rowRead !== null }) + /** And the press it asks for, once the rule has said there is one to make. */ + const handListRef = useRef(() => {}) + handListRef.current = () => { + if (listOnScreen !== null) { + handToList(listOnScreen, rowRead !== null) + } + } /** Enter's answer: whether there was a press to make, having made it. */ const orderRef = useRef<() => boolean>(() => false) orderRef.current = () => { @@ -5166,8 +5304,6 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: } dispatch({ type: 'escape' }) } - stepRef.current = (step: number) => - dispatch({ type: 'step', order: rows.map((each) => each.featureTag), by: step > 0 ? 1 : -1 }) keepRef.current = () => { // Nothing being read yet: the first press takes the first row, so a fresh // list is one key rather than a click and a key. @@ -5601,7 +5737,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: the column's own dialog, because that is the rule it overrules. */} - {overriding.length > 0 && overrideTools.length > 0 ? ( + {forgiven.length > 0 && overrideTools.length > 0 ? ( setInfo(focused)} + /* + **The same press closes it again** (Paul, + 2026-09-11). The `i` opened the details + panel and then did nothing at all, so the + only ways out were the X and a press of + Escape the page was taking for itself. + */ + onInfo={() => + setInfo((current) => (current === focused ? null : focused)) + } candidates={candidates} onRead={(featureTag) => dispatch({ type: 'read', featureTag })} directionOf={(feature) => directionOf(feature)} @@ -6327,8 +6477,6 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: onRename={(id, name) => writeTree(renameAssembly(assemblies, id, name)) } - title={listTitle} - confirmed={activeItem !== null} /> ) : null} @@ -6455,7 +6603,10 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: */}

    @@ -6740,7 +6891,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: Tools will automatically be selected for each feature. After creating the group, click on a feature in the list to see all compatible tools.

    - ) : asking && detailed === null && !tappingNow ? ( + ) : !toolListDrawn ? ( ) : tappingNow ? ( /* @@ -6841,13 +6992,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: columnOrder={ componentSlot === 'holder' ? holderColumnOrder : colletColumnOrder } - chosen={ - looking - ? lookedUp - : componentSlot === 'holder' - ? (assembly?.holderGuid ?? null) - : (assembly?.colletGuid ?? null) - } + chosen={rowRead} /* A row fills the slot the tree has open; with no stack open there is no slot, and the click is a look-up the diff --git a/apps/catalog/app/shared/arrow-target.test.ts b/apps/catalog/app/shared/arrow-target.test.ts new file mode 100644 index 0000000..357b2ff --- /dev/null +++ b/apps/catalog/app/shared/arrow-target.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + COMPONENT_LIST, + TOOL_LIST, + arrowTarget, + focusList, + handToList, + insideList, + readFirstRow, +} from './arrow-target' + +describe('arrowTarget', () => { + it('is nobody’s while nothing is being asked', () => { + expect(arrowTarget({ boxOpen: false, inList: false, readingARow: false })).toBeNull() + expect(arrowTarget({ boxOpen: false, inList: true, readingARow: true })).toBeNull() + }) + + it('stands down for a list that has the focus and a row to move from', () => { + expect(arrowTarget({ boxOpen: true, inList: true, readingARow: true })).toBeNull() + }) + + it('hands the press to a list that has not got the focus', () => { + expect(arrowTarget({ boxOpen: true, inList: false, readingARow: true })).toBe('list') + }) + + it('answers a list reading no row, focus or no focus', () => { + expect(arrowTarget({ boxOpen: true, inList: true, readingARow: false })).toBe('list') + expect(arrowTarget({ boxOpen: true, inList: false, readingARow: false })).toBe('list') + }) +}) + +afterEach(() => { + document.body.replaceChildren() +}) + +/** A list on screen: the kit's focusable container, and rows inside it. */ +const mountList = (attribute: string, rows: number): HTMLElement => { + const list = document.createElement('div') + list.setAttribute(attribute.slice(1, -1), 'true') + const container = document.createElement('div') + container.setAttribute('tabindex', '0') + list.append(container) + for (let index = 0; index < rows; index += 1) { + const row = document.createElement('div') + row.setAttribute('data-row-index', String(index)) + container.append(row) + } + document.body.append(list) + return list +} + +describe('insideList', () => { + it('knows a press inside either list from one outside both', () => { + const tools = mountList(TOOL_LIST, 1) + const rack = mountList(COMPONENT_LIST, 1) + const elsewhere = document.createElement('canvas') + document.body.append(elsewhere) + + expect(insideList(tools.querySelector('[data-row-index]'))).toBe(true) + expect(insideList(rack.querySelector('[data-row-index]'))).toBe(true) + expect(insideList(elsewhere)).toBe(false) + expect(insideList(null)).toBe(false) + }) +}) + +describe('focusList', () => { + it('focuses the kit’s own container rather than the wrapper', () => { + const tools = mountList(TOOL_LIST, 2) + + expect(focusList(TOOL_LIST)).toBe(true) + expect(document.activeElement).toBe(tools.querySelector('[tabindex="0"]')) + }) + + it('says so when there is no list on screen', () => { + expect(focusList(TOOL_LIST)).toBe(false) + }) +}) + +describe('readFirstRow', () => { + it('clicks the first row the list draws', () => { + const tools = mountList(TOOL_LIST, 2) + const clicked = vi.fn() + tools.querySelector('[data-row-index="0"]')?.addEventListener('click', clicked) + tools.querySelector('[data-row-index="1"]')?.addEventListener('click', clicked) + + readFirstRow(TOOL_LIST) + + expect(clicked).toHaveBeenCalledTimes(1) + }) +}) + +describe('handToList', () => { + it('lands on the first row where the list is reading none', () => { + const tools = mountList(TOOL_LIST, 2) + const clicked = vi.fn() + tools.querySelector('[data-row-index="0"]')?.addEventListener('click', clicked) + + expect(handToList(TOOL_LIST, false)).toBe(true) + expect(document.activeElement).toBe(tools.querySelector('[tabindex="0"]')) + expect(clicked).toHaveBeenCalledTimes(1) + }) + + it('leaves the row being read alone', () => { + const tools = mountList(TOOL_LIST, 2) + const clicked = vi.fn() + tools.querySelector('[data-row-index="0"]')?.addEventListener('click', clicked) + + handToList(TOOL_LIST, true) + + expect(clicked).not.toHaveBeenCalled() + }) +}) diff --git a/apps/catalog/app/shared/arrow-target.ts b/apps/catalog/app/shared/arrow-target.ts new file mode 100644 index 0000000..f4d6f01 --- /dev/null +++ b/apps/catalog/app/shared/arrow-target.ts @@ -0,0 +1,100 @@ +/** + * Where an arrow press goes while a box is open over the part. + * + * **Nowhere near the features.** This application's readings are chosen by + * clicking an arrow on the part or by naming one in the list, and never walked + * with the keyboard (Paul, 2026-09-11: "we should disable the arrow navigation + * for features in this app — it only happens by clicking the arrow or through + * the drop down list, never browsed through the keyboard arrows"). The page + * used to spend every press on that walk, so choosing a feature and reaching + * for the arrows moved the reading behind the open box rather than the tools + * inside it. + * + * So the arrows belong to the **list** — the tools, or the rack of holders or + * drawer of collets where a slot has one open — and the page's whole job is to + * put the list in a state where `@toolpath/ui`'s table will take the press + * itself. The focus goes there the moment something is selected + * (`routes/part.tsx`), which leaves this as what a press has to repair when it + * did not. + * + * It is a rule of its own rather than a condition inline in the handler for the + * reason `use-escape.ts` and `part-chrome.ts` are: which layer a press reaches + * is the kind of sentence somebody is wrong about, and it is worth a test. + */ + +/** The list the press is for, or nothing where the page keeps its hands off. */ +export type ArrowTarget = 'list' | null + +export interface ArrowState { + /** A feature, a group or a tool assembly is open, so there is a list under it. */ + readonly boxOpen: boolean + /** + * The press landed inside a list already. + * + * The kit's table runs its own navigation off the focus inside it, so the + * page standing down *is* the handoff — but only once that table has a row to + * move from, which is the other half below. + */ + readonly inList: boolean + /** + * The list is reading a row. + * + * The kit moves a cursor it only has once a row is selected, so a list + * reading nothing takes the focus and then ignores every press after it. A + * press in that state is the page's to answer, focus or no focus. + */ + readonly readingARow: boolean +} + +export const arrowTarget = ({ boxOpen, inList, readingARow }: ArrowState): ArrowTarget => + !boxOpen || (inList && readingARow) ? null : 'list' + +/** The tool list, by the attribute `PartToolTable` marks itself with. */ +export const TOOL_LIST = '[data-part-tool-table]' + +/** A rack of holders or a drawer of collets, the same way. */ +export const COMPONENT_LIST = '[data-component-table]' + +/** Whether a press landed inside either of them. */ +export const insideList = (target: EventTarget | null): boolean => + (target as HTMLElement | null)?.closest?.(`${TOOL_LIST}, ${COMPONENT_LIST}`) != null + +/** + * Puts the focus in the list on screen, and says whether there was one. + * + * The kit's own scroll container is what holds it; the wrapper around that is + * this application's and takes none. Without `preventScroll` the panel jumps + * to wherever the list was last left, which is not what selecting a feature + * asked for. + */ +export const focusList = (selector: string): boolean => { + const container = document + .querySelector(selector) + ?.querySelector('[tabindex="0"]') + if (container === null || container === undefined) { + return false + } + container.focus({ preventScroll: true }) + return true +} + +/** + * Lands on the list's first row, so there is a cursor to move from. + * + * Clicked rather than chosen out of the data the page handed over, because the + * order on screen is the table's own once somebody has sorted it. + */ +export const readFirstRow = (selector: string): void => { + document.querySelector(`${selector} [data-row-index]`)?.click() +} + +/** Hands one press to the list: the focus, and a row to move from. */ +export const handToList = (selector: string, readingARow: boolean): boolean => { + if (!focusList(selector)) { + return false + } + if (!readingARow) { + readFirstRow(selector) + } + return true +} diff --git a/apps/catalog/app/shared/assembly-actions.test.ts b/apps/catalog/app/shared/assembly-actions.test.ts index 64bdfe6..5d60b92 100644 --- a/apps/catalog/app/shared/assembly-actions.test.ts +++ b/apps/catalog/app/shared/assembly-actions.test.ts @@ -189,11 +189,7 @@ describe('a feature that is not a row yet', () => { */ it('offers the row itself where there is no row yet and no tool', () => { expect(assemblyActions(stack({ toolGuid: null }), [], false)).toEqual([ - { - kind: 'list', - label: 'Add feature to list', - note: 'Nothing is ordered against it yet, so it goes on the list marked incomplete.', - }, + { kind: 'list', label: 'Add feature to list' }, ]) }) diff --git a/apps/catalog/app/shared/assembly-actions.ts b/apps/catalog/app/shared/assembly-actions.ts index 37a99cd..018aa03 100644 --- a/apps/catalog/app/shared/assembly-actions.ts +++ b/apps/catalog/app/shared/assembly-actions.ts @@ -138,9 +138,6 @@ const LISTING: Readonly> = { assembly: null, } -/** What a row put on the list with nothing in it is, said before it is made. */ -const INCOMPLETE = 'Nothing is ordered against it yet, so it goes on the list marked incomplete.' - /** * The press under a stack with nothing in it to order. * @@ -166,7 +163,15 @@ const nothingYet = (onList: boolean, subject: Subject): AssemblyAction => { const listing = onList ? null : LISTING[subject] return listing === null ? { kind: onList ? 'add' : 'confirm', label: 'Add to order list', disabled: true } - : { kind: 'list', label: listing, note: INCOMPLETE } + : /* + **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 + list already says about it in the one place it can be read — dashed, with + the mark beside the name (`isIncomplete`, `shared/order-list.ts`). Two + sentences of amber under the press said it before there was anything to + say it about. + */ + { kind: 'list', label: listing } } /** The line this stack would write, or null while it has no tool to write one for. */ @@ -258,8 +263,25 @@ export const holdingChanges = (had: Choice, wanted: Choice): Array): Choice | null => { + /* + **The line this stack wrote, by name** (Paul, 2026-09-11). Since a line + carries the id of the stack that wrote it, that is the whole lookup — and it + is what lets two stacks of one cutter each find their own line rather than + both finding the first. + */ + const mine = onSheet.find((each) => each.assemblyId === assembly.id) + if (mine !== undefined) { + return mine + } + /* + A line written before a line carried one — by the tool panel, or by any + sheet saved before 2026-09-11 — is still keyed by its tool, and + `orderedTool` is what says which tool this stack stands as. + */ const wanted = assembly.orderedTool ?? null - return wanted === null ? null : (onSheet.find((each) => each.toolGuid === wanted) ?? null) + return wanted === null + ? null + : (onSheet.find((each) => each.assemblyId === undefined && each.toolGuid === wanted) ?? null) } /** How a component is named on the button, or null where the caller cannot name it. */ diff --git a/apps/catalog/app/shared/assembly-tree.ts b/apps/catalog/app/shared/assembly-tree.ts index 656b22d..aecc8ac 100644 --- a/apps/catalog/app/shared/assembly-tree.ts +++ b/apps/catalog/app/shared/assembly-tree.ts @@ -614,7 +614,10 @@ export const treeFromLines = ( : // Stable, so two drills or two taps keep the order the bill has them in. [...lines].sort((a, b) => Number(isTap(b.toolGuid)) - Number(isTap(a.toolGuid))) return ordered.map((line, index) => ({ - id: `assembly-${String(index + 1)}`, + /* The stack that wrote the line, where it said so — a tree read back off + the bill has to come back with the ids the bill's lines name, or every + stack of it reads as one nobody has ordered. */ + id: line.assemblyId ?? `assembly-${String(index + 1)}`, role: roles[index] ?? 'cut', toolGuid: line.toolGuid, holderGuid: line.holderGuid ?? null, diff --git a/apps/catalog/app/shared/filter.test.ts b/apps/catalog/app/shared/filter.test.ts index 5310220..b411af9 100644 --- a/apps/catalog/app/shared/filter.test.ts +++ b/apps/catalog/app/shared/filter.test.ts @@ -9,6 +9,7 @@ import { ownBounds, prioritise, queryFromSearch, + releasedBounds, searchFromQuery, searchWithQuery, stillOffered, @@ -653,3 +654,36 @@ describe('the bounds somebody set themselves', () => { expect(ownBounds({ DC: {} }, {})).toEqual({}) }) }) + +/** + * **An empty box means unbounded, and unbounded includes the rules** (Paul, + * 2026-09-11: "when I remove a value for min or max, it is not showing tools + * down to the smallest or largest tool in the library with a feature or group + * active"). + */ +describe('the numbers the geometry set and somebody took away', () => { + it('names a column whose bound has been cleared outright', () => { + expect(releasedBounds({}, { DC: { max: 8 } })).toEqual(['DC']) + }) + + it('names one left holding neither end, which narrows nothing', () => { + expect(releasedBounds({ DC: {} }, { DC: { max: 8 } })).toEqual(['DC']) + }) + + it('leaves a column still holding a number alone, the geometry’s or not', () => { + expect(releasedBounds({ DC: { max: 8 } }, { DC: { max: 8 } })).toEqual([]) + expect(releasedBounds({ DC: { max: 20 } }, { DC: { max: 8 } })).toEqual([]) + // One end taken away is still an answer with a number in it. + expect(releasedBounds({ DC: { min: 3 } }, { DC: { min: 3, max: 8 } })).toEqual([]) + }) + + /** + * Elsewhere the rules are the only thing narrowing that number, which is the + * ordinary state of the page: an empty box there is not an answer about + * anything, and releasing on it would set every column's rules aside at once. + */ + it('says nothing about a column the geometry never bounded', () => { + expect(releasedBounds({}, {})).toEqual([]) + expect(releasedBounds({ LCF: { min: 5 } }, {})).toEqual([]) + }) +}) diff --git a/apps/catalog/app/shared/filter.ts b/apps/catalog/app/shared/filter.ts index 5cee097..bf228e8 100644 --- a/apps/catalog/app/shared/filter.ts +++ b/apps/catalog/app/shared/filter.ts @@ -211,6 +211,39 @@ export const ownBounds = ( return own } +/** + * The columns whose number the geometry set and somebody took away. + * + * **An empty box means unbounded, and unbounded includes the rules** (Paul, + * 2026-09-11: "when I remove a value for min or max, it is not showing tools + * down to the smallest or largest tool in the library with a feature or group + * active"). Clearing a bound took the *filter* off and left the `must` rows + * that wrote it judging every tool exactly as before — and the one way to set + * those aside, the tick in that column's dialog, was offered only while a + * number stood in the box. So the list could not be widened by clearing it and + * could not be widened by the control either: each half of "show me + * everything" was behind the other. + * + * The number and the forgiveness stay one decision, which is what + * {@link ownBounds} above and `part.tsx` § `overrideFor` have said since + * 2026-09-09 — it is the *number* that is different here. No bound at all is + * the loosest thing a column can say, so the rules go with it; typing the + * geometry's own number back in puts them back, which is still the whole way + * out of an override. + * + * Only a column the geometry actually bounded. Everywhere else the rules are + * the only thing narrowing that number, which is the ordinary state of the + * page, and an empty box there is not an answer about anything. + */ +export const releasedBounds = ( + ranges: ToolQuery['ranges'], + suggested: ToolQuery['ranges'], +): ReadonlyArray => + Object.keys(suggested).filter((key) => { + const held = ranges[key] + return held === undefined || (held.min === undefined && held.max === undefined) + }) + /** Pure, and the whole of the search: the same function the tests run on literals. */ export const filterTools = ( tools: ReadonlyArray, diff --git a/apps/catalog/app/shared/order-list.test.ts b/apps/catalog/app/shared/order-list.test.ts index a27efad..017f7de 100644 --- a/apps/catalog/app/shared/order-list.test.ts +++ b/apps/catalog/app/shared/order-list.test.ts @@ -25,7 +25,7 @@ const names = (tag: string) => (tag.startsWith('h') ? 'Through Hole' : 'Pocket') const order = ( sheet: SetupSheet, item: ListItem, - choice: { toolGuid: string; holderGuid?: string; colletGuid?: string }, + choice: { toolGuid: string; holderGuid?: string; colletGuid?: string; assemblyId?: string }, ): SetupSheet => (item.kind === 'assembly' ? [item.id] : item.tags).reduce( (current, key) => addChoice(current, key, choice), @@ -103,6 +103,7 @@ describe('the order list itself', () => { choice: { toolGuid: 'em', holderGuid: 'bt30' }, rows: ['Through Hole', 'Pocket'], keys: ['h1', 'h2', 'h3', 'p1'], + ids: ['em'], featureless: false, }, ]) @@ -124,6 +125,77 @@ describe('the order list itself', () => { ]) }) + /** + * **Two stacks of one cutter on one row are two rows here** (Paul, + * 2026-09-11: "when I have two (or more) tool assemblies on a feature or + * group, both need to be shown in the order list. Only the first is being + * shown right now"). A line is keyed by the stack that wrote it, so the + * second no longer writes over the first — and the holder each was given is + * the holder it keeps. + */ + it('is two stacks where one row gave a cutter two holders', () => { + const sheet = order( + order(emptySheet('part-1'), circle, { + toolGuid: 'em', + holderGuid: 'a', + assemblyId: 'assembly-1', + }), + circle, + { toolGuid: 'em', holderGuid: 'b', assemblyId: 'assembly-2' }, + ) + + expect(orderAssemblies([circle], sheet, names).map((each) => each.key)).toEqual([ + 'em|a|', + 'em|b|', + ]) + }) + + /** Identical twice over is still two things to set up, and two to buy. */ + it('is two stacks where one row ordered the same assembly twice', () => { + const sheet = order( + order(emptySheet('part-1'), circle, { + toolGuid: 'em', + holderGuid: 'bt30', + assemblyId: 'assembly-1', + }), + circle, + { toolGuid: 'em', holderGuid: 'bt30', assemblyId: 'assembly-2' }, + ) + const stacks = orderAssemblies([circle], sheet, names) + + expect(stacks.map((each) => each.key)).toEqual(['em|bt30|', 'em|bt30|#2']) + expect( + componentTotals(stacks, (each) => each.rows.join(', ')).map((each) => [ + each.component, + each.count, + ]), + ).toEqual([ + ['tool', 2], + ['holder', 2], + ]) + }) + + /** + * And two *rows* that ordered one stack are still one thing to buy (Paul, + * 2026-08-31), whatever the stacks that wrote them are called. + */ + it('is one stack where two rows ordered it under different stack ids', () => { + const sheet = order( + order(emptySheet('part-1'), circle, { + toolGuid: 'em', + holderGuid: 'bt30', + assemblyId: 'assembly-1', + }), + pocket, + { toolGuid: 'em', holderGuid: 'bt30', assemblyId: 'assembly-2' }, + ) + const stacks = orderAssemblies([circle, pocket], sheet, names) + + expect(stacks.map((each) => each.key)).toEqual(['em|bt30|']) + expect(stacks[0]?.rows).toEqual(['Through Hole', 'Pocket']) + expect(stacks[0]?.ids).toEqual(['assembly-1', 'assembly-2']) + }) + it('says a stack answers no feature only where every row it is on does', () => { const sheet = order(emptySheet('part-1'), facing, { toolGuid: 'face' }) @@ -186,7 +258,7 @@ describe('what to buy, by component', () => { quantity: 1, total: 1, keys: ['h1', 'h2', 'h3'], - toolGuid: 'em', + ids: ['em'], }, ]) }) diff --git a/apps/catalog/app/shared/order-list.ts b/apps/catalog/app/shared/order-list.ts index 63cf19b..5821ef7 100644 --- a/apps/catalog/app/shared/order-list.ts +++ b/apps/catalog/app/shared/order-list.ts @@ -2,6 +2,7 @@ import { labelOf, sheetKeysOf, type ListItem } from './feature-list' import { choicesFor, clearChoice, + lineId, quantityOf, setQuantity, totalOf, @@ -42,18 +43,20 @@ import { * * A row writes the same line under each of its keys, so the union is what the * row holds and the duplicate is the storage rather than a second order. Keyed - * by tool, which is what the sheet keys a line by. + * by {@link lineId} — the stack that wrote the line — so two assemblies holding + * one cutter are two lines here rather than one seen twice (Paul, 2026-09-11). */ export const linesOf = (sheet: SetupSheet, keys: ReadonlyArray): Array => { - const byTool = new Map() + const byLine = new Map() for (const key of keys) { for (const line of choicesFor(sheet, key)) { - if (!byTool.has(line.toolGuid)) { - byTool.set(line.toolGuid, line) + const at = lineId(line) + if (!byLine.has(at)) { + byLine.set(at, line) } } } - return [...byTool.values()] + return [...byLine.values()] } /** What a row holds on the order list — its keys read together. */ @@ -111,6 +114,14 @@ export interface OrderAssembly { readonly rows: ReadonlyArray /** Every sheet key it is kept under, which is what a control on it writes to. */ readonly keys: ReadonlyArray + /** + * The line ids it stands for, which is what a control on it writes *to*. + * + * A control used to name the tool, which was the sheet's key for a line until + * 2026-09-11; a row holding two stacks of one cutter has two lines of that + * tool, and a control naming the tool would reach both. + */ + readonly ids: ReadonlyArray /** Whether every row that ordered it answers no feature. */ readonly featureless: boolean } @@ -132,19 +143,38 @@ export const orderAssemblies = ( ): Array => { const stacks = new Map< string, - { choice: Choice; rows: Array; keys: Array; featureless: boolean } + { + choice: Choice + rows: Array + keys: Array + ids: Array + featureless: boolean + } >() for (const item of list) { const row = labelOf(item, nameOf) const keys = sheetKeysOf(item) + /* + **The same stack twice on one row is two things to set up** (Paul, + 2026-09-11). Two rows that ordered one stack are still one thing to buy — + that is what the key is for — so what tells them apart is *whose* second + copy it is: the count within this row. The first copy takes the plain key + and merges across rows the way it always has; the second takes a key of + its own, and a second row that also built two meets it there. + */ + const copies = new Map() for (const choice of linesOf(sheet, keys)) { - const key = assemblyKey(choice) + const stack = assemblyKey(choice) + const at = copies.get(stack) ?? 0 + copies.set(stack, at + 1) + const key = at === 0 ? stack : `${stack}#${String(at + 1)}` const had = stacks.get(key) if (had === undefined) { stacks.set(key, { choice, rows: [row], keys: [...keys], + ids: [lineId(choice)], featureless: item.kind === 'assembly', }) continue @@ -157,6 +187,10 @@ export const orderAssemblies = ( had.keys.push(each) } } + const id = lineId(choice) + if (!had.ids.includes(id)) { + had.ids.push(id) + } had.featureless = had.featureless && item.kind === 'assembly' } } @@ -192,8 +226,8 @@ export interface ComponentUse { readonly total: number /** The sheet keys the assembly is kept under, for a control to write to. */ readonly keys: ReadonlyArray - /** The tool the line is keyed by, which is how a control finds it again. */ - readonly toolGuid: string + /** The line ids the assembly is kept under, which is how a control finds it. */ + readonly ids: ReadonlyArray } /** Tools first, then what holds them: a bill is read in the order it is built. */ @@ -235,7 +269,7 @@ export const componentTotals = ( quantity: quantityOf(assembly.choice, component), total: totalOf(assembly.choice), keys: assembly.keys, - toolGuid: assembly.choice.toolGuid, + ids: assembly.ids, }) totals.set(at, had) } @@ -283,7 +317,8 @@ export const setComponentCount = ( const others = rest.reduce((sum, use) => sum + use.quantity * use.total, 0) const forFirst = Math.max(1, Math.round((Math.max(1, Math.floor(wanted)) - others) / first.total)) return first.keys.reduce( - (soFar, key) => setQuantity(soFar, key, first.toolGuid, total.component, forFirst), + (soFar, key) => + first.ids.reduce((held, id) => setQuantity(held, key, id, total.component, forFirst), soFar), sheet, ) } diff --git a/apps/catalog/app/shared/part-interaction.test.ts b/apps/catalog/app/shared/part-interaction.test.ts index 18ce126..9b0f6e3 100644 --- a/apps/catalog/app/shared/part-interaction.test.ts +++ b/apps/catalog/app/shared/part-interaction.test.ts @@ -508,15 +508,6 @@ describe('naming a reading from a list', () => { // Reading is not arming. expect(state.activeDirection).toBeNull() }) - - it('walks the order the list is drawn in', () => { - const state = run( - { type: 'click', pick: face() }, - { type: 'step', order: ['pocket', 'wall'], by: 1 }, - ) - - expect(state.focused).toBe('wall') - }) }) describe('naming a reading', () => { diff --git a/apps/catalog/app/shared/part-interaction.ts b/apps/catalog/app/shared/part-interaction.ts index 7d3e55e..b1d8a26 100644 --- a/apps/catalog/app/shared/part-interaction.ts +++ b/apps/catalog/app/shared/part-interaction.ts @@ -5,7 +5,6 @@ import { focusWithin, pickFace, scopeToDirection, - stepThrough, type SelectionState, } from '@toolpath/part-contracts/selection' import { sameDirection, type PartPick } from '@toolpath/viewer' @@ -23,9 +22,14 @@ import { dropAll, escapeStep, keepAll, preferLargest } from './part-selection' * three assertions each. * * The vocabulary is the DFM application's: `pickFace` decides what a face - * click resolves to, `focusWithin` what naming a reading from a list does, and - * `stepThrough` how the keyboard walks. This module only says what happens - * *around* them — arming, guessing, keeping, escaping. + * click resolves to and `focusWithin` what naming a reading from a list does. + * This module only says what happens *around* them — arming, guessing, + * keeping, escaping. + * + * **Not `stepThrough`, which this application does not use.** A reading is + * chosen by clicking an arrow on the part or by naming one in the list, never + * walked with the keyboard (Paul, 2026-09-11) — the arrows belong to the tool + * list, and `shared/arrow-target.ts` is that rule. */ export interface Interaction { /** What the viewport was asked about, and the readings that answer it. */ @@ -111,8 +115,6 @@ export type InteractionAction = | { readonly type: 'miss' } /** A reading named from a list. */ | { readonly type: 'read'; readonly featureTag: string } - /** The keyboard walking a list, in the order it is drawn. */ - | { readonly type: 'step'; readonly order: ReadonlyArray; readonly by: 1 | -1 } /** A reading ticked or unticked by hand. */ | { readonly type: 'toggle'; readonly featureTag: string } /** Escape, outward one press at a time. */ @@ -460,11 +462,6 @@ export const interactionFor = (part: InteractionPart) => { case 'read': return read(state, action.featureTag) - case 'step': { - const next = stepThrough(action.order, state.focused, action.by) - return next === null ? state : read(state, next) - } - case 'toggle': { /* One feature, in or out — the X beside a hole in the group box takes diff --git a/apps/catalog/app/shared/range-entry.test.ts b/apps/catalog/app/shared/range-entry.test.ts new file mode 100644 index 0000000..9f55bc1 --- /dev/null +++ b/apps/catalog/app/shared/range-entry.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' +import { readEntry, readRange } from './range-entry' + +/** The lower box, reading in millimetres, which is the ordinary case. */ +const lower = (raw: string) => readEntry(raw, 'min', 'millimeters', 'length') +/** The upper box, same. */ +const upper = (raw: string) => readEntry(raw, 'max', 'millimeters', 'length') + +describe('what one box says', () => { + it('reads a bare number as the end of the box it was typed into', () => { + expect(lower('6')).toEqual({ min: 6 }) + expect(upper('6')).toEqual({ max: 6 }) + }) + + it('says nothing about an empty box, or one holding what is not a number', () => { + expect(lower('')).toEqual({}) + expect(lower(' ')).toEqual({}) + expect(lower('carbide')).toEqual({}) + }) + + /** + * The whole of what the operator list used to offer, typed instead — and + * every one of them means the same end whichever box it was typed into, + * which is what lets somebody type without looking at the cursor. + */ + it('takes a comparison, in either box', () => { + expect(lower('>6')).toEqual({ min: 6 }) + expect(lower('>=6')).toEqual({ min: 6 }) + expect(lower('≥6')).toEqual({ min: 6 }) + expect(upper('>6')).toEqual({ min: 6 }) + + expect(upper('<12')).toEqual({ max: 12 }) + expect(upper('<=12')).toEqual({ max: 12 }) + expect(upper('≤12')).toEqual({ max: 12 }) + expect(lower('<12')).toEqual({ max: 12 }) + }) + + it('reads = as both ends, which is the old "exactly"', () => { + expect(lower('=6')).toEqual({ min: 6, max: 6 }) + expect(upper('==6')).toEqual({ min: 6, max: 6 }) + }) + + it('takes a whole range in one box, however it is written', () => { + expect(lower('6-12')).toEqual({ min: 6, max: 12 }) + expect(lower('6..12')).toEqual({ min: 6, max: 12 }) + expect(lower('6 to 12')).toEqual({ min: 6, max: 12 }) + expect(lower('6 – 12')).toEqual({ min: 6, max: 12 }) + expect(upper('6-12')).toEqual({ min: 6, max: 12 }) + }) + + /** No dimension in this catalog is negative, so a leading minus is an open end. */ + it('reads a half-written range as the one end it states', () => { + expect(lower('6-')).toEqual({ min: 6 }) + expect(lower('-12')).toEqual({ max: 12 }) + }) + + it('does not mistake the exponent of one number for a range', () => { + expect(lower('6e-3')).toEqual({ min: 0.006 }) + }) + + it('is not fooled by case or stray space', () => { + expect(lower(' 6 - 12 ')).toEqual({ min: 6, max: 12 }) + expect(lower('6MM')).toEqual({ min: 6 }) + }) +}) + +describe('the unit a number is read in', () => { + it('converts what was typed from the unit on screen', () => { + expect(readEntry('1.25', 'max', 'inches', 'length')).toEqual({ max: 31.75 }) + expect(readEntry('1.25', 'max', 'millimeters', 'length')).toEqual({ max: 1.25 }) + }) + + /** A shop reading in millimetres still knows a tool as a quarter inch. */ + it('lets a number name its own unit, whatever the page is set to', () => { + expect(lower('0.25"')).toEqual({ min: 6.35 }) + expect(lower('0.25in')).toEqual({ min: 6.35 }) + expect(readEntry('6mm', 'min', 'inches', 'length')).toEqual({ min: 6 }) + }) + + it('reads a fraction, and a mixed number', () => { + expect(lower('1/4"')).toEqual({ min: 6.35 }) + expect(readEntry('1/4', 'min', 'inches', 'length')).toEqual({ min: 6.35 }) + expect(readEntry('1 1/2', 'min', 'inches', 'length').min).toBeCloseTo(38.1, 9) + expect(lower('1/0')).toEqual({}) + }) + + it('converts neither a count nor an angle, and takes the degree sign off one', () => { + expect(readEntry('4', 'min', 'inches', 'count')).toEqual({ min: 4 }) + expect(readEntry('118', 'min', 'inches', 'deg')).toEqual({ min: 118 }) + expect(readEntry('118°', 'min', 'millimeters', 'deg')).toEqual({ min: 118 }) + expect(readEntry('3', 'max', 'inches', 'ratio')).toEqual({ max: 3 }) + }) +}) + +describe('what two boxes add up to', () => { + const range = (low: string, high: string) => readRange(low, high, 'millimeters', 'length') + + it('is nothing at all while neither box states an end', () => { + expect(range('', '')).toBeUndefined() + expect(range('carbide', '')).toBeUndefined() + }) + + it('takes one end from each box', () => { + expect(range('6', '12')).toEqual({ min: 6, max: 12 }) + expect(range('6', '')).toEqual({ min: 6 }) + expect(range('', '12')).toEqual({ max: 12 }) + }) + + /** + * The redistribution this control exists for: an end stated is an end meant, + * whichever box stated it, so the cursor never has to be in the right one. + */ + it('lets either box state the end that belongs to the other', () => { + expect(range('<12', '')).toEqual({ max: 12 }) + expect(range('', '>6')).toEqual({ min: 6 }) + expect(range('6-12', '')).toEqual({ min: 6, max: 12 }) + expect(range('', '6-12')).toEqual({ min: 6, max: 12 }) + expect(range('=6', '')).toEqual({ min: 6, max: 6 }) + }) + + /** Each box keeps its own end where both boxes state one. */ + it('gives each box its own end when the two disagree', () => { + expect(range('6-12', '20')).toEqual({ min: 6, max: 20 }) + expect(range('<12', '20')).toEqual({ max: 20 }) + expect(range('3', '>6')).toEqual({ min: 3 }) + }) +}) diff --git a/apps/catalog/app/shared/range-entry.ts b/apps/catalog/app/shared/range-entry.ts new file mode 100644 index 0000000..3356fff --- /dev/null +++ b/apps/catalog/app/shared/range-entry.ts @@ -0,0 +1,199 @@ +import { convertLength, type UnitSystem } from '@toolpath/tool-support' +import type { RangeKind } from './column-filters' + +/** + * What somebody typed into a number filter, read as the ends it states. + * + * **The operator is typed, not chosen** (Paul, 2026-09-11: "it's weird showing + * the drop down then having to enter text"). Narrowing Diameter to "at least + * 6" used to cost four presses before the first keystroke: the funnel, the + * operator list, the operator, and the box that only then existed. The list is + * gone and the two ends are always on screen, so the shortest way to ask is to + * type — and everything the list used to offer is still sayable, in the + * shorthand a shop already writes on a traveller: `6-12`, `>6`, `<12`, `=6`. + * + * A box also reads what belongs to the *other* box. `<12` typed into the lower + * one states a maximum, and `6-12` states both; {@link readRange} is what puts + * each number where it belongs, and the control writes the boxes back out once + * the entry is finished. That is why this states the ends it found rather than + * a value: which end a number is, is the question. + * + * Every number is returned in millimetres, the basis the dataset is stored in, + * exactly as `ToolQuery.ranges` holds it. + */ +export interface Entry { + readonly min?: number + readonly max?: number +} + +/** Which end a bare number is, which is simply the box it was typed into. */ +export type Side = 'min' | 'max' + +/** An entry with nothing said about an end left out rather than set undefined. */ +const ends = (min: number | undefined, max: number | undefined): Entry => ({ + ...(min === undefined ? {} : { min }), + ...(max === undefined ? {} : { max }), +}) + +/** + * The unit a number names for itself, which overrules the one being read in. + * + * A shop reading in millimetres still knows a tool as a quarter inch, and + * `1/4"` is how that gets typed. The suffix is the whole of the override: with + * none, the number means whatever the page is set to. + */ +const INCHES = /(?:"|”|in|inch|inches)$/ +const MILLIMETRES = /(?:mm|millimeters?|millimetres?)$/ + +/** Degrees, which are degrees in either unit and convert to nothing. */ +const DEGREES = /(?:°|deg|degs|degrees?)$/ + +/** + * A number, a fraction, or a mixed number. + * + * `1/4` and `1 1/2` are drill sizes as a machinist says them, and a box that + * only took decimals made somebody divide in their head to ask about a tool + * the vendor names in eighths. + */ +const MIXED = /^(\d+)\s+(\d+)\s*\/\s*(\d+)$/ +const FRACTION = /^(\d+)\s*\/\s*(\d+)$/ + +const readMagnitude = (text: string): number | undefined => { + if (text === '') { + return undefined + } + const mixed = MIXED.exec(text) + if (mixed !== null) { + const over = Number(mixed[3]) + return over === 0 ? undefined : Number(mixed[1]) + Number(mixed[2]) / over + } + const fraction = FRACTION.exec(text) + if (fraction !== null) { + const over = Number(fraction[2]) + return over === 0 ? undefined : Number(fraction[1]) / over + } + const plain = Number(text) + // `Number('')` is nought, which is why the empty text is turned away above. + return Number.isFinite(plain) ? plain : undefined +} + +/** One number in the dataset's own millimetres, or nothing while it is not one. */ +const readNumber = (raw: string, unit: UnitSystem, kind: RangeKind): number | undefined => { + const text = raw.trim() + if (kind !== 'length') { + // A count converts to nothing and an angle is degrees in either unit; the + // suffix is allowed and dropped so that `118°` is the number it looks like. + return readMagnitude(text.replace(DEGREES, '').trim()) + } + if (INCHES.test(text)) { + const value = readMagnitude(text.replace(INCHES, '').trim()) + return value === undefined ? undefined : convertLength(value, 'inches', 'millimeters') + } + if (MILLIMETRES.test(text)) { + return readMagnitude(text.replace(MILLIMETRES, '').trim()) + } + const value = readMagnitude(text) + return value === undefined ? undefined : convertLength(value, unit, 'millimeters') +} + +/** + * The marks a range is written with, in the order they are looked for. + * + * A bare `-` is last because it is also the leading mark of `-12`, which is + * how "up to 12" gets typed: no dimension in this catalog is negative, so a + * minus at the front is an open lower end rather than a sign. + */ +const SEPARATORS: ReadonlyArray = ['..', '–', '—', ' to ', '-'] + +const splitRange = (text: string): readonly [string, string] | null => { + for (const mark of SEPARATORS) { + const at = text.indexOf(mark) + if (at === -1) { + continue + } + // `6e-3` is one number rather than a range from `6e` to `3`. + if (mark === '-' && at > 0 && text[at - 1] === 'e') { + continue + } + return [text.slice(0, at).trim(), text.slice(at + mark.length).trim()] + } + return null +} + +/** + * The comparisons, longest mark first so `>=` is never read as `>` and a + * stray `=`. + */ +const COMPARISONS: ReadonlyArray<{ readonly mark: string; readonly states: Side | 'both' }> = [ + { mark: '>=', states: 'min' }, + { mark: '=>', states: 'min' }, + { mark: '≥', states: 'min' }, + { mark: '>', states: 'min' }, + { mark: '<=', states: 'max' }, + { mark: '=<', states: 'max' }, + { mark: '≤', states: 'max' }, + { mark: '<', states: 'max' }, + { mark: '==', states: 'both' }, + { mark: '=', states: 'both' }, +] + +/** What one box says, read as the box it was typed into. */ +export const readEntry = (raw: string, side: Side, unit: UnitSystem, kind: RangeKind): Entry => { + const text = raw.trim().toLowerCase() + if (text === '') { + return {} + } + + const split = splitRange(text) + if (split !== null) { + return ends(readNumber(split[0], unit, kind), readNumber(split[1], unit, kind)) + } + + for (const each of COMPARISONS) { + if (!text.startsWith(each.mark)) { + continue + } + const value = readNumber(text.slice(each.mark.length), unit, kind) + if (value === undefined) { + return {} + } + if (each.states === 'both') { + return { min: value, max: value } + } + return each.states === 'min' ? { min: value } : { max: value } + } + + const value = readNumber(text, unit, kind) + if (value === undefined) { + return {} + } + return side === 'min' ? { min: value } : { max: value } +} + +/** + * The bound two boxes add up to. + * + * **An end stated is an end meant, whichever box stated it.** The lower box + * answers for the minimum and the upper for the maximum, and either of them + * answers for the end the other one left unsaid — which is what lets `<12` be + * typed wherever the cursor happens to be and still land on the maximum. + * + * Nothing said at either end is no filter at all, rather than a bound holding + * two undefined ends: `{}` reads as "Any" everywhere downstream, and returning + * one would leave a column's funnel filled by a filter asking nothing. + */ +export const readRange = ( + lower: string, + upper: string, + unit: UnitSystem, + kind: RangeKind, +): Entry | undefined => { + const low = readEntry(lower, 'min', unit, kind) + const high = readEntry(upper, 'max', unit, kind) + const min = low.min ?? high.min + const max = high.max ?? low.max + if (min === undefined && max === undefined) { + return undefined + } + return ends(min, max) +} diff --git a/apps/catalog/app/shared/setup-sheet.test.ts b/apps/catalog/app/shared/setup-sheet.test.ts index 7e328c3..11e3023 100644 --- a/apps/catalog/app/shared/setup-sheet.test.ts +++ b/apps/catalog/app/shared/setup-sheet.test.ts @@ -5,9 +5,11 @@ import { chosenFor, clearChoice, emptySheet, + lineId, quantityOf, readSheet, removeChoice, + removeTool, setQuantity, setTotal, totalOf, @@ -36,6 +38,76 @@ describe('the setup sheet', () => { ]) }) + /** + * **A line is the stack that wrote it** (Paul, 2026-09-11: "when I have two + * (or more) tool assemblies on a feature or group, both need to be shown in + * the order list"). Keyed by its tool, one feature held one line per cutter, + * so the second stack given that cutter wrote over the first and took its + * holder with it. + */ + it('keeps two stacks of one cutter as two lines', () => { + const sheet = addChoice( + addChoice(emptySheet('part-1'), 'pocket-1', { + toolGuid: 't', + holderGuid: 'a', + assemblyId: 'assembly-1', + }), + 'pocket-1', + { toolGuid: 't', holderGuid: 'b', assemblyId: 'assembly-2' }, + ) + + expect(choicesFor(sheet, 'pocket-1')).toEqual([ + { toolGuid: 't', holderGuid: 'a', assemblyId: 'assembly-1' }, + { toolGuid: 't', holderGuid: 'b', assemblyId: 'assembly-2' }, + ]) + }) + + it('replaces the line one stack already wrote, where that stack writes again', () => { + const sheet = addChoice( + addChoice(emptySheet('part-1'), 'pocket-1', { + toolGuid: 't', + holderGuid: 'a', + assemblyId: 'assembly-1', + }), + 'pocket-1', + { toolGuid: 'u', holderGuid: 'b', assemblyId: 'assembly-1' }, + ) + + expect(choicesFor(sheet, 'pocket-1')).toEqual([ + { toolGuid: 'u', holderGuid: 'b', assemblyId: 'assembly-1' }, + ]) + }) + + /** A line nothing built out of a tree is its tool, exactly as it always was. */ + it('names a line by its tool where no stack wrote it', () => { + expect(lineId({ toolGuid: 't' })).toBe('t') + expect(lineId({ toolGuid: 't', assemblyId: 'assembly-2' })).toBe('assembly-2') + }) + + it('takes one stack off and leaves the other standing', () => { + const sheet = addChoice( + addChoice(emptySheet('part-1'), 'pocket-1', { toolGuid: 't', assemblyId: 'assembly-1' }), + 'pocket-1', + { toolGuid: 't', assemblyId: 'assembly-2' }, + ) + + expect(removeChoice(sheet, 'pocket-1', 'assembly-1')).toEqual({ + partId: 'part-1', + choices: { 'pocket-1': [{ toolGuid: 't', assemblyId: 'assembly-2' }] }, + }) + }) + + /** What the tool panel's *Remove* means: this tool, however many stacks hold it. */ + it('takes a tool off every stack of the feature holding it', () => { + const sheet = addChoice( + addChoice(emptySheet('part-1'), 'pocket-1', { toolGuid: 't', assemblyId: 'assembly-1' }), + 'pocket-1', + { toolGuid: 't', assemblyId: 'assembly-2' }, + ) + + expect(choicesFor(removeTool(sheet, 'pocket-1', 't'), 'pocket-1')).toEqual([]) + }) + it('removes a cleared feature rather than leaving a null behind', () => { const sheet = clearChoice( addChoice(emptySheet('part-1'), 'pocket-1', { toolGuid: 't' }), diff --git a/apps/catalog/app/shared/setup-sheet.ts b/apps/catalog/app/shared/setup-sheet.ts index fa05119..2786e6c 100644 --- a/apps/catalog/app/shared/setup-sheet.ts +++ b/apps/catalog/app/shared/setup-sheet.ts @@ -16,6 +16,27 @@ import { useCallback, useEffect, useState } from 'react' */ export interface Choice { readonly toolGuid: string + /** + * Which stack of the tree this line is, where a stack wrote it. + * + * **A line is an assembly, not a tool** (Paul, 2026-09-11: "when I have two + * (or more) tool assemblies on a feature or group, both need to be shown in + * the order list. Only the first is being shown right now"). The sheet keyed + * a line by its tool, so one feature could hold exactly one line per cutter — + * and a second assembly given that cutter was written *over* the first, + * taking its holder with it. What reached the order list was one row where a + * shop had built two, and the count on it (`total`) was the sheet saying + * "twice" about a thing it had only one description of. + * + * The stack's id is the identity instead: two stacks are two lines whatever + * is in them, and the holder each was given survives. {@link lineId} is the + * one reading of it. + * + * Absent where nothing built the line out of a tree — the tool panel writes + * one, and every sheet saved before 2026-09-11 is written that way — and the + * tool is its identity then, exactly as it was. + */ + readonly assemblyId?: string readonly holderGuid?: string readonly colletGuid?: string /** In millimetres; absent is the application's default. */ @@ -42,6 +63,15 @@ export interface Choice { readonly total?: number } +/** + * What a line *is*, on the feature it is kept under. + * + * The stack that wrote it, or the tool where nothing did — so a line the tool + * panel wrote is found by its tool the way it always was, and two stacks are + * two lines even when they hold the same cutter. + */ +export const lineId = (choice: Choice): string => choice.assemblyId ?? choice.toolGuid + /** The three things a line can hold, each bought on its own. */ export type Component = 'tool' | 'holder' | 'collet' @@ -73,33 +103,44 @@ export const emptySheet = (partId: string): SetupSheet => ({ partId, choices: {} export const choicesFor = (sheet: SetupSheet, featureTag: string): ReadonlyArray => sheet.choices[featureTag] ?? [] -/** The line for one tool under one feature, if there is one. */ +/** + * The line for one tool under one feature, if there is one. + * + * By the *tool*, which is what the tool panel asks — it knows a catalog number + * and nothing about the tree. Where a row holds two stacks of one cutter this + * finds the first of them; {@link lineUnder} is the reading that names one. + */ export const chosenFor = (sheet: SetupSheet, featureTag: string, toolGuid: string): Choice | null => choicesFor(sheet, featureTag).find((each) => each.toolGuid === toolGuid) ?? null +/** One line named by what it is: {@link lineId}, so two stacks are two lines. */ +export const lineUnder = (sheet: SetupSheet, featureTag: string, id: string): Choice | null => + choicesFor(sheet, featureTag).find((each) => lineId(each) === id) ?? null + /** * Keep an assembly for a feature. * - * A tool already kept for that feature is replaced where it stands rather than - * added twice: choosing a different holder for the same cutter is a correction, - * not a second line. + * A line already kept under the same {@link lineId} is replaced where it stands + * rather than added twice: choosing a different holder for the same stack is a + * correction, not a second line. Two *stacks* holding one cutter have two ids, + * so they are two lines — which is the whole of Paul's 2026-09-11 defect. */ export const addChoice = (sheet: SetupSheet, featureTag: string, choice: Choice): SetupSheet => { const kept = choicesFor(sheet, featureTag) - const at = kept.findIndex((each) => each.toolGuid === choice.toolGuid) + const at = kept.findIndex((each) => lineId(each) === lineId(choice)) const next = at === -1 ? [...kept, choice] : kept.map((each, index) => (index === at ? choice : each)) return { ...sheet, choices: { ...sheet.choices, [featureTag]: next } } } -/** One line changed in place, by the tool it is for. */ +/** One line changed in place, by what it is — {@link lineId}. */ const withChoice = ( sheet: SetupSheet, featureTag: string, - toolGuid: string, + id: string, change: (choice: Choice) => Choice, ): SetupSheet => { - const had = chosenFor(sheet, featureTag, toolGuid) + const had = lineUnder(sheet, featureTag, id) return had === null ? sheet : addChoice(sheet, featureTag, change(had)) } @@ -111,10 +152,10 @@ const withChoice = ( export const setTotal = ( sheet: SetupSheet, featureTag: string, - toolGuid: string, + id: string, total: number, ): SetupSheet => - withChoice(sheet, featureTag, toolGuid, (choice) => { + withChoice(sheet, featureTag, id, (choice) => { const wanted = Math.max(1, Math.floor(total)) const { total: _was, ...rest } = choice return wanted === 1 ? rest : { ...rest, total: wanted } @@ -129,11 +170,11 @@ export const setTotal = ( export const setQuantity = ( sheet: SetupSheet, featureTag: string, - toolGuid: string, + id: string, component: Component, quantity: number, ): SetupSheet => - withChoice(sheet, featureTag, toolGuid, (choice) => { + withChoice(sheet, featureTag, id, (choice) => { const wanted = Math.max(1, Math.floor(quantity)) const quantities = { ...choice.quantities } if (wanted === 1) { @@ -167,12 +208,31 @@ export const clearChoice = (sheet: SetupSheet, featureTag: string): SetupSheet = * the one to copy: they are all the same tool, and the earliest is the one * whose holder was thought about first. */ -/** Un-choose one tool, leaving the others kept for that feature. */ -export const removeChoice = ( - sheet: SetupSheet, - featureTag: string, - toolGuid: string, -): SetupSheet => { +/** + * Un-choose one line, leaving the others kept for that feature. + * + * By {@link lineId}, so taking one of two stacks of a cutter off leaves the + * other standing. {@link removeTool} is the other question — *this tool, off + * this feature* — which is what the tool panel asks. + */ +export const removeChoice = (sheet: SetupSheet, featureTag: string, id: string): SetupSheet => { + const kept = choicesFor(sheet, featureTag).filter((each) => lineId(each) !== id) + if (kept.length === choicesFor(sheet, featureTag).length) { + return sheet + } + return kept.length === 0 + ? clearChoice(sheet, featureTag) + : { ...sheet, choices: { ...sheet.choices, [featureTag]: kept } } +} + +/** + * Un-choose a tool, however many stacks of this feature were holding it. + * + * What the tool panel's *Remove* means: it knows a catalog number and nothing + * about the tree, so leaving one of two stacks of that cutter behind would be + * the press doing half of what it says. + */ +export const removeTool = (sheet: SetupSheet, featureTag: string, toolGuid: string): SetupSheet => { const kept = choicesFor(sheet, featureTag).filter((each) => each.toolGuid !== toolGuid) if (kept.length === choicesFor(sheet, featureTag).length) { return sheet diff --git a/apps/catalog/app/shared/type.ts b/apps/catalog/app/shared/type.ts new file mode 100644 index 0000000..46afd3a --- /dev/null +++ b/apps/catalog/app/shared/type.ts @@ -0,0 +1,94 @@ +/** + * The type system, as the three recipes that were being retyped. + * + * The **sizes** are settled elsewhere and by a sensor: four of them, pinned by + * `app/type-scale.test.ts`. What had drifted is everything else — the face, the + * weight and the caps treatment — because each of those was typed out at every + * site rather than named once. On 2026-09-11 the section label existed in six + * versions, a catalog number was a title at three different weights, and the + * heading face the application downloads was rendering nowhere at all. + * + * So the rules are: + * + * - **Three faces, by role.** Nunito (`font-display`) is headings; Open Sans is + * everything else and needs no class; Roboto Mono (`font-mono`) is an + * identifier or a measured value and nothing else. + * - **Three weights.** `font-bold` belongs to a display heading, `font-semibold` + * is emphasis in the body face, and 400 is the rest. `font-normal` is a reset + * for a `

    Everything kept for this part
    Tool
    `, not a choice. + * - **Mono never takes a weight.** `root.tsx` asks Google for Roboto Mono at 400 + * and nothing else, so `font-mono font-bold` is a weight the browser + * *synthesises* — a smeared outline rather than a bold cut. Size and colour + * 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 + * pins the scale — a rule about type without a check on it is how this drifted. + */ + +/** + * A heading: the display face, the top of the scale, the brightest ink. + * + * The application's `

    `s and `

    `s. Nunito is loaded for exactly this and + * was reaching none of them, because the class every one of them carried was + * `font-heading` — which is not a token `@toolpath/ui`'s theme defines, so it + * compiled to nothing and each heading quietly fell back to body copy. + */ +export const HEADING = 'font-display text-lg font-bold text-zinc-100' + +/** + * A section label: the small capitals over a group of controls. + * + * Twenty-seven of them, in six recipes. This is the one the majority already + * wore — the others were a half-step brighter, a half-step heavier, or a step + * wider, none of which was saying anything the other twenty-six were not. + */ +export const SECTION_LABEL = 'text-2xs font-semibold tracking-wide text-zinc-500 uppercase' + +/** + * The face a table is set in — **one, for the whole grid**. + * + * A row used to change font mid-way and change back: the catalog number was + * mono, the vendor, family and type were body, and the six measured columns + * were mono again, so reading across one tool crossed the seam five times + * (Paul, 2026-09-11: "it's still back and forth in the table — I think choose + * one font in the tables"). + * + * **The body face, not mono** (Paul, 2026-09-11: "this is too techy and doesn't + * fit in"). A grid of part numbers and measurements is the one place mono is + * traditionally reached for, and setting the whole table in it made the table + * read as a terminal window bolted onto an application that is Open Sans + * everywhere else. The catalog is a thing a shop reads, not a log. + * + * **`tabular-nums` holds on to what mono was actually for here.** The reason + * those six columns were mono is that a column of figures has to line up. + * Open Sans draws its digits on one advance width already — measured on + * 2026-09-11, `1111111111` and `0000000000` set to the same 91.5px with the + * feature on *or* off — so this buys nothing today and guarantees it tomorrow: + * it is the alignment stated as a requirement rather than inherited from a + * property of one typeface, and the next face swapped in here cannot quietly + * take it away. + * + * It is declared **once, on the container**, and nothing inside a table names a + * face of its own — that is what makes one font a fact about the grid rather + * than fifteen cells agreeing. `type-scale.test.ts` § "set a table in one face" + * is the check, and it reads the table components for any `font-*` family class + * at all. + */ +export const TABLE_FACE = 'font-body tabular-nums' + +/** + * The ink a table's rows are set in — **one, for every column**. + * + * The face was not the only thing changing across a row (Paul, 2026-09-11: "it + * needs to be consistent, some of those columns are different text than + * others"). Four greys were in play at once — the catalog number at `zinc-100`, + * the vendor and family at `zinc-400`, the type and the measurements at + * `zinc-300` — so half the columns read as emphasised and the other half as + * secondary, on a grid where every column is equally the answer. + * + * Colour in a cell now means something happened to that value: an amber or red + * mark against a rule, or `zinc-600` where there is no value at all. Nothing is + * dimmed merely for being prose. + */ +export const TABLE_INK = 'text-zinc-300' diff --git a/apps/catalog/app/type-scale.test.ts b/apps/catalog/app/type-scale.test.ts index bafde34..62a9ebb 100644 --- a/apps/catalog/app/type-scale.test.ts +++ b/apps/catalog/app/type-scale.test.ts @@ -24,8 +24,95 @@ const SCALE = ['text-2xs', 'text-xs', 'text-sm', 'text-lg'] /** Any Tailwind text-size class, including an arbitrary `text-[…]` value. */ const SIZE = /\btext-(?:\[[^\]]+\]|(?:\d?xs|sm|base|lg|\d?xl)\b)/g +/** + * The faces, by role — and only the three `@toolpath/ui`'s theme actually + * defines (`theme.css`: `--font-body`, `--font-display`, `--font-mono`). + * + * `font-heading` was on all four of this application's headings and is not a + * token, so it compiled to nothing: `root.tsx` asked Google for Nunito, the + * browser downloaded it, and not one character was ever set in it. A font class + * naming a variable that does not exist fails silently, which is why this is a + * check rather than a convention. + */ +const FACES = ['font-body', 'font-display', 'font-mono', 'font-sans'] + +/** Any Tailwind font-family or font-weight class. */ +const FACE = /\bfont-(?!\d)[a-z]+\b/g + +/** + * Three weights, for three jobs. + * + * `font-bold` is a display heading; `font-semibold` is emphasis in the body + * face; 400 is everything else, and `font-normal` says so only where a `

    ` + * or an inherited weight has to be undone. `font-medium` was a fourth doing + * `font-semibold`'s job in two places. + */ +const WEIGHTS = ['font-bold', 'font-semibold', 'font-normal'] + +/** A class list, as written — the string between one pair of quotes. */ +const QUOTED = /(['"`])((?:[^'"`\\]|\\.)*?)\1/g + +/** + * One class *expression*, not one string. + * + * `cn('text-2xs … tracking-wide', chosen ? 'uppercase' : '')` is one set of + * classes on one element, and reading its strings separately says the capitals + * carry no tracking when the element plainly has some. So a `cn(…)` call is a + * unit, and only the strings outside every such call are units of their own. + */ +const expressions = (source: string): Array => { + const calls: Array = [] + const spans: Array<[number, number]> = [] + for (let at = source.indexOf('cn('); at !== -1; at = source.indexOf('cn(', at + 1)) { + if (/[A-Za-z0-9_$]/.test(source[at - 1] ?? '')) { + continue + } + let depth = 0 + let end = at + 2 + for (; end < source.length; end += 1) { + const char = source[end] + if (char === '(') { + depth += 1 + } else if (char === ')') { + depth -= 1 + if (depth === 0) { + break + } + } + } + calls.push(source.slice(at, end + 1)) + spans.push([at, end + 1]) + } + + let rest = '' + let cursor = 0 + for (const [start, end] of spans) { + if (start >= cursor) { + rest += source.slice(cursor, start) + cursor = end + } + } + rest += source.slice(cursor) + + return [...calls, ...[...rest.matchAll(QUOTED)].map(([, , classes]) => classes ?? '')] +} + const appDir = 'app' +/** + * The file with its comments taken out. + * + * These checks read source as text, and a note *about* a class is not a use of + * one — `shared/type.ts` documents why `font-heading` and a bold mono are + * wrong, and without this that documentation is itself the failure. + */ +const code = (source: string): string => + source + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .split('\n') + .filter((line) => !/^\s*(\/\/|\*)/.test(line)) + .join('\n') + const sourceFiles = (dir: string): Array => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const path = join(dir, entry.name) @@ -38,7 +125,7 @@ const sourceFiles = (dir: string): Array => describe('the type scale', () => { it(`is only ${SCALE.join(', ')}`, () => { const stray = sourceFiles(appDir).flatMap((file) => - (readFileSync(file, 'utf8').match(SIZE) ?? []) + (code(readFileSync(file, 'utf8')).match(SIZE) ?? []) .filter((size) => !SCALE.includes(size)) .map((size) => `${file}: ${size}`), ) @@ -49,3 +136,75 @@ describe('the type scale', () => { ).toEqual([]) }) }) + +describe('the faces and weights', () => { + it(`are only ${[...FACES, ...WEIGHTS].join(', ')}`, () => { + const stray = sourceFiles(appDir).flatMap((file) => + (code(readFileSync(file, 'utf8')).match(FACE) ?? []) + .filter((name) => !FACES.includes(name) && !WEIGHTS.includes(name)) + .map((name) => `${file}: ${name}`), + ) + + expect( + [...new Set(stray)].sort(), + 'A font class that is not one of the three faces or the three weights. `font-heading` is the one that compiles to nothing.', + ).toEqual([]) + }) + + it('never puts a weight on the mono face', () => { + const faked = sourceFiles(appDir).flatMap((file) => + expressions(code(readFileSync(file, 'utf8'))) + .filter( + (classes) => + /\bfont-mono\b/.test(classes) && /\bfont-(bold|semibold|medium)\b/.test(classes), + ) + .map((classes) => `${file}: ${classes}`), + ) + + expect( + faked, + 'Roboto Mono is requested at 400 and nothing else (`root.tsx`), so a weight on it is one the browser synthesises. Use size and colour, or add the weight to the font request first.', + ).toEqual([]) + }) + + /** + * The components that draw a grid, and are therefore set in one face. + * + * Each declares `TABLE_FACE` on its container and nothing inside it names a + * face at all, so a row cannot change font between one column and the next. + */ + const TABLES = [ + 'app/components/part-tool-table.tsx', + 'app/components/component-table.tsx', + 'app/components/component-tally.tsx', + 'app/routes/order-list.tsx', + ] + + it('sets a table in one face', () => { + const named = TABLES.flatMap((file) => { + const source = code(readFileSync(file, 'utf8')) + return expressions(source) + .filter((classes) => /\bfont-(mono|sans|body|display)\b/.test(classes)) + .map((classes) => `${file}: ${classes}`) + }) + + expect( + named, + 'A table is one face, declared once as `TABLE_FACE` on its container. A `font-*` class on a cell is the row changing font halfway across.', + ).toEqual([]) + }) + + it('sets small capitals one way', () => { + const wrong = sourceFiles(appDir).flatMap((file) => + expressions(code(readFileSync(file, 'utf8'))) + .filter((classes) => /\buppercase\b/.test(classes)) + .filter((classes) => !/\btext-2xs\b/.test(classes) || !/\btracking-wide\b/.test(classes)) + .map((classes) => `${file}: ${classes}`), + ) + + expect( + wrong, + 'Capitals are `text-2xs tracking-wide` — `SECTION_LABEL` in `shared/type.ts` is the whole recipe. A second tracking is a second kind of label.', + ).toEqual([]) + }) +}) diff --git a/apps/catalog/tests/on-the-part.spec.ts b/apps/catalog/tests/on-the-part.spec.ts index 68cc9be..ba914d2 100644 --- a/apps/catalog/tests/on-the-part.spec.ts +++ b/apps/catalog/tests/on-the-part.spec.ts @@ -275,12 +275,46 @@ test('a click on a face names its reading, and lists the tools that cut it', asy await ready(page) await expect(field(page)).toBeVisible() - // Named twice, and both are the reading's own: the stack it opened says what - // it is for, and the list under it is headed by it. - await expect(page.locator('[data-assembly-tree]').getByText(/^Cuts the /)).toBeVisible() + // Named where it is over something otherwise unlabelled: the list under the + // part. The tree's own copy came out on 2026-09-11 — the card carrying it + // already names the reading at the top of the same box. + await expect(page.locator('[data-assembly-tree]').getByText(/^Cuts the /)).toHaveCount(0) await expect(page.locator('[data-list-chrome]').getByText(/^Cuts the /)).toBeVisible() }) +/** + * **Three ways out, and none of them costs the box** (Paul, 2026-09-11: + * "clicking on the i icon again, hitting the x, or hitting escape should close + * feature details — but keep the feature dialog open and as is"). + * + * The `i` opened the record and then did nothing at all, and Escape belonged to + * the page: one press put the record away *and* dropped the reading behind it, + * so what somebody came back to was an empty corner. The record is the newest + * thing on the screen, so it takes the press — `use-escape.ts` is the stack and + * `part-viewer.tsx` pushes the layer. + */ +test('the feature record closes three ways, and the box behind it stays', async ({ page }) => { + await ready(page) + + const open = page.getByRole('button', { name: /^What Toolpath measured about / }) + const record = page.getByText('Feature details') + + for (const close of [ + async () => open.click(), + async () => page.getByRole('button', { name: 'Back to the part' }).click(), + async () => page.keyboard.press('Escape'), + ]) { + await open.click() + await expect(record).toBeVisible() + + await close() + await expect(record).toHaveCount(0) + // The box it was opened from is untouched: same reading, same stack. + await expect(field(page)).toBeVisible() + await expect(page.locator('[data-assembly-tree]')).toBeVisible() + } +}) + /** * The Engine reports a feature per way up, so one face has several. * @@ -616,25 +650,48 @@ test('keeps a column filter open for a second value, and closes it on a press ou }) /** - * **A dropdown opened from inside the filter is inside it.** The kit draws a - * `Combobox` popover in a portal of its own, so choosing an operator read as a - * press on the page and shut the filter before the box to type in was drawn — - * and every keystroke after it rebuilt the header under the box. A number - * column could not be narrowed at all. + * **Open it and type: that is the whole gesture** (Paul, 2026-09-11: "it's + * weird showing the drop down then having to enter text"). + * + * Narrowing a number column used to cost four presses before the first + * keystroke — the funnel, an operator list, an operator, then the box that only + * then existed — and each of those presses was a defect waiting: the operator + * list was drawn in a portal of the kit's own, so choosing from it read as a + * press on the page and shut the filter before the box appeared, and every + * keystroke after it rebuilt the header under the box. Both ends are on screen + * from the start now, and the caret is in the lower one. */ test('a number column is typed into without the filter shutting', async ({ page }) => { await page.getByRole('button', { name: 'Filter by Diameter', exact: true }).click() - await page.getByRole('combobox', { name: 'How to compare Diameter' }).click() - await page.getByRole('option', { name: '≥ at least' }).click() - const box = page.getByRole('textbox', { name: 'Diameter — value' }) - await box.click() + const box = page.getByRole('textbox', { name: 'Diameter — min' }) + await expect(box).toBeFocused() await page.keyboard.type('12') await expect(box).toHaveValue('12') await expect(page).toHaveURL(/min\.DC=12/) }) +/** + * **An end stated is an end meant, whichever box stated it.** `<12` belongs in + * the upper box and gets typed wherever the caret happens to be, so the lower + * box reads it and `shared/range-entry.ts` puts it where it goes — which is + * what replaced choosing "≤ at most" off a list. The boxes are written back out + * in longhand once the entry is finished, so what was asked stays legible. + */ +test('an upper bound typed into the lower box lands on the upper one', async ({ page }) => { + await page.getByRole('button', { name: 'Filter by Diameter', exact: true }).click() + + await page.getByRole('textbox', { name: 'Diameter — min' }).click() + await page.keyboard.type('<12') + await expect(page).toHaveURL(/max\.DC=12/) + + await page.getByRole('textbox', { name: 'Diameter — max' }).click() + + await expect(page.getByRole('textbox', { name: 'Diameter — min' })).toHaveValue('') + await expect(page.getByRole('textbox', { name: 'Diameter — max' })).toHaveValue('12.00') +}) + /** * **The bar floats over the bottom of the part** (Paul, 2026-09-11: "they * should float in the 3d viewer above the table"). It was the table card's @@ -829,7 +886,6 @@ test('a click previews and asks, and the list answers for itself', async ({ page // a second toolbar row above the table. await page.getByRole('button', { name: 'Filter by Catalog number', exact: true }).click() await expect(page.getByRole('searchbox', { name: 'Search by catalog number' })).toBeVisible() - await expect(page.getByText(/^Cuts the /)).toBeVisible() }) /** @@ -896,8 +952,6 @@ test('adds a tool assembly with no feature behind it', async ({ page }) => { // and nothing has been ordered. const tree = page.locator('[data-assembly-tree]') await expect(tree).toBeVisible() - await expect(tree.getByText('New tool assembly — no feature')).toBeVisible() - await expect(tree.getByText('not on the list yet')).toBeVisible() const list = await orderList(page) await expect(list).toBeHidden() @@ -1640,7 +1694,12 @@ test('presses the tool under a row for everything that fits it', async ({ page } await list.getByRole('button', { name: / for / }).click() - await expect(page.getByText('Cuts every feature in the group')).toBeVisible() + /* + The box is open on the group again. It used to be read off the tree's own + heading, which came out on 2026-09-11 — the card above it names what is + being asked, and the bar under the part heads the list. + */ + await expect(page.locator('[data-assembly-tree]')).toBeVisible() await page.getByRole('button', { name: 'Filter by Catalog number', exact: true }).click() await expect(page.getByRole('searchbox', { name: 'Search by catalog number' })).toBeVisible() }) @@ -2087,9 +2146,7 @@ test.describe('the tool assembly tree', () => { .click() await page.getByRole('button', { name: 'Filter by Diameter', exact: true }).click() - await page.getByRole('combobox', { name: 'How to compare Diameter' }).click() - await page.getByRole('option', { name: '≥ at least' }).click() - const box = page.getByRole('textbox', { name: 'Diameter — value' }) + const box = page.getByRole('textbox', { name: 'Diameter — min' }) await box.click() await page.keyboard.type('1') @@ -2116,9 +2173,7 @@ test.describe('the tool assembly tree', () => { .click() await page.getByRole('button', { name: 'Filter by Diameter', exact: true }).click() - await page.getByRole('combobox', { name: 'How to compare Diameter' }).click() - await page.getByRole('option', { name: '≥ at least' }).click() - await page.getByRole('textbox', { name: 'Diameter — value' }).click() + await page.getByRole('textbox', { name: 'Diameter — min' }).click() await page.keyboard.type('4') await expect(page).toHaveURL(/min\.DC=4/) @@ -2147,6 +2202,49 @@ test.describe('the tool assembly tree', () => { * so widening one asked for precisely the tools the rules then removed — and * the table answered a deliberate question with nothing. */ + /** + * **An empty box means unbounded, and unbounded includes the rules** (Paul, + * 2026-09-11: "when I remove a value for min or max, it is not showing tools + * down to the smallest or largest tool in the library with a feature or group + * active"). + * + * Clearing took the *filter* off and left the `must` rows that wrote it + * judging every tool exactly as before — and the tick that sets those aside + * was offered only while a number stood in the box, so each half of "show me + * everything" was behind the other. `releasedBounds` in `shared/filter.ts` is + * the rule; this is both halves of it on screen at once, since the dialog + * saying so and the list widening are the same act. + */ + test('sets a column of rules aside when its number is taken away', async ({ page }) => { + await ready(page) + await page + .locator('[data-assembly-tree]') + .getByRole('button', { name: /^TOOL for / }) + .click() + + // What the geometry asked for, before anything is changed. + await expect(page).toHaveURL(/min\.LCF=50\.9/) + + await page.getByRole('button', { name: 'Filter by Flute length', exact: true }).click() + const dialog = page.getByRole('group', { name: 'Flute length' }) + await expect(dialog.getByRole('note')).toBeHidden() + + await page.getByRole('textbox', { name: 'Flute length — min' }).fill('') + + // The filter is gone from the question... + await expect(page).not.toHaveURL(/min\.LCF=/) + // ...and so are the rules that wrote it, with no second press: the dialog + // says so, and it does not also say that changing it changes nothing. + const note = dialog.getByRole('note') + await expect(note).toContainText('flute length rules are set aside') + await expect(note).not.toContainText('does not change the rules') + // There is nothing left to confirm, so the tick is only a way out. + await expect( + dialog.getByRole('button', { name: 'Keep this flute length and override its rules' }), + ).toBeHidden() + await expect(page.getByText(/rules turn down are listed/)).toBeVisible() + }) + test('warns in the column that overrules the rules, and marks the stack', async ({ page }) => { await ready(page) const tree = page.locator('[data-assembly-tree]') @@ -2158,7 +2256,7 @@ test.describe('the tool assembly tree', () => { const dialog = page.getByRole('group', { name: 'Flute length' }) await expect(dialog.getByRole('note')).toBeHidden() - await page.getByRole('textbox', { name: 'Flute length — value' }).click() + await page.getByRole('textbox', { name: 'Flute length — min' }).click() await page.keyboard.press('ControlOrMeta+a') await page.keyboard.type('10') @@ -2212,7 +2310,7 @@ test.describe('the tool assembly tree', () => { await page.getByRole('button', { name: 'Filter by Flute length', exact: true }).click() const dialog = page.getByRole('group', { name: 'Flute length' }) - const bound = page.getByRole('textbox', { name: 'Flute length — value' }) + const bound = page.getByRole('textbox', { name: 'Flute length — min' }) await bound.click() await page.keyboard.press('ControlOrMeta+a') await page.keyboard.type('10') @@ -2475,7 +2573,6 @@ test.describe('the tool assembly tree', () => { await expect(list).toBeHidden() const tree = page.locator('[data-assembly-tree]') - await expect(tree.getByText(/not on the list yet/)).toBeVisible() /* Nothing built, and the press is on screen anyway (Paul, 2026-09-09: "Add to order list should be shown by default … Right now it is hidden by @@ -2510,7 +2607,6 @@ test.describe('the tool assembly tree', () => { // Opened again from the row: on the list now, so the press that is left is // the way back off it. const again = await openRow(page) - await expect(again.getByText(/not on the list yet/)).toBeHidden() await expect(again.getByRole('button', { name: 'Remove from order list' })).toBeVisible() }) @@ -3300,26 +3396,43 @@ test.describe('the tool assembly tree', () => { await expect(holders.nth(1)).toHaveText('HOLDER—') await expect(tree.getByRole('button', { name: /^COLLET for / }).nth(1)).toHaveText('COLLET—') - // And what it offers is what a new stack offers: the way on, not an edit of - // the other one's line. - await expect(tree.getByRole('button', { name: 'Add to order list' })).toBeVisible() + /* + And what it offers is what a new stack offers: the way on, not an edit of + the other one's line. + + **One press for the box, not one per card** (Paul, 2026-09-11), so what it + says is the difference between the box and the bill — the second stack's + cutter being added. `Cancel` stands beside it because that difference is + an unsaved edit; what must not be here is an offer to take the *first* + stack's holder off, which is the adopted line this test exists for. + */ + await expect(tree.getByRole('button', { name: /^Add tool / })).toBeVisible() await expect(tree.getByRole('button', { name: /^Take holder / })).toHaveCount(0) - await expect(tree.getByRole('button', { name: /^Cancel/ })).toHaveCount(0) }) /** * **Two identical stacks are two things to set up** (Paul, 2026-09-10: * "duplicates are now showing up as separate line items — in either order list * view … that should show 2 assemblies and a count of two of each component", - * and "show a (×2, used in Assembly 1) in the feature dialog"). + * and "show a (×2, used in Assembly 1) in the feature dialog"), and **both of + * them are shown** (Paul, 2026-09-11: "when I have two (or more) tool + * assemblies on a feature or group, both need to be shown in the order list. + * Only the first is being shown right now"). + * + * The sheet keyed a line by its tool, so a row holding one cutter in two + * stacks could not hold two lines — what it held instead was `total`, a count + * on one line, and the order list drew the one line. That was the count Paul + * asked for standing in for the assemblies he asked for, and it hid the worse + * half of the same defect: two stacks of one cutter given *different holders* + * were one line too, so the second one's holder wrote over the first's. * - * The sheet keys a line by its tool, so a row holding one cutter in two stacks - * cannot hold two lines — what it holds is `total`, how many of that assembly, - * which `componentTotals` already multiplies every component by. Nothing was - * writing it, so a second identical assembly wrote the same line again and the - * bill said one of each. + * A line now carries the id of the stack that wrote it — `lineId`, + * `shared/setup-sheet.ts` — so this is two lines, two rows, and two of each + * component to buy. */ - test('counts a cutter a row ordered twice, and says so while it is picked', async ({ page }) => { + test('lists a cutter a row ordered twice twice over, and says so while it is picked', async ({ + page, + }) => { await ready(page) const tree = await buildStack(page) const slot = tree.getByRole('button', { name: /^TOOL for / }) @@ -3358,16 +3471,89 @@ test.describe('the tool assembly tree', () => { .first() .click() - await tree.getByRole('button', { name: 'Add to order list' }).last().click() + // One press over both stacks since 2026-09-11, and it names what it writes. + await tree.getByRole('button', { name: /^Add tool / }).click() - // One line on the list, and it says there are two of it to set up. + // Both stacks on the list, and two lines on the sheet under them. const list = await orderList(page) - await expect(list.getByRole('button', { name: /, ×2, for / })).toBeVisible() + await expect(list.getByRole('button', { name: / for / })).toHaveCount(2) const kept = await page.evaluate(() => { const key = Object.keys(localStorage).find((each) => each.startsWith('tool-catalog.setup.')) return key === undefined ? null : localStorage.getItem(key) }) - expect(kept).toContain('"total":2') + expect(kept).toContain('"assemblyId":"assembly-1"') + expect(kept).toContain('"assemblyId":"assembly-2"') + + // And what to buy says two of the cutter, counted over the two of them. + await page.getByRole('link', { name: 'Order list' }).click() + await expect(page.getByRole('table')).toBeVisible() + await page.getByRole('button', { name: 'Components', exact: true }).click() + const counted = page + .getByRole('row') + .filter({ has: page.getByRole('rowheader', { name: 'Tool' }) }) + .getByRole('spinbutton') + await expect(counted).toHaveValue('2') + }) + + /** + * The half of the same defect a count could never have covered: two stacks of + * one cutter, held differently. The second used to write over the first, so + * the holder somebody had chosen for it simply went (Paul, 2026-09-11). + */ + test('keeps the holder each stack of one cutter was given', async ({ page }) => { + await ready(page) + const tree = await buildStack(page) + const slot = tree.getByRole('button', { name: /^TOOL for / }) + await expect(slot).not.toContainText('—') + const number = ((await slot.textContent()) ?? '').replace(/^TOOL/, '').trim() + await tree.getByRole('button', { name: 'Add to order list' }).click() + await openRow(page) + + await tree.getByRole('button', { name: 'Add assembly' }).click() + const tools = tree.getByRole('button', { name: /^TOOL for / }) + await expect(tools).toHaveCount(2) + const rows = page.getByRole('grid').first().getByRole('row') + await expect(rows.first()).toContainText(number) + await expect(async () => { + await rows.first().evaluate((element) => { + element.click() + }) + await expect(tools.nth(1)).toContainText(number, { timeout: 2_000 }) + }).toPass({ timeout: 20_000 }) + + // A different holder from the one the first stack is on the list with. + await tree.getByRole('button', { name: 'HOLDER for assembly-2' }).click() + await showNoCollet(page) + const holders = page.locator('[data-component-table="holder"]').getByRole('grid') + await expect(holders.getByRole('row').nth(1)).toBeVisible() + const held = tree.getByRole('button', { name: /^HOLDER for / }) + /* + Retried until the slot says it took, the way {@link pickTool} is: the rack + is answered asynchronously, so a row that was there when the click was + scheduled can be gone by the time it lands — and the em dash the slot + keeps meanwhile is what the assertions below would read as a holder. + */ + await expect(async () => { + await holders + .getByRole('row') + .nth(1) + .evaluate((element) => { + element.click() + }) + await expect(held.nth(1)).not.toContainText('—', { timeout: 2_000 }) + }).toPass({ timeout: 20_000 }) + + const first = ((await held.first().textContent()) ?? '').replace(/^HOLDER/, '').trim() + const second = ((await held.nth(1).textContent()) ?? '').replace(/^HOLDER/, '').trim() + expect(second).not.toBe(first) + + await tree.getByRole('button', { name: /^Add tool / }).click() + + // Two rows, each still holding the holder its stack was given. + const list = await orderList(page) + await expect(list.getByRole('button', { name: / for / })).toHaveCount(2) + await expect(list.getByText(first, { exact: false })).toBeVisible() + await expect(list.getByText(second, { exact: false })).toBeVisible() }) test('another assembly is one press away', async ({ page }) => { @@ -3698,9 +3884,7 @@ test('the flute filter binds the list that stands in when nothing fits', async ( .click() await page.getByRole('button', { name: 'Filter by Flutes', exact: true }).click() - await page.getByRole('combobox', { name: 'How to compare Flutes' }).click() - await page.getByRole('option', { name: '≤ at most' }).click() - const box = page.getByRole('textbox', { name: 'Flutes — value' }) + const box = page.getByRole('textbox', { name: 'Flutes — max' }) await box.fill('5') // Nothing fits this face, so the list is the closest misses standing in — @@ -3716,3 +3900,62 @@ test('the flute filter binds the list that stands in when nothing fits', async ( page.getByText('No tool in the catalog matches every part of this selection.'), ).toBeVisible() }) + +/** + * **The focus is in the tool list the moment something is selected** (Paul, + * 2026-09-11: "the focus should go to the table as soon as a feature is + * selected — we should disable the arrow navigation for features in this app"). + * + * Three things, and the first is what the other two are for. Selecting a + * feature hands the keys to the list; the press after it moves in the list + * rather than through the readings behind the box; and a list reading no row + * yet still moves, because `@toolpath/ui`'s table navigates off a cursor it + * only has once a row is selected — a list that took the focus and then ignored + * every press is the dead end this half exists to prevent. + * + * `shared/arrow-target.ts` is the rule and is unit-tested; what cannot be + * tested there is whether the kit picks the press up at all. + */ +test('puts the focus in the tool list as soon as a feature is selected', async ({ page }) => { + await ready(page) + const tree = await keepFeature(page) + const slot = tree.getByRole('button', { name: /^TOOL for / }) + const named = async () => (await slot.textContent()) ?? '' + await expect(slot).toContainText('—') + + await expect(page.locator('[data-part-tool-table] [tabindex="0"]')).toBeFocused() + + // Nothing is read yet, so this press lands on a row rather than moving from + // one — the half the kit cannot do for itself. + await page.keyboard.press('ArrowDown') + + await expect(orderPress(page)).toBeEnabled() + const first = await named() + + await page.keyboard.press('ArrowDown') + + await expect.poll(named).not.toBe(first) +}) + +/** + * **And the readings are never walked with them** (Paul, 2026-09-11: "we should + * disable the arrow navigation for features in this app — it only happens by + * clicking the arrow or through the drop down list, never browsed through the + * keyboard arrows"). + * + * The press that used to change which reading the page was on is the same press + * that now belongs to the list, so this is the other side of the test above: + * the box goes on naming the reading it was opened on, however many times it is + * pressed. + */ +test('never walks the readings with the arrow keys', async ({ page }) => { + await ready(page) + const named = (await field(page).textContent()) ?? '' + expect(named).not.toBe('') + + await page.keyboard.press('ArrowDown') + await page.keyboard.press('ArrowDown') + await page.keyboard.press('ArrowUp') + + await expect(field(page)).toHaveText(named) +}) diff --git a/apps/catalog/tests/threaded-hole.spec.ts b/apps/catalog/tests/threaded-hole.spec.ts index 327497d..d46f6d9 100644 --- a/apps/catalog/tests/threaded-hole.spec.ts +++ b/apps/catalog/tests/threaded-hole.spec.ts @@ -35,6 +35,8 @@ test.use({ viewport: { width: 1680, height: 1000 } }) /** The face under the default camera's centre, scanned in `on-the-part.spec.ts`. */ const FACE = { x: 0.5, y: 0.5 } +/** Off the part, and clear of everything over it — the same corner that file uses. */ +const NOTHING = { x: 0.86, y: 0.86 } const at = async (page: Page, spot: { x: number; y: number }): Promise => { const box = await page.locator('canvas').boundingBox() @@ -309,3 +311,60 @@ test('takes one hole out of a group of identical holes at a time', async ({ page await chips.first().click() await expect(chips).toHaveCount(all - 2) }) + +/** + * **A group asks about identical holes too** (Paul, 2026-09-11: "the group + * dialog should ask if I want to add identical holes if I select one, just like + * the feature dialog"). + * + * Clicking a hole while a group is open used to take every identical hole with + * it. That expansion came out on 2026-09-11 so a group could be corrected a + * hole at a time — and with it went the only way to pick up a bolt circle + * without one click per hole. The offer is the way back to one press; + * `offerSiblings` in `routes/part.tsx` is the rule, and it is a different press + * from the reading panel's, which *opens* a group rather than growing one. + * + * Here rather than in `on-the-part.spec.ts` for the usual reason: the plain + * cube has no two identical holes, so the offer never appears there. + */ +test('offers the identical holes inside the group being built', async ({ page }) => { + // How many there are, read off the reading panel's own offer before the + // group is opened — the same set, counted the same way. + const named = page.getByRole('button', { name: /^Add all \d+ as a group$/ }) + await expect(named).toBeVisible() + const all = Number(/\d+/.exec((await named.innerText()) ?? '')?.[0] ?? '0') + expect(all).toBeGreaterThan(2) + + /* + The reading is put down first. `+ Group` over a held hole opens the group on + every hole like it — the reading panel's offer, taken through the press — + and what is under test here is the group that starts empty. + */ + await at(page, NOTHING) + await page.getByRole('button', { name: '+ Group', exact: true }).click() + await at(page, FACE) + const chips = page.getByRole('button', { name: /^Take .+ out of the group$/ }) + await expect(chips).toHaveCount(1) + + const offer = page.getByRole('button', { name: `Add all ${String(all)} to the group` }) + await expect(offer).toBeVisible() + await offer.click() + + await expect(chips).toHaveCount(all) + // Nothing left to add, so nothing left to offer. + await expect(offer).toHaveCount(0) +}) + +/** And the other answer stands the group down without adding anything. */ +test('leaves the group at one hole when the offer is turned down', async ({ page }) => { + await at(page, NOTHING) + await page.getByRole('button', { name: '+ Group', exact: true }).click() + await at(page, FACE) + const chips = page.getByRole('button', { name: /^Take .+ out of the group$/ }) + await expect(chips).toHaveCount(1) + + await page.getByRole('button', { name: 'Just this hole' }).click() + + await expect(chips).toHaveCount(1) + await expect(page.getByRole('button', { name: /^Add all \d+ to the group$/ })).toHaveCount(0) +}) diff --git a/apps/catalog/tsconfig.tsbuildinfo b/apps/catalog/tsconfig.tsbuildinfo new file mode 100644 index 0000000..55d45a6 --- /dev/null +++ b/apps/catalog/tsconfig.tsbuildinfo @@ -0,0 +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 diff --git a/docs/FEATURE-LIST.md b/docs/FEATURE-LIST.md index 56241b1..f97ac3a 100644 --- a/docs/FEATURE-LIST.md +++ b/docs/FEATURE-LIST.md @@ -164,6 +164,15 @@ Hole` sat under a heading reading `Blind Hole`. `SelectionPanel` and `holesAt` in `shared/hole-mode.ts`, because a choice written across everything selected named a group's pocket `M6×1 Pocket`. `PredrillChoice` writes through the same rule, having written to the focused hole alone until then. +- **The thread is asked in a bubble of its own, high on the box** (Paul, + 2026-09-11: "the option to add a thread should be more prominent — put it + directly underneath the group bubble in a similar bubble with grey + background"). It sat at the foot of both boxes under a hairline rule, below + every measurement, which is where this page puts a detail — and whether a hole + is tapped decides which catalog the table below is even showing. The reading + panel puts it directly under the grouping offer; the group editor puts it + directly under the chips. `thread-picker.tsx` owns the bubble, so both get it + from one place. - **A group is threaded in the group editor.** The editor stands where the reading panel would be, so a bolt circle picked out there had to be taken apart again to say it was tapped. It carries a `ThreadPicker` of its own where @@ -261,6 +270,17 @@ to be applied on every path while `collecting` was on — `click`, `read`, `arm` correct: the X beside one hole, and a press on one hole, each took all thirty-nine. A group of all-but-one could not be asked for at all. +**And the group asks the same question the reading panel does** (Paul, +2026-09-11: "the group dialog should ask if I want to add identical holes if I +select one, just like the feature dialog"). Editing a hole at a time left a bolt +circle costing one click per hole when the group had been opened on something +else, so the group editor carries the offer too — the same sentence doing a +different thing, since here it grows the group already open rather than opening +one. `offerSiblings` in `routes/part.tsx` is that rule, and `groupOffer` is +deliberately silent while a group is being built for exactly this reason: it +answers the press that _opens_ a group. It stands down once every sibling is in, +because a press that adds what is there says nothing. + A click itself is a **toggle** of one feature, and nothing else: - a face not in the group goes in; @@ -286,6 +306,40 @@ set. `reset` puts everything down at once and is what confirming a draft uses what was being picked has become a row, and leaving it selected as well would have the page answering the same question twice. +### The keyboard, and which list it is for + +**The focus goes to the tool list the moment anything is selected** (Paul, +2026-09-11: "the focus should go to the table as soon as a feature is selected — +we should disable the arrow navigation for features in this app; it only happens +by clicking the arrow or through the drop down list, never browsed through the +keyboard arrows"). Selecting a feature, a group or a stack is what puts somebody +in front of a list of tools, so it is what hands them the keys to it. + +- **The readings are never walked with the arrows.** A reading is chosen by + clicking an arrow on the part or by naming one in the list. The `step` action + the keyboard used to dispatch is gone from `shared/part-interaction.ts` + entirely, so there is no second way back in. +- **The arrows are the list's**, and mostly the kit's: `@toolpath/ui`'s table + navigates off the focus inside it. `shared/arrow-target.ts` is what the page + keeps of them — one press repairing a list that has not got the focus, or has + it and is reading no row yet. +- **A list reading no row is the half the kit cannot do for itself.** It moves a + cursor it only has once a row is selected, so a list that took the focus would + otherwise ignore every press after it. The press lands on the first row for + that reason — clicked through the DOM rather than chosen out of the data the + page handed over, since the order on screen is the table's own once somebody + has sorted it. +- **Escape is the page's wherever the focus is**, short of a field being typed + into. The tool list used to be exempt along with the fields, on the grounds + that it answers Escape for itself by dropping its selected row; that was + harmless only while nothing put the focus there, and now something does. + +The focus is taken on a new **question** — a row selected, a draft opened, a +different slot in the tree, the list swapping between the tools and a rack. Not +on what the list holds: a filter typed into, a row picked, an answer arriving +late are none of them a new question, and taking the focus back on each would +take it away from whatever somebody was doing. + --- ## 4. Adding @@ -943,9 +997,13 @@ true of the work the worker does: | the row a reading already has, if any | `rowFor`, `app/shared/feature-list.ts` | | what _+ Feature_ does over a reading | `keepReading`, `app/routes/part.tsx` | | which press closes the box, and Enter's | `isOrdering` / `orderingPress`, `assembly-actions.ts` | +| which list one press of an arrow reaches | `arrowTarget`, `app/shared/arrow-target.ts` | +| handing that press to the list on screen | `handToList`, same file | +| which list the focus follows a question to | `listOnScreen` / `askingNow`, `app/routes/part.tsx` | | what Enter presses, over the whole box | `orderPress` / `applyStacks`, `routes/part.tsx` | | a component standing in two stacks at once | `sharedWith` / `sharedPhrase`, `assembly-tree.ts` | | how many of one assembly a row ordered | `Choice.total`, `app/shared/setup-sheet.ts` | +| which line on a feature a stack's line is | `lineId`, same file | | a row's answer, and what opens | `app/shared/recommendations.ts` | | what a click means | `app/shared/part-interaction.ts` | | the list on screen | `app/components/feature-list-panel.tsx` | @@ -956,6 +1014,8 @@ true of the work the worker does: | where identical holes are grouped, once | `{ type: 'group' }`, `part-interaction.ts` | | whether the offer to group them is made | `app/shared/group-offer.ts` | | the offer on screen, and both answers | `identical`, `components/selection-panel.tsx` | +| the same offer inside the group being built | `offerSiblings`, `app/routes/part.tsx` | +| the bubble the thread is asked in | `app/components/thread-picker.tsx` | | a feature row turned into a group | `changeToGroup`, `app/routes/part.tsx` | | what a press on a row of the list opens | `pressRow`, same file | | the row a draft is changing, where it is one | `editedItem`, same file | @@ -967,6 +1027,8 @@ true of the work the worker does: | what the panel and the ⓘ dialog call it | `nameOf`, handed down by `part.tsx` | | the tool table and its marks | `app/components/part-tool-table.tsx` | | what overruling the rules offers | `overridableTools`, `shared/tool-fit.ts` | +| what a number box takes besides a number | `app/shared/range-entry.ts` | +| which columns' rules an emptied box releases | `releasedBounds`, `app/shared/filter.ts` | | the warning and its confirm | `OverrideNotice`, `components/column-filter.tsx` | | what a filter is not showing, and the `…` | `TermFilter`, `components/column-filter.tsx` | | what a tick on Type asks of the forms | `formsAsking`, `app/shared/tool-type.ts` | diff --git a/docs/TOOL-ASSEMBLY-TREE.md b/docs/TOOL-ASSEMBLY-TREE.md index 269b15d..0e123fd 100644 --- a/docs/TOOL-ASSEMBLY-TREE.md +++ b/docs/TOOL-ASSEMBLY-TREE.md @@ -118,21 +118,33 @@ this assembly`, because a rack is narrowed to what fits a stack only while this stack reads `TDMX0800 → TDMX0500`, the old one struck through. The button says the change in a sentence, which is the right place for _what pressing it does_ and the wrong place to find out which of three slots moved. An em dash - stands for a slot the line has nothing in; a stack that is not on the order - list at all says nothing on any row, because "not on the list yet" is the - stack's own state. -- **One button per assembly, under the components it is about** (Paul, - 2026-09-07: "I should just have an 'add to order list' button (or update, - context aware), at the top level of each tool assembly"). It writes the whole - assembly — tool, holder and collet are one line on the sheet and one thing a - shop orders — and its label is the change: _Add to order list_, _Change holder - from A to B_, _Remove from order list_. **A threaded hole is one press, not - two** (Paul, 2026-09-08: "there should only be one 'add to order list' button - for the full assembly"): the drill hangs under the tap, so the tap, its - holding, the drill and the drill's holding go on the list together. A tap - orderable on its own is a thread with no hole under it to cut. A sentence per - stack that moved, the first on the button and the rest under it, each naming - the stack it is about — `TAP: Change holder from A to B`. + stands for a slot the line has nothing in; a stack not on the order list says + nothing on any row. It used to carry a line reading _not on the list yet_; that + came out on 2026-09-11, along with the heading naming what the tree was for — + the card above it names the reading, and the list is dashed and marked once it + is a row. +- **One button under the whole box, not one per assembly** (Paul, 2026-09-07: "I + should just have an 'add to order list' button (or update, context aware)", + and 2026-09-11: "the button should be below BOTH of them, and add both + assemblies to the order list — we only need one button and it just adds + everything on the list"). It writes every stack in the box — tool, holder and + collet are one line on the sheet and one thing a shop orders — and its label + is the change: _Add to order list_, _Change holder from A to B_, _Remove from + order list_. **A threaded hole is one press, not two** (Paul, 2026-09-08: + "there should only be one 'add to order list' button for the full assembly"): + the drill hangs under the tap, so the tap, its holding, the drill and the + drill's holding go on the list together. A tap orderable on its own is a + thread with no hole under it to cut. A rougher and a finisher go the same way + — one press, both stacks, which is what Enter on the same box had done since + 2026-09-10 while the buttons disagreed with it. A sentence per stack that + moved, the first on the button and the rest under it, each naming the stack it + is about — `TAP: Change holder from A to B`. +- **_Add assembly_ is a row of the tree, over that press** (Paul, 2026-09-11: + "add assembly should be above the add feature to list or add to order list + buttons — like another row with a plus button in the list of components"). It + stood outside the card as a second full-width button competing with the one + that finishes the box. It is drawn on the last card only: it makes a card + rather than a row inside one. - **The press that orders closes the box** (Paul, 2026-09-10: "clicking 'Add to Order List' should close the feature, group, or tool assembly dialog"). The decision is written the moment it is pressed, and what stood on screen @@ -233,7 +245,8 @@ which tap was chosen. `treeRows` is the nesting and the drawing order: │ │ ○ HOLDER — │ │ │ ○ COLLET — │ └─────────────────────┘ - [ Add to order list ] + + Add assembly +[ Add to order list ] ``` **The drill is a slot of the tap, not a card beside it** (Paul, 2026-09-08). @@ -289,10 +302,10 @@ and the group editor alike — and its stacks are kept under `DRAFT_TREE`. **A stack is selected, not chosen** (Paul, 2026-09-07: "it should also no longer autoselect the tool component row that I click on"). Clicking a row in any of the three tables puts that component into the stack on screen and nowhere else — -nothing reaches the list or the order list until the stack's own button is -pressed. The tree says so while it is unconfirmed: _not on the list yet_. +nothing reaches the list or the order list until the button under the box is +pressed. -**One press does both.** Where the feature is not a row yet, the stack's button +**One press does both.** Where the feature is not a row yet, the box's button is still **Add to order list**, and the note under it says it adds the feature to the list as well. There is no order in which you confirm the feature and then confirm its tools: they are one decision, and splitting them left a built stack @@ -303,7 +316,8 @@ row is given. 2026-09-10: "I should be able to create a feature or group without adding a tool"). It reads **Add feature to list** — **Add group to list** for a group — and it orders nothing: the row goes onto the list marked incomplete, dashed, with -whatever is standing in the stack carried onto it. The moment a component is +whatever is standing in the stack carried onto it — which the list says by being +dashed and marked, so the press no longer carries a note saying it (2026-09-11). The moment a component is picked it becomes **Add to order list** again, in the same place, so choosing a tool changes what the button will do rather than where it is. A **part-level tool assembly** is the one subject with no such press: it _is_ its order, so an empty @@ -509,7 +523,7 @@ What changed is where a line comes from: - **A stack is the line's identity, not its tool** (Paul, 2026-09-07: "when editing an already active assembly, a tool not in the order list should say 'replace' in the active assembly. Right now it is adding a new assembly to the - feature"). A line on the sheet is keyed by its tool, so a stack that swapped + feature"). A line on the sheet was keyed by its tool, so a stack that swapped cutters looked up nothing and read as never ordered — and its button offered to add a _second_ assembly to a feature that has one. `orderedTool` on the stack is the link back: what the sheet holds for this stack, written by the press @@ -553,22 +567,38 @@ What changed is where a line comes from: one, because a collet bought twice is two collets whichever row it sits on. The table's own mark is about the order list; this is about the tree in hand. - **And two of one assembly are counted rather than collapsed** (Paul, - 2026-09-10: "duplicates are now showing up as separate line items — in either - order list view … that should show 2 assemblies and a count of two of each - component"). The sheet keys a line by its tool, so a row cannot hold the same - cutter on two lines — what it holds is `Choice.total`, how many of that - assembly, which `componentTotals` has always multiplied every component by. - Nothing was writing it: `applyStacks` in `routes/part.tsx` now counts the - stacks of the row standing as that cutter and writes it, the part page's line - wears the `×2`, and taking one of two off leaves the line with a one on it - rather than removing it. - - **What is still keyed by the tool is the line itself.** A row holding one - cutter in two stacks _with different holding_ — the same end mill at two - stickouts — cannot keep both: the second overwrites the first. Counting fixes - the identical case, which is the one a shop hits; the general case needs a line - keyed by the stack that ordered it rather than by the tool in it. + **And two of one assembly are two lines** (Paul, 2026-09-10: "duplicates are + now showing up as separate line items — in either order list view … that + should show 2 assemblies and a count of two of each component", and + 2026-09-11: "when I have two (or more) tool assemblies on a feature or group, + both need to be shown in the order list. Only the first is being shown right + now"). The sheet keyed a line by its tool, so a row could not hold the same + cutter on two lines at all. What stood in for the second of them was + `Choice.total`, a count on the one line — which `componentTotals` multiplies + every component by, so the bill added up and the order list still drew one row + where a shop had built two. + + **A line carries the id of the stack that wrote it.** `Choice.assemblyId`, and + `lineId` in `app/shared/setup-sheet.ts` is the one reading of it: the stack, + or the tool where nothing built the line out of a tree — the tool panel writes + one, and so does every sheet saved before 2026-09-11. `addChoice` replaces the + line with the same id rather than the same tool, `removeChoice` takes one line + off and `removeTool` takes every line of a tool off (which is what the tool + panel's _Remove_ means), and `applyStacks` in `routes/part.tsx` writes the id + with the line. So the identical case is two rows and two of each component to + buy, and the case a count could never have covered — the same end mill in two + holders, at two stickouts — keeps the holding each stack was given, instead of + the second writing over the first. + + `Choice.total` stays what it always was: how many of _that_ assembly a shop + wants, edited on the order list page. Nothing writes it automatically any + more. + + Two _rows_ that ordered one stack are still one thing to buy (Paul, + 2026-08-31), so `orderAssemblies` in `app/shared/order-list.ts` groups by the + stack's contents across rows and by how many copies of it a row holds: the + first copy merges across rows the way it always has, and a second takes a key + of its own. - **Enter is about the box; the button is about the assembly** (Paul, 2026-09-10: "when I create two tool assemblies on a group and click enter, it diff --git a/docs/TOOL-CATALOG-PLAN.md b/docs/TOOL-CATALOG-PLAN.md index ca70a03..1b74449 100644 --- a/docs/TOOL-CATALOG-PLAN.md +++ b/docs/TOOL-CATALOG-PLAN.md @@ -1059,19 +1059,21 @@ A day that fixed the same three things repeatedly, and what now stops each from coming back. Recorded because the next session will be tempted by the same shortcuts. -| What went wrong | Why nothing caught it | What catches it now | -| ------------------------------------------------------------------------------ | ----------------------------------------------------------- | --------------------------------------------------- | -| The arrows were never wired: `DirectionArrows` drawn without `onPickDirection` | No test rendered the viewer; three "fixes" went downstream | `components/part-viewer.test.tsx` | -| A miss on the mesh un-armed the arrow that was just pressed | Arrow rules were `useState` interplay inside the route | `shared/part-interaction.test.ts` | -| An armed arrow stayed armed, pinning every later click to one way up | Same | Same | -| `?job=` read as a filter; then filters wiped `?job=` | The URL round trip had no test | `shared/filter.test.ts` (`axes`, `searchWithQuery`) | -| A suggestion from the last feature outlived it (pocket kept the hole's drill) | "Fill blanks only" looked right until two features in a row | `shared/suggest-filters.test.ts` | -| The range operator was derived from the bound, so ≤ with nothing typed was Any | No component test on the control | `components/column-filter.test.tsx` | -| The number box re-formatted "1." to "1.000" under the cursor | Same | Same | -| The type grid spoke Fusion's names against a catalog that said `endmill` | Two vocabularies, no facet for the finer one | `form` derived at build; `build.test.ts` | -| Eighty single-line `if`s against a rule stated in AGENTS.md | The rule had no sensor | `pnpm lint` (`curly`), in `pnpm check` | -| Holding keys would have emptied the tool list through `filterTools` | Pre-empted, because `?job=` had done exactly that | `shared/holding.test.ts` | -| A contract bump meant a re-scrape | No way to re-run `buildCatalog` over an existing dataset | `scripts/rebuild.mjs` | +| What went wrong | Why nothing caught it | What catches it now | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------- | +| The arrows were never wired: `DirectionArrows` drawn without `onPickDirection` | No test rendered the viewer; three "fixes" went downstream | `components/part-viewer.test.tsx` | +| A miss on the mesh un-armed the arrow that was just pressed | Arrow rules were `useState` interplay inside the route | `shared/part-interaction.test.ts` | +| An armed arrow stayed armed, pinning every later click to one way up | Same | Same | +| `?job=` read as a filter; then filters wiped `?job=` | The URL round trip had no test | `shared/filter.test.ts` (`axes`, `searchWithQuery`) | +| A suggestion from the last feature outlived it (pocket kept the hole's drill) | "Fill blanks only" looked right until two features in a row | `shared/suggest-filters.test.ts` | +| The range operator was derived from the bound, so ≤ with nothing typed was Any | No component test on the control | `components/column-filter.test.tsx` | +| Narrowing a number cost four presses before a keystroke, and the operator list was a popover inside a popover | The cost was in the shape of the control, and no test asks a shape | The control has no operator list: `shared/range-entry.test.ts` | +| Clearing a bound took the filter off and left the rules that wrote it judging, while the tick that sets them aside was offered only with a number in the box | The filter and the rules were tested apart, and the gap was between them | `shared/filter.test.ts` (`releasedBounds`), `tests/on-the-part.spec.ts` | +| The number box re-formatted "1." to "1.000" under the cursor | Same | Same | +| The type grid spoke Fusion's names against a catalog that said `endmill` | Two vocabularies, no facet for the finer one | `form` derived at build; `build.test.ts` | +| Eighty single-line `if`s against a rule stated in AGENTS.md | The rule had no sensor | `pnpm lint` (`curly`), in `pnpm check` | +| Holding keys would have emptied the tool list through `filterTools` | Pre-empted, because `?job=` had done exactly that | `shared/holding.test.ts` | +| A contract bump meant a re-scrape | No way to re-run `buildCatalog` over an existing dataset | `scripts/rebuild.mjs` | Three habits from the DFM repository's history that would have saved most of the day, now adopted: From 7fd86f6062a1dda12baebdd7a0a8d496f7cbe922 Mon Sep 17 00:00:00 2001 From: nathan-vandrei <87823030+dementive@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:46:26 -0400 Subject: [PATCH 12/12] Fix test --- apps/catalog/app/routes/part.tsx | 2 +- apps/catalog/tests/on-the-part.spec.ts | 28 +++++++++++++------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/catalog/app/routes/part.tsx b/apps/catalog/app/routes/part.tsx index 219d086..0c4ac28 100644 --- a/apps/catalog/app/routes/part.tsx +++ b/apps/catalog/app/routes/part.tsx @@ -5740,7 +5740,7 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: {forgiven.length > 0 && overrideTools.length > 0 ? ( {overrideTools.length} the {overridden} rules turn down are listed {/* diff --git a/apps/catalog/tests/on-the-part.spec.ts b/apps/catalog/tests/on-the-part.spec.ts index ba914d2..1ed4921 100644 --- a/apps/catalog/tests/on-the-part.spec.ts +++ b/apps/catalog/tests/on-the-part.spec.ts @@ -2288,17 +2288,12 @@ test.describe('the tool assembly tree', () => { }) /** - * **The number and the forgiveness are one decision** (Paul, 2026-09-08: "if - * override rules is off, it should go back to the filter defined by the - * geometry — right now it is keeping the override"). Dropping only the - * forgiveness left the widened bound standing over a list the rules then - * emptied: the dead end the control exists to remove, reached by pressing the - * control. - * - * So there is no press that turns one off (Paul, 2026-09-09) — the way back - * is the number, and the × beside the tick is how it goes. + * **A cleared geometry bound means unbounded** (Paul, 2026-09-11). A shop + * that removes the feature's minimum asks to see the whole catalog on that + * axis, so its rule must be released too. Typing the geometry's value back is + * still how the ordinary, rule-bound answer comes back. */ - test('drops the override when the number that raised it is cleared', async ({ page }) => { + test('releases the matching rule when a geometry bound is cleared', async ({ page }) => { await ready(page) await page .locator('[data-assembly-tree]') @@ -2325,12 +2320,17 @@ test.describe('the tool assembly tree', () => { await page.getByRole('button', { name: 'Filter by Flute length', exact: true }).click() await dialog.getByRole('button', { name: 'Clear the Flute length filter' }).click() - // The number is gone, and the forgiveness went with it. + // The number is gone, so this axis is deliberately unbounded. Its matching + // rule is therefore released as well. await expect(page).not.toHaveURL(/min\.LCF=/) await expect(bound).not.toHaveValue('10') - await expect(page.getByText(/rules turn down are listed/)).toBeHidden() - // And with nothing overruled, the dialog has nothing to warn about. - await expect(dialog.getByRole('note')).toBeHidden() + await expect(page.getByText(/rules turn down are listed/)).toHaveAttribute( + 'title', + /Clearing a geometry bound releases its rule/, + ) + // The open dialog confirms that the now-unbounded axis has released its + // matching rule; the list carries the same explanation after it closes. + await expect(dialog.getByRole('note')).toContainText('rules are set aside') }) /**