MR-318 · the admin home for the product-type taxonomy the product record already assigns
The taxonomy was drawn before it was administered. catalog-product-edit-v6.html has carried the rail chips and a working assign drawer since MR‑315, and that drawing settled four things on its own: it is a hierarchy, a product carries several nodes, parents are selectable, and assignment is product-level, not version-scoped. RI‑538 had all four open; they are marked settled there rather than only here.
What did not exist was any way to create, rename, move or retire a node. That is this screen, and nothing else is.
Paul, 2026‑09‑09: “if we are administrating categories, I don’t think it needs to be on the main nav. It could probably be somewhere in the settings, in its own tab.” RI‑552 already set the pattern — a managed vocabulary is administered on the list screen of the entity that owns it, under a Settings link.
Categories is the one case with no entity list screen, because the tree is the list: there is no browse view for it to be a tab beside. So it gets a file of its own and no tabstrip — a one-tab tab strip is chrome asserting a choice that does not exist. The #tab=admin address is not used here for the same reason; the screen has one state and the URL names the selected node instead (#node=sleep-stress).
The nav item moved rather than multiplied. Categories was a not mocked yet stub in the Catalog section in fifteen files; it is now a real link in the Settings section in those same fifteen. MR‑003 fixed the durable nav sections and every one of them survives — one item changed section, and it did not stay in both, because two entries pointing at one screen is the ambiguity the move exists to remove. Browsing the catalog by category is still reachable: each node’s usage links into the catalog products list.
A node in use cannot be deleted — the flat vocabularies already work this way (RI‑552) and the reason carries over unchanged: the control says so before the click, not after. A node with children cannot be deleted either, which is the tree-only case the flat lists never had; deleting a parent would either orphan its children or silently take a whole branch with it, and both are worse than refusing.
Those two guards would make a bad node permanent, so Retire is the release valve: the node stays on every product that carries it and stops being offered for a new assignment. Sleep Aids is the fixture for it — an early node that duplicated Sleep & Stress, retired, with one product still filed under it.
All four combinations are reachable by clicking: Health & Wellness is in use and has children, Sleep & Stress is in use only, Toys & Games has children only, Energy has neither and deletes.
Direct is the products carrying that exact node. In branch is the distinct products on it or anything under it — so it is not the sum of its children. Supplements shows 7 in branch where its children sum to 8, because Vapofil is on Sleep & Stress and Herbal & Botanical and is one product either way.
The delete guard reads the direct count, never the branch total. A parent with products under it is already blocked by its children, so letting the branch total gate a delete would state a second, weaker reason for the same refusal. The branch total answers a different question — whether a branch is worth keeping. Toys & Games at 0 direct · 0 in branch is genuinely empty; a parent at 0 direct and 7 in branch is a live spine.
Sibling order is curated, not alphabetical — Supplements sits above Beauty & Personal Care because someone put it there. So order is a stored fact (RI‑561), and a gesture has to exist for it.
Dragging a row does both jobs, and which one it does is the part of the row you drop on: the top and bottom edges reorder (a line draws where the row will land), the middle reparents (the target outlines). That is Finder’s, VS Code’s and Jira’s convention, not a new one. Dropping below the last row promotes to the end of the top level, which no row’s own edge can express — the strip for it opens only while you are dragging. A node dropped onto itself or anything beneath it is refused during the drag, dashed red, rather than reported after it.
A parent carries its subtree, which is what makes the move worth having and also what makes it the most expensive gesture on the screen — see the open questions.
The keyboard is not an afterthought, and it is now the only gesture-free route. Native drag-and-drop has no keyboard path and no touch support, so every row’s grab handle is a real button. ↑ / ↓ reorder among siblings; → makes the row a child of the sibling above it and ← makes it the next sibling of its own parent. Focus follows the row.
Those four are complete, which is what let the Move under select go (MR‑320): outdent to the level you want, reorder to the sibling you want, indent under it. It is the outliner idiom, put on the arrow keys rather than on Tab — Tab in a list is focus, and hijacking it is hostile. The select was a second answer to a question the drag already answered, and it was the widest control on the panel; what it displayed — where the node sits — the breadcrumb above already says.
No library, and that is the repo rule rather than a preference. These mockups carry zero external dependencies, and listing-builder.html already hand-rolls native HTML5 drag-and-drop for the image slot grid. A tree needs one thing that grid did not — a drop between rows as against onto one — and that is a hit zone, not a dependency. For the real build the answer is different and is written down (RI‑564) so it is not re-argued.
It is a shared module, _reorder.js, because four lists needed it at once. The other three were the flat vocabularies — Form, Benefits and Sold for (since removed, MR‑345) — which have drawn a cursor: grab handle since MR‑317 with nothing wired to it. Three screens were promising a gesture none of them performed; that is fixed in the same round rather than logged.
RI‑552 logged that nothing renames a value in use. A tree is where that starts to hurt, because a rename is how a bad node gets fixed without orphaning the products on it. So the name is editable on any node and the consequence line says what it keeps.
The stored value is shown beside it and is read-only once anything carries the node. The name is a label; the stored value is what every export, filter and listing job reads, and moving it repoints those products at a value that does not exist. On a new node it is still open and follows the name until you type one — the propose-don’t-decide shape the Form control and _vocab-admin.js already use. RI‑542 is why the value is visible at all: it was a live defect caused by a stored value nobody could see.
Neither table here is a §12 grid. The tree is a list, not a column model — there is nothing to resize, and a rowspan-free hierarchy sorts by structure rather than by column, which is the one thing _grid-kit.js refuses to fake.
This screen used to carry a panel proving the two lists were different in data: the Sold for values the products on a branch carried, and where else each appeared (RI‑558). Sold for was removed on 2026-09-12 (MR‑345, RI‑649) because its values restated the Benefits list; research now reads every benefit. With no second list to contrast, the panel came out. The distinction it drew still holds and is now the one between Categories (what a product is) and Benefits (what it does), which nobody has confused.
SAMPLE-DATA.md is canonical and holds all fifteen nodes. Toys & Games and its two children exist because Paul asked for a taxonomy that outlives supplements — “you could have a category for toys or something like that, as we grow” — and because a fixture containing only supplements draws a screen that only works for supplements. It is also the empty branch: nothing is on it, and the screen has to read correctly anyway rather than as a gap.
Usage counts are over the eight catalog products — the seven on catalog-list-v2.html plus CP‑0102 Memopryl, whose two chips were already drawn on the product record. Counting is by product, not version, so Vapofil’s two rows count once.
1 · Nothing merges two nodes. Rename fixes a bad name and retire takes a duplicate out of the picker, but the duplicate’s products stay on it — Sleep Aids keeps its one product forever. A merge is reassign every product, then delete, and it is the operation that actually cleans a taxonomy up. Not drawn, because it is a bulk write over products and this screen has no product-selection model.
2 · Nothing enforces uniqueness of a stored value or of a name within a parent, which is the same gap RI‑552 left on the flat lists.
3 · The catalog list’s Category column is not this. — closed by MR‑345: the free-text column sat on the Sold for axis and was removed with it.
4 · A misdropped parent has no undo, and it is the one gesture whose blast radius exceeds its effort: dragging Health & Wellness one row too far moves seven products’ branch, and the only way back is to drag it again from memory. The flat vocabularies have the same gap and it matters less there, because a row is a row. An undo, or a confirm on a move that carries children, is the obvious answer and neither is drawn.
5 · Should a node carry a description? The listing prompts read the product, not the node, today. If a category ever carries copy guidance the node is where it would live, and nothing here has room for it.