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 (
-
- )
-}
-
/**
* **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.`}
-
- 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 = ({
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 ? (
+
+
+ Updating compatible tools...
+
+ ) : 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 ? (
-
-
- Updating compatible tools...
-
- ) : 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 (
-
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)
+ }
+ }}
+ >
+
)
}
@@ -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.
*/
{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