Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 119 additions & 73 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/catalog/app/client/catalog-matcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const context: MatchContext = {
margins: { radial: 0, axial: 0 },
thresholds: thresholdsFrom(),
overrides: [],
ownRanges: {},
suggestedRanges: {},
}

afterEach(() => {
Expand Down
33 changes: 32 additions & 1 deletion apps/catalog/app/components/app-header.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { PublicInspectionReport } from '@toolpath/part-contracts'
import { forgetPart, rememberPart } from 'shared/part-session'
Expand Down Expand Up @@ -82,3 +82,34 @@ describe('the tabs', () => {
expect(screen.getByRole('link', { name: 'Parts' })).toHaveAttribute('href', '/parts')
})
})

/**
* **One setting with two states, so the kit's `Toggle`** (Paul, 2026-09-11:
* "the mm/in toggle should use the toggle component from @toolpath/ui"). It was
* two `Chip`s side by side, which is two buttons that happen to be drawn next
* to each other — the kit's control slides an indicator between the two and
* takes the keyboard with it.
*
* The group around it is load-bearing: the kit makes a two-item toggle a
* `role="switch"` and takes no name of its own, so without it the header offers
* a switch that says only "mm".
*/
describe('the unit control', () => {
it('is the kit toggle, named, with the current unit selected', () => {
render(<AppHeader unit="millimeters" onUnit={vi.fn()} toolCount={42} />)

const units = screen.getByRole('group', { name: 'Units' })
expect(within(units).getByRole('switch')).toBeVisible()
expect(within(units).getByRole('button', { name: 'mm' })).toHaveAttribute('data-selected')
expect(within(units).getByRole('button', { name: 'in' })).toHaveAttribute('data-unselected')
})

it('asks for the other unit when the other one is pressed', () => {
const onUnit = vi.fn()
render(<AppHeader unit="millimeters" onUnit={onUnit} toolCount={42} />)

fireEvent.click(screen.getByRole('button', { name: 'in' }))

expect(onUnit).toHaveBeenCalledWith('inches')
})
})
38 changes: 28 additions & 10 deletions apps/catalog/app/components/app-header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NavLink, useNavigate, useParams, useSearchParams } from 'react-router'
import { Badge, IconButton, cn } from '@toolpath/ui'
import { Chip, ChipGroup } from 'components/chip'
import { Badge, IconButton, Toggle, cn } from '@toolpath/ui'
import { Chip } from 'components/chip'
import { ToolpathLogo } from 'components/toolpath-logo'
import { UNIT_ABBREVIATION, UNIT_SYSTEMS, type UnitSystem } from '@toolpath/tool-support'
import { MoonIcon, SunIcon, UploadSimpleIcon } from '@phosphor-icons/react'
Expand Down Expand Up @@ -40,7 +40,7 @@ export const AppHeader = ({ unit, onUnit, toolCount, onUploadPart }: AppHeaderPr
<header className="border-b border-zinc-800 bg-zinc-950">
<div className="flex items-center gap-3 px-6 pt-4 pb-2">
<ToolpathLogo className="size-6 shrink-0" />
<h1 className={HEADING}>Toolpath Tool Catalog</h1>
<h1 className={HEADING}>Toolpath Catalog</h1>
<Badge variant="secondary">{toolCount} tools</Badge>
{/* 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
Expand Down Expand Up @@ -78,13 +78,31 @@ export const AppHeader = ({ unit, onUnit, toolCount, onUploadPart }: AppHeaderPr
>
{theme === 'dark' ? <SunIcon aria-hidden="true" /> : <MoonIcon aria-hidden="true" />}
</IconButton>
<ChipGroup label="Units">
{UNIT_SYSTEMS.map((each) => (
<Chip key={each} pressed={each === unit} onClick={() => onUnit(each)}>
{UNIT_ABBREVIATION[each]}
</Chip>
))}
</ChipGroup>
{/*
**The kit's `Toggle`, not two chips** (Paul, 2026-09-11). Millimetres
or inches is one setting with two states, which is the control the kit
exports for exactly this — it slides an indicator between them and
handles the keyboard, where a pair of chips is two buttons that happen
to be drawn next to each other.

Labelled by the group around it, because the kit makes a two-item
toggle a `role="switch"` and takes no name of its own: without it the
header offers a switch that says only "mm".
*/}
<div role="group" className="mb-0.5" aria-label="Units">
<Toggle
size="sm"
value={unit}
onValueChange={(next) => onUnit(next as UnitSystem)}
className="text-2xs font-semibold dark:bg-black/25"
>
{UNIT_SYSTEMS.map((each) => (
<Toggle.Item key={each} value={each}>
{UNIT_ABBREVIATION[each]}
</Toggle.Item>
))}
</Toggle>
</div>
</div>
<nav className="flex gap-2 border-t border-zinc-900 px-6">
{/*
Expand Down
62 changes: 62 additions & 0 deletions apps/catalog/app/components/assembly-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import type { Holder } from '@toolpath/catalog-data'
import { AssemblyPanel } from './assembly-panel'

const holder: Holder = {
guid: 'one',
familyId: 'bt30_er_collet_adapters_metric',
brand: 'Kennametal',
vendor: 'Kennametal',
catalogNumber: 'BT30ER11060M',
materialNumber: '6694846',
taper: 'BT30',
contact: 'taper',
clamping: 'collet',
gaugeLength: 60,
colletSeries: 'ER11',
boreDiameter: null,
noseDiameter: null,
noseLength: null,
bodyDiameter: null,
bodyLength: null,
projection: null,
flangeDiameter: null,
colletProtrusion: null,
productLink: null,
cadModelUrl: null,
provenance: {},
}

const read = (notice: string | null) =>
render(
<AssemblyPanel
tool={null}
holder={holder}
collet={null}
selected="holder"
unit="millimeters"
notice={notice}
/>,
)

describe('the panel with no cutter in the stack', () => {
/**
* **One message, not two** (Paul, 2026-09-11). A holder chosen with no tool
* asked for the same tool twice — `nothingToConfirm` in a strip at the top
* and the drawing frame's own "Choose a tool to draw the assembly." under it.
*/
it("says why the drawing is empty once, in the notice's words", () => {
read('Pick a tool for this assembly.')

expect(screen.getByText('Pick a tool for this assembly.')).toBeInTheDocument()
expect(screen.queryByText('Choose a tool to draw the assembly.')).toBeNull()
})

/** With no reason to give — a rack being browsed with no feature — the frame still says what it wants. */
it("falls back to the frame's own words where there is no notice", () => {
read(null)

expect(screen.getByText('Choose a tool to draw the assembly.')).toBeInTheDocument()
})
})
10 changes: 8 additions & 2 deletions apps/catalog/app/components/assembly-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export const AssemblyPanel = ({
about, so what is left here is the reading and, where a stack cannot be
confirmed at all, why.
*/}
{notice === null ? null : (
{notice === null || tool === null ? null : (
<p className="shrink-0 rounded border border-zinc-800 px-2 py-1 text-xs text-zinc-500">
{notice}
</p>
Expand All @@ -194,8 +194,14 @@ export const AssemblyPanel = ({
tool and what holds it, and a holder on its own is a picture the
package does not offer. Saying so beats an empty frame — and the
component's own numbers are still read below.

**Said once** (Paul, 2026-09-11). `nothingToConfirm` asks for the
same tool in the same words, so a holder with no cutter carried
two notices stacked on each other — the reason the frame is empty
and the reason nothing can be ordered are one sentence. Where
there is a notice it fills this frame and the strip above goes.
*/}
Choose a tool to draw the assembly.
{notice ?? 'Choose a tool to draw the assembly.'}
</p>
<div className="min-h-0 flex-1 overflow-auto">{facts}</div>
</>
Expand Down
52 changes: 25 additions & 27 deletions apps/catalog/app/components/assembly-tree-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
type TreeNode,
} from 'shared/assembly-tree'
import { NameField } from './name-field'
import { SECTION_LABEL } from 'shared/type'
import { DIALOG_EMPTY, DIALOG_NOTE, DIALOG_VALUE, SECTION_LABEL } from 'shared/type'

/**
* The stacks a feature is answered with, as a tree beside the tool table.
Expand Down Expand Up @@ -157,7 +157,7 @@ const SlotRow = ({
}) => (
<div
className={cn(
'group flex items-center gap-2 rounded px-2 py-1 text-left text-xs',
'group flex items-center gap-2 rounded px-2 py-1 text-left',
selected ? 'bg-primary/15 ring-1 ring-primary/50' : 'hover:bg-zinc-900',
)}
>
Expand All @@ -168,7 +168,7 @@ const SlotRow = ({
aria-current={selected ? 'true' : undefined}
aria-label={`${slotLabel(assembly, slot)} for ${assembly.id}`}
onClick={onSelect}
className="flex min-w-0 flex-1 items-center justify-start gap-2 border-0 bg-transparent px-0 py-0 text-xs hover:bg-transparent"
className="flex min-w-0 flex-1 items-center justify-start gap-2 border-0 bg-transparent px-0 py-0 hover:bg-transparent"
>
{/*
Filled or not, in one glyph. A slot nobody has answered is the question
Expand All @@ -183,18 +183,15 @@ const SlotRow = ({
)}
/>
{/*
The row that heads a stack is the tool, and it reads as the head: the
holding under it is what it is held by, not two more things of the same
rank (Paul, 2026-09-08).
**Every slot name is the one section label** (Paul, 2026-09-11). The
tool's used to be a half-step brighter than the holding's, to say that
the row heading a stack is the tool and the two under it are what it is
held by (Paul, 2026-09-08) — but the indent and the rule down the left of
`StackRows` were added the same day for exactly that, and say it without
spending a grey on it. Two inks meaning one thing is what this pass took
out of these boxes.
*/}
<span
className={cn(
'w-14 shrink-0 font-semibold tracking-wide',
slot === 'tool' ? 'text-zinc-300' : 'text-zinc-500',
)}
>
{slotLabel(assembly, slot)}
</span>
<span className={cn(SECTION_LABEL, 'w-14 shrink-0')}>{slotLabel(assembly, slot)}</span>
{/*
**A change shows on the row it is a change to** (Paul, 2026-09-07: "when
I make changes, they should show in the respective component rows (like
Expand All @@ -204,7 +201,7 @@ const SlotRow = ({
*/}
{ordered === null ? null : (
<>
<span className="truncate font-mono text-zinc-500 line-through" title={ordered}>
<span className={cn(DIALOG_NOTE, 'truncate font-mono line-through')} title={ordered}>
{ordered}
</span>
<span aria-hidden="true" className="shrink-0 text-zinc-600">
Expand All @@ -214,8 +211,9 @@ const SlotRow = ({
)}
<span
className={cn(
DIALOG_VALUE,
'truncate font-mono',
label === null ? 'text-zinc-600' : ordered === null ? 'text-zinc-200' : 'text-amber-300',
label === null ? DIALOG_EMPTY : ordered === null ? '' : 'text-amber-300',
)}
title={label ?? undefined}
>
Expand Down Expand Up @@ -256,7 +254,7 @@ const SlotRow = ({
*/}
{shared === null ? null : (
<span
className="text-2xs shrink-0 rounded bg-zinc-800 px-1 py-0.5 text-zinc-400"
className={cn(DIALOG_NOTE, 'shrink-0 rounded bg-zinc-800 px-1 py-0.5')}
title={`This component stands in more than one assembly here — ${shared}`}
>
{shared}
Expand Down Expand Up @@ -444,12 +442,13 @@ export const AssemblyTreePanel = ({
title="Rename this assembly"
onClick={() => setNaming(group.root.id)}
className={cn(
SECTION_LABEL,
// `w-full` so the box inside the button is the width of the
// button rather than of the name — where the ellipsis happens.
'text-2xs w-full min-w-0 flex-1 truncate text-left font-semibold tracking-wide text-zinc-400',
'w-full min-w-0 flex-1 truncate text-left',
/* A name is somebody's words, so it is left as typed; a
number is a heading, and headings here are upper case. */
group.root.name === undefined ? 'uppercase' : '',
group.root.name === undefined ? '' : 'normal-case',
)}
>
{/*
Expand Down Expand Up @@ -560,7 +559,7 @@ export const AssemblyTreePanel = ({
}}
/* Drawn as a slot row is drawn — same height, same padding,
same hover — so it sits in the list rather than on it. */
className="flex w-full items-center gap-2 rounded border-0 bg-transparent px-2 py-1 text-left text-xs text-zinc-500 hover:bg-zinc-900 hover:text-zinc-300"
className="flex w-full items-center gap-2 rounded border-0 bg-transparent px-2 py-1 text-left hover:bg-zinc-900"
full
>
{/* Centred where the slot rows wear their dot, and a size up
Expand All @@ -572,7 +571,7 @@ export const AssemblyTreePanel = ({
>
<PlusIcon className="size-4" />
</span>
<span className="font-semibold tracking-wide">Add assembly</span>
<span className={SECTION_LABEL}>Add assembly</span>
</Button>
)}
</div>
Expand All @@ -598,14 +597,13 @@ export const AssemblyTreePanel = ({
<div key={action.key} className="flex flex-col gap-0.5">
<Button
type="button"
size="sm"
/* `md` is the kit's own `text-xs`: the one press that finishes the
box is not set in the 10px a `sm` button would give it. */
size="md"
variant={action.danger === true || action.quiet === true ? 'secondary' : 'primary'}
disabled={action.disabled === true}
onClick={action.onClick}
className={cn(
'w-full justify-center text-xs',
action.danger === true ? 'text-danger' : '',
)}
className={cn('w-full justify-center', action.danger === true ? 'text-danger' : '')}
full
>
{action.label}
Expand All @@ -616,7 +614,7 @@ export const AssemblyTreePanel = ({
chose without a word is the defect this line exists to prevent.
*/}
{action.note === undefined ? null : (
<p className="text-2xs text-amber-300">{action.note}</p>
<p className={cn(DIALOG_NOTE, 'text-amber-300')}>{action.note}</p>
)}
</div>
))}
Expand Down
Loading