toolshed/docs/implementation.md
2026-09-06 00:53:21 +02:00

60 KiB

Implementation Notes

Design rationale and non-obvious invariants that used to live as long inline comments, moved here so the code stays skimmable while the reasoning stays discoverable. Code comments reference these sections by anchor.

Content-Addressed Files & Caching

FileCache Design Rationale

fileCache (frontend/src/fileCache.js) is a shared, session-lifetime cache for the bytes behind AuthenticatedImage's src (a /media/... hash-addressed path; see backend/files/media_urls.py). Content there is immutable and hash-addressed (SHA-256), so a cache hit never needs revalidation — the same src string can only ever resolve to the same bytes, for any owner or requester.

It is deliberately NOT Vuex state: it holds Blob/object-URL data that nothing needs reactive access to (components only bind the resulting object-URL string, which they hold in their own local state), so a plain module-level Map avoids Vue's reactivity overhead entirely and sidesteps proxying Blob instances for no benefit.

SHA-256 File Hashing Must Match The Backend

Before uploading, the frontend computes a SHA-256 hash of a file's raw bytes via the Web Crypto API (crypto.subtle.digest('SHA-256', ...)). This hash must match the one the backend computes over the same bytes (backend/files/models.py, hashlib.sha256), so the hash computed client-side can later be used to identify the same File row server-side without a mismatch. This same computation appears in CameraFileSource.vue, DragDropFileSource.vue, FsFileSource.vue, and WebcamFileSource.vue.

Stale Orphan Cleanup At The Canonical Hash Path

FileManager.create() (backend/files/models.py) derives each file's upload path entirely from its content hash (hash_upload), and hash is unique in the database. That means if no File row currently owns a given hash, anything already sitting at that hash's computed storage path is necessarily a stale orphan — for example, left behind by a bug in cleanup code that deleted a File row without removing its stored bytes, or by a crashed upload.

Before saving a new File row, create() proactively deletes anything already at the expected path rather than letting Django's storage layer invent an alternate filename to avoid the apparent "collision". A suffixed name would silently break every part of the app that derives a file's URL purely from its hash (media serving, thumbnail generation, item/avatar attachment), and no future caller could ever discover the file again. backend/files/tests.py's test_file_upload_reclaims_stale_orphan_at_canonical_path is a regression test for this exact scenario.

RGBA Flattening Avoids Revealing Black Under Transparent Pixels

When generating thumbnails (backend/files/media_urls.py, thumbnail_urls), the source image is first converted to RGBA and composited onto an opaque white background before being flattened to RGB and saved as JPEG. This matters because some source modes (grayscale+alpha, palette-with-transparency, RGBA) store meaningless color or luminance data underneath fully transparent pixels, which is often zeroed out (i.e. black). Converting straight to RGB without compositing reveals that zeroed data as solid black instead of showing nothing, producing a thumbnail that looks broken even though the visible, opaque content is fine.

backend/files/tests.py's test_thumbnail_flattens_transparency_instead_of_going_black is a regression test built around an 'LA' (grayscale + alpha) source image whose transparent half has zeroed-out luminance underneath, reproducing exactly this failure mode.

Label Rendering Engine

QR Encoder Loading

anyd-qr.js's own loadAnyDCode() memoizes the wasm instantiation itself, so calling it more than once (each of Print.vue and LabelLayoutPreview.vue does, on mount) is free — label.js's module-level anyd variable just mirrors its resolved value so buildRenderTree can use it synchronously. Until it resolves, a QR-family leaf (any QR_LEAF_TYPES entry) throws (see encodeQr) the same way an oversized value already does — callers already have to handle layoutContent throwing, so this reuses that path rather than adding a second failure mode.

QR Leaf Type Mapping

QR_LEAF_TYPES maps each of label-layouts.js's LABEL_TEMPLATES leaf types that draw a code to the anyd-qr.js symbology/error-correction level (and, for rMQR, size strategy) it renders as (see anyd's EncodeOptionsecc/size — and its per-symbology EcLevel enums, wasm.rs's qr_ec/micro_ec/rmqr_ec/rmqr_size). Which combination a given label uses is baked into its layout tree (see label-layouts.js's "qr"-prefixed templates), rather than a single choice applied to every code leaf alike, so there's no longer a global selector for any of them (see Print.vue).

A plain symbology id (no suffix) always means anyd's own defaults — ecc "M", rMQR size "balanced" — every other value gets a "-<name>" suffix naming it:

  • ecc: the same letter anyd itself uses (qr_ec/micro_ec's L/M/Q/H), except micro-qr's "Detection" (MicroEcLevel::Detection, an M1-only error-detection-but-not-correction mode with no plain single-letter grade of its own). Coverage isn't uniform across symbologies (see qr_ec/micro_ec/rmqr_ec) — full QR takes all four grades, Micro QR swaps "L" (QR's actual lowest) for "Detection" (lower still, but M1-only) and has no "H" at all, and rMQR only ever supports "M" or "H".
  • size (rMQR only, see rmqr_size/SizeStrategy): "min"/"max" prefer the shortest (flattest, widest) or tallest (narrowest) symbol that fits the text, over the default "balanced" (smallest total module area) — which shape to prefer depends on which of the tape's two axes (across vs. along the feed) is more constrained.

rMQR's matrix isn't square (see encodeQr's width/height), unlike qr/micro-qr, which always are.

QR Module Matrix Shim

encodeQr wraps anyd's row-major Uint8Array matrix in a BitMatrix-alike shim ({width, height, get(row, col)}), matching the shape drawQrLeaf/snapQrToCrispSize expect — they predate this and were written against the old "qrcode" package's own modules.size/get() shape. anyd's matrix already excludes the quiet zone from width/height (see its ModuleMatrix type), same as the old library's BitMatrix. width/height are kept separate rather than a single size (the old library's own shape, always square) since rMQR symbols are rectangular.

Layout Tree Structure

A layout is a tree built from two shapes, alternating orientation by nesting depth:

  • An array is a "split" node: its children sit side by side (a row) at even depth (the root, depth 0, is always a row), or stacked (a column) at odd depth. To turn a row into a column, wrap it in an extra one-element array — that array is one depth deeper, so its lone child (the original row) is now read at odd depth.
  • An object is a leaf: {type, content} where type is one of QR_LEAF_TYPES' keys draws a QR/Micro QR/rMQR code at that id's symbology/error-correction level (see QR Leaf Type Mapping above), {type: "text", content} draws a text block — either way content is a function from the resolved field values to the string (or, for "text", an array of strings — one per line) to render. {type: "empty", "min-width": "2mm"} / {type: "empty", "min-height": "2mm"} is a spacer with no ink of its own — the only way padding/gaps enter a layout, since nothing here draws a border, margin or gap on its own. An "empty" leaf's dimension always names the axis its enclosing split flows along: "min-width" inside a row, "min-height" inside a column.

See label-layouts.js's LABEL_TEMPLATES for concrete trees.

Pixel Font Selection

Below 33px, a general-purpose sans-serif gets blurry/illegible, so drawTextLeaf switches to one of fontTierFor's bitmap-style fonts instead (see ../assets/fonts/pixel/LICENSE.md) — Silkscreen/Chava/Pixelon/Terminus across increasingly larger size bands, snapping to whichever fixed/hinted size each reads best at rather than whatever fontPx literally asks for. Unlike the top (Inter) tier, these are rendered through pixel-font.js's drawPixelText rather than canvas fillText — see Freetype Production Rendering below for why. At 33px and up, fontTierFor names Inter explicitly (see ../scss/_label-fonts.scss) rather than falling back to the CSS generic sans-serif keyword — that keyword resolves to a different real font per browser/OS, which would make the same label print differently depending on where it was rendered from.

Every tier was chosen only after rendering through prototypes/freetype-wasm/ladder.html — FreeType's own monochrome rasterizer at real canvas pixel sizes — and inspecting the actual pixels; checking that fillText was merely called doesn't confirm anything legible got drawn, and neither does a DPI-adjusted size that was never the number actually handed to the rasterizer. Two earlier candidates were tried and dropped: Tom Thumb's declared ascent/descent (0 / ~fontPx, backwards from a normal font) turned out not to be a centering quirk — its actual visible ink was only ~1/3.2 of its own nominal font-size — and PICO-8 rendered cleanly but has no lowercase glyphs at all (silently draws lowercase input as uppercase), ruling it out for real label content (item handles, URLs) that isn't reliably all-caps; see frontend/src/assets/fonts/pixel/LICENSE.md. Chava carries the same uppercase-only limitation but was kept anyway, restricted to a narrow size band, since PICO-8's dealbreaker didn't get applied consistently — see that same LICENSE.md.

Each tier's size (the actual px handed to the rasterizer) replaces the old Tom Thumb-only scale correction, generalized: fontPx (the layout-derived logical size, compared against each branch's threshold) still drives box centering/stacking regardless of what size a tier resolves to. Those thresholds and MIN_TEXT_PX are both compared against that same logical, unscaled fontPx, deliberately not adjusted for the tape's dpi: a browser's font rasterizer only ever sees a raw pixel count, with no notion of "physical size" at all, so that's what determines whether a glyph's fine detail survives — confirmed by real-Chromium testing, where a raw 4.35px render was a solid blob regardless of what a dpi-scaled version of that number would have implied.

A tier's optional tracking retunes glyph spacing (drawPixelText adds it to every glyph's advance) without ever touching the rendered glyph size.

Freetype Production Rendering

Canvas 2D's fillText is always anti-aliased — there is no monochrome/hinted-bitmap text mode in the API, however carefully a glyph's size/position are snapped to the pixel grid — and weblabel.js's bitmap.js hard-thresholds the whole canvas at 50% luminance before it ever reaches a printer (a pin either fires or it doesn't). Anti-aliased glyph edges surviving into that threshold read as stray or missing pixels, not a clean bitmap-font shape. pixel-font.js's drawPixelText sidesteps this for every fontTierFor tier below Inter by rendering through the same FreeType-wasm rasterizer ladder.html uses to pick those fonts in the first place (FT_LOAD_TARGET_MONO, true 1-bit output) and blitting the result at integer pixel offsets — measureLine's pass computes a line's real ink extent (advance width, max ascent/descent actually reached by these glyphs) the same way drawTextLeaf's old ctx.measureText(...).actualBoundingBox* did, so centering math is unchanged; only where the pixels themselves come from is different. Inter (the one non-pixel tier) still uses fillText — anti-aliasing is expected/fine at that size, the same reasoning weblabel.js's own text renderer relies on ("what stops small text turning to mush is not the threshold but the size").

preloadPixelFontRenderer() (re-exported from label.js, defined in pixel-font.js) loads freetype.wasm and fetches every bitmap tier's font bytes up front, the same pattern as preloadQrEncoder/anyd (see QR Encoder Loading) — drawTree/drawTextLeaf stay fully synchronous, so both Print.vue and LabelLayoutPreview.vue await it (alongside preloadQrEncoder) before their first redraw(), and drawPixelText throws the same "still loading" shape of error encodeQr does if a draw is ever attempted before it resolves. The wasm module holds exactly one font face loaded at a time (ft_load_font replaces whatever was there); ensureFace skips the reload when consecutive leaves already share a family.

Affine Width Height Relations

Every node's width/height relate to each other affinely — width = A*height + B for a node read in row context, height = A*width + B in column context — because a leaf is either scale-free (a text block, whose aspect ratio holds at any size: A = aspect or 1/aspect, B = 0) or a fixed physical size (an "empty" spacer, or a QR code once its crisp pixel size is known — see Crisp QR Sizing: A = 0, B = the size in px). Splits combine their children's relations by addition (a row's total width is the sum of each child's width for the shared height, and symmetrically for a column), which stays affine, so the same two numbers describe a whole subtree no matter how deeply it nests.

ownAxis is true if the split directly containing node is a row, false if a column — for a leaf, that's what "empty" measures itself against; for a split, its own axis (and thus how it combines its children) is always the opposite, per the alternating-depth rule. wantWidth is true to ask for {A, B} such that width = A*height + B, false for height = A*width + B; requesting the direction a split doesn't naturally combine in just inverts its own relation.

Fixed Size Relation Edge Case

In relation(), when every child is a fixed size (a === 0) in the combining direction — e.g. a row that's just one crisp QR leaf, with no scale-free (text) sibling to invert against — inverting "width = b" for an a of 0 would divide by zero: a constant width genuinely doesn't determine a height, since nothing here actually scales with it. Instead, ask each child directly for its own size in the wanted direction (every one of them must be similarly fixed, since only a fixed leaf ever contributes a === 0), and take the largest — the shared dimension has to fit whichever child needs the most room, with any child that ends up with room to spare centered within it (see drawQrLeaf).

Crisp Qr Sizing

A QR code needs an integer number of pixels per module to render crisply rather than blurring at a fractional scale, so its true size is whatever that rounds down to — almost never the scale-free box its aspect ratio alone would suggest. snapQrToCrispSize, called once every QR-family leaf has a provisional (scale-free) box from a first layoutTree pass, pins each one's real box.width/box.height as crispWidth/crispHeight, so relation() starts treating it as a fixed size, the same as an "empty" leaf, instead of one that scales with whatever height/width it's offered. A second relation()/layoutTree() pass (see Label Content Layout) then resizes everything else around that real footprint, so nothing downstream reserves — and leaves unfilled — room for a squarer/differently-shaped code than what actually gets drawn. Kept as two independent dimensions rather than one crispSize (as when every code here was a square QR) since an rMQR symbol isn't square — see encodeQr.

Render Tree Construction

buildRenderTree turns a resolved content tree (see templateContent — leaf objects carry a value rather than a content function) into one ready for layout: a QR-family leaf gets its actual encoded modules (see encodeQr, keyed off the leaf's own type via QR_LEAF_TYPES) and an aspect ratio taken from their real width/height — 1 (square) for qr/micro-qr, but not for rmqr, whose symbols are rectangular — a text leaf gets its measured natural aspect ratio, and an "empty" leaf passes through untouched. Multi-line text (value is an array) measures as one leaf, not one per line — splitting it into a column of independently-sized leaves would let each line grow to its own full width, ending up at a different font size than its neighbors, which is legible but not what "one text field" should look like.

Canvas Font Loading

Applies only to Inter, the one fontTierFor tier still drawn with fillText (see Freetype Production Rendering above — every other tier goes through pixel-font.js instead, which fetches its own font bytes directly and has no @font-face/document.fonts involvement at all). A @font-face family already in use elsewhere on the page loads in time for drawTextLeaf's use, but canvas text silently falls back to the next font in the stack (there isn't one here, so the browser default) if drawn before its first-ever load finishes — unlike DOM text, a canvas fillText never waits or repaints on its own once the real font arrives. Kicking off document.fonts.load() there means only that very first draw at a given size risks the fallback; every redraw after it (Print.vue's live preview redraws on every keystroke) picks up the real font.

Label Content Layout

layoutContent builds, sizes and validates the tree for a fixed fixedSize (the tape's cross-web printAreaPx, or the fallback preview's reference height) — the one dimension every layout scales from, plus pxPerMm to turn "empty" leaves' physical sizes into pixels. fixedSize and the tree's content fully determine its overall size along the other, growing axis (the one that runs along the tape as it feeds); maxLength, when finite (a fixed-length/die-cut tape), rejects content that doesn't fit rather than shrinking it.

orientation picks which axis fixedSize binds to: "along" (the default) fixes the tree's height — the tape's cross-web width — and grows its width along the feed direction, same as a plain read top-to-bottom design. "across" fixes the tree's width instead and grows its height, so the design is built turned 90deg from how it'd read "along"drawLabel/drawFallbackLabel are what actually rotate the drawing back into the physical raster's fixed orientation; nothing here needs to know about that rotation, since relation()/layoutTree() already solve the tree in either direction symmetrically.

Sizing runs twice: a first pass treats every QR-family leaf as the scale-free box its real width/height ratio suggests, purely to find out how much room each one would actually be offered; from that, snapQrToCrispSize pins each one's real (smaller, crisp-pixel) size. The second pass then resolves the whole tree again with that real size fixed in, so every sibling and the overall size reflect what's actually drawn rather than the idealized box no code ever quite fills.

Tape Fed Label Drawing

drawLabel is the tape-fed layout — it draws a fully resolved content tree (see templateContent) at the tape's real pixel dimensions. orientation is "along" (the default) to lay the design out reading along the tape's feed direction, or "across" to turn it 90deg so it reads across the tape instead — either way the physical raster this returns is still exactly printedLength x tape.printAreaPx (that's fixed by the tape/print head, not a choice this makes); "across" just draws the (now width-fixed, see Label Content Layout) tree through a rotated canvas transform so it lands correctly in that same raster, rather than transposing every box the tree itself computed. See DEBUG_LEAF_BORDERS to outline every leaf's box.

Across Orientation Rotation

When drawLabel draws with orientation === "across", the tree was solved width-fixed (see Label Content Layout) — its width already exactly fills tape.printAreaPx, so only its (growing) height needs the same along-the-feed centering origin got earlier; translate+rotate then carries that tree-local (x, y) box straight into the physical (printedLength x printAreaPx) raster, a quarter turn at a time.

Fallback Label Preview

drawFallbackLabel is the no-webusb preview/PNG — same layout tree and renderer as drawLabel, just scaled from a fixed reference height instead of a real tape's, and with no maxLength (there's no physical tape to run out of, so the canvas just grows to fit) and no printer feed margin, since there's no real print head here to keep clear of.

Label Templates, Preview & Pixel Fonts

Template Layout Tree

A template's layout is a tree as described in label.js, with leaves whose type is one of label.js's QR_LEAF_TYPES keys or "text", and whose content is a function from the resolved field values (see label.js's buildLabelFields) to what they render. null/undefined returned from that function means the field isn't available yet (see templateIsAvailable). A template is only selectable once every leaf's content resolves to a value.

Generated QR-Only Template Matrix

QR_ONLY_TEMPLATES is the full "just the code" matrix: every {symbology, error-correction level, [rMQR] size strategy} combination label.js's QR_LEAF_TYPES supports, one template each. A plain id (no suffix) is always anyd's own defaults: ecc "M" and, for rMQR, size "balanced". Coverage isn't uniform (see QR_LEAF_TYPES): full QR gets all four ecc grades L/M/Q/H; Micro QR swaps "L" (QR's actual lowest) for the even-lower, M1-only, detection-only "Detection", and has no "H" at all; rMQR only ever supports ecc "M" or "H", each crossed with all three size strategies (balanced/min/max). The matrix is generated (rather than hand-writing every near-duplicate entry) so that a symbology/level/size combination it's missing is one new row here, not a new block to keep in sync with its neighbors. id doubles as the layout's leaf type, since that's exactly what QR_LEAF_TYPES is keyed by.

Content Kinds Registry

CONTENT_KINDS/CONTENT_KINDS_BY_ID (label-layouts.js) is the single registry for everything that varies per content kind (currently item/storage-location): the handles-and-shortids.md kind letter (typedPrefix), the short-id.js schema name to use for an individually- vs. group-owned thing of that kind (shortIdKind/groupShortIdKind), the field name short-id.js's serializeShortId expects for its local id (localIdField), and a builder for the kind's long-form URL (buildUrl, undefined for a future kind added with no such route - see the url DERIVED_VARS entry). Every place that used to special-case "item vs. storage location" - label.js's prefill builders, label-layouts.js's DERIVED_VARS, Print.vue's shortId() - reads this one table instead, keyed by the Content card's kind field (a <select>, see Print.vue) rather than inferring which kind is meant from which id-shaped field happens to be populated.

Derived Vars Shape And Ordering

A derived var (DERIVED_VARS) is a format string calculated from other vars rather than typed directly - it doesn't get its own input, just a read-only, live-recalculated display next to the ones that do (see Print.vue and withDerivedVars). Its inputs name every var (base or, in principle, another derived one - e.g. qualifiedHandle/url, which both read the derived userHandle, and qualifiedHandle also reads the derived typedPrefix) that calc reads. inputs is declared up front rather than inferred from calc's body so BASE_VARS can include a var like "webdomain" that only feeds a calculation and that no template ever references directly. Declaration order matters here: withDerivedVars runs these in a single pass, so a derived var must be declared after every other derived var it depends on - typedPrefix before qualifiedHandle, in particular.

Kind And Id Known Vars

BASE_VARS is computed purely from KNOWN_VARS (every template's required_vars, see templateIsAvailable) plus whatever DERIVED_VARS reads - there's no separate registry of "fields a prefill might supply". kind and id reach BASE_VARS (and so the Content card's form, and fields()) the same way any other var does: named directly in several templates' required_vars, and kind also as an input of the derived typedPrefix. A content var meant to be prefillable/typeable needs that same anchor - at least one template naming it in required_vars, or some DERIVED_VARS entry reading it - or fields() (which only copies BASE_VARS keys out of varValues) silently drops it.

Print View

Tape-Full Print Margin

The tape-fed preview draws two representations of the tape: .tape-full (in the template, wrapping .label-preview/the canvas) spans the tape's entire physical width (tape.mediaWidthMm), while the canvas inside it is only printAreaPx wide. A label printer's print head can't mark all the way to a tape's outer edges, so the true printable area is narrower than the tape itself, by an amount that isn't a fixed or predictable fraction of the tape width. .tape-full centers the canvas within itself (equal margin on both sides) and applies a faint background tint to that margin, so it reads as real, if unprintable, tape rather than empty page space. Because of this gap, the vertical ruler (verticalRulerTicks) is deliberately built from tape.mediaWidthMm, not from printAreaPx/dpi — ticking off only the printable width would run past the edge of the visible tape. The ruler's container is sized from mediaWidthMm too (see the template's inline height), so ticks can never run past what's actually drawn.

SHORT_ID_VAR ("shortId"), DOMAIN_SHORT_ID_VAR ("domainShortId") and SHORT_URL_VAR ("shortUrl") are Print.vue-local additions on top of label-layouts.js's own DERIVED_VARS. Resolving any of them needs the current identityIdByHandle map (populated by store.js's fetchIdMap) to turn a handle into the numeric owner_identity_id that short-id.js encodes — that dependency on Vuex state means none can be a pure fields-to-value calculation like the rest of DERIVED_VARS, so all three are computed here instead (see the shortId method and the fields computed). They're excluded from baseVars, unlike every other entry KNOWN_VARS normally picks up from a template's required_vars, because none is ever typed directly into the form - SHORT_URL_VAR/DOMAIN_SHORT_ID_VAR are filtered out again in the baseVars/derivedVars computed even though some template's required_vars puts them in BASE_VARS, since neither is actually a label-layouts.js DERIVED_VARS entry.

These target three scopes, by how much context a reader needs before the value is useful at all: SHORT_ID_VAR is the bare short-id.js token (e.g. "~AbCd12") with no domain or leading "/" - Home-Instance scope, only meaningful to a reader already logged in as the identity it was minted for (printed by "internal"-tagged templates); DOMAIN_SHORT_ID_VAR prepends the printing user's own home domain (homeDomain + ":" + shortId, e.g. "a.example.com:~AbCd12") with still no URL wrapper - Any-Instance scope, resolvable by any Toolshed frontend's own scanner/resolver via a cross-domain lookup (short-id.js's isDomainQualifiedShortId/decodeDomainQualifiedShortId, see Domain-Qualified Short ID Resolution below) regardless of who's logged in there; SHORT_URL_VAR wraps that same bare shortId in webdomain as a clickable URL. webdomain only picks which frontend loads - it says nothing about which backend the data lives on, since that's decided entirely by whoever ends up logged in once it does - so SHORT_URL_VAR shares SHORT_ID_VAR's Home-Instance trust requirement (same identity, just a tap instead of typed/scanned into an already-open session), not a step up to a universally-openable link. The actual zero-context scope is served elsewhere, by the long-form Item/Location URL (url, see Content Kinds Registry above), which resolves by handle rather than by the opener's own idmap.

Libweblabel Served Unbundled

libweblabel.js is served verbatim from public/vendor/ rather than bundled by Vite. Its own emscripten glue resolves its .wasm sibling relative to its own import.meta.url at runtime, so both files need to keep sitting together, unhashed, at a stable URL rather than a Vite-fingerprinted asset path.

Concurrent Wasm Loading

preloadQrEncoder() and preloadPixelFontRenderer() (see Freetype Production Rendering) are both kicked off in mounted() without an immediate await, so they load concurrently with each other and with MultiPrinterBlob.load() instead of serializing three independent wasm fetches. Every redraw()/redrawFallback() call still waits on both (via fontsReady, itself Promise.all([qrReady, preloadPixelFontRenderer()])) before drawing, since a qr/mqr/rmqr leaf throws (see label.js's encodeQr) until the encoder has finished loading, and a bitmap-tier text leaf throws the same way (see drawPixelText) until the font renderer has.

Ruler Tier Selection

RULER_TIERS controls how far apart plain ticks and labeled/major ticks sit, both getting coarser the longer the ruler runs — tightly spaced ticks (and their labels) get too cramped to read or render once there are enough of them. The list is ordered smallest threshold first; rulerTier picks the last entry whose aboveMm the ruler's own length clears, so a finer or coarser tier is added there rather than growing a pile of separate constants. Every tier's majorEveryMm is a multiple of its own tickMm, so major ticks always land on a tick that's actually drawn. rulerTier itself is a single shared value keyed off whichever axis (horizontalTotalMm/verticalTotalMm) is physically longer, so a long label's ruler never ends up coarser or finer than the tape-width ruler right next to it just because the other axis happens to be shorter — both rulers coarsen together once either axis needs it.

Fields Computed: Dropping Blank Values

The fields computed builds the named content fields templates draw from: the form's own base vars, plus every DERIVED_VARS format string calculated live from those, so typing a userHandle/kind/id (by hand or via prefill) recalculates url/qualifiedHandle the same way either way. A blank or uncalculated value is dropped entirely rather than passed through as an empty string, so it reads as absent to templateIsAvailable/templateContent the same way a prefill that never supplied it would — that's what LabelLayoutPreview.vue greys a template's thumbnail out on.

ShortId Resolution

The shortId method builds the same shortened link Inventory.vue's and StorageLocation.vue's own shortIdLink build for one of their rows, but returns the bare token (see short-id.js's encodeShortId) rather than a router target or full URL — shortenedRoute's leading "/" is stripped since this is plain display/label content, not something this view itself navigates to. Which short-id.js kind applies depends directly on the Content card's kind field (via CONTENT_KINDS_BY_ID, see Content Kinds Registry above), not on which id-shaped field happens to be populated — so hand-typing a userHandle+kind+id with no prefill at all resolves exactly the same way a prefilled print link does. It falls back to no value, the same as an unresolved DERIVED_VARS entry, until identityIdByHandle has loaded (see mounted's fetchIdMap) or if the handle isn't in it.

DOMAIN_SHORT_ID_VAR reattaches the homeDomain computed specifically — the printing user's own home domain (state.user's domain half), not derived.domain (the printed thing's own owner handle domain, which for a group-owned print is the group's domain, not necessarily the printer's). This matters because owner_identity_id/owner_group_id are resolved via identityIdByHandle/groupIdByHandle, both sourced from state.idmap, which always loads from the printing user's own home server (getHomeServers) regardless of whose item is being printed — so the domain a Domain-Qualified Short ID needs to carry is always the printer's home domain, the one whose KnownIdentity/Group numbering the encoded ints are actually scoped to (see docs/handles-and-shortids.md#domain-qualified-short-id). Getting this wrong (e.g. using derived.domain for a group print) would silently mint a token that resolves to the wrong backend, or to nothing, once scanned by someone else.

Domain-Qualified Short ID Resolution

A Domain-Qualified Short ID (<domain>:~<token>) resolving to something other than "the app I'm currently in" needs a real network round-trip, since the token's ints are only meaningful to whichever backend minted them (see docs/handles-and-shortids.md#domain-qualified-short-id). router.js's domainQualifiedRoute(domain, ints) is the single chokepoint every entry surface (the /:short_id route's beforeEnter guard, ShortId.vue's resolveIfQualified, Scan.vue's describeForeignShortId) funnels through: if domain matches the caller's own home domain (store.state.user's domain, read via isLoggedIn first to force its lazy hydration — see getHomeServers's own comment on the same ordering issue) it resolves locally via the existing idmap-based expandedRoute, exactly like a bare token; otherwise it dispatches resolveShortId (store.js), which SRV-discovers domain's backend (getFriendServers) and calls its new GET /api/v1/resolve_short_id/<kind>/<owner_id>/<local_id>/ (backend/toolshed/api/inventory.py). That endpoint answers "what does this owner/local id pair mean" against its own KnownIdentity/Group numbering, applying the exact same friend-or-self/membership and availability_policy checks InventoryItemViewSet.get_queryset() already does (see Owner-Handle Scoped Routes below) — it only differs in being keyed by numeric owner id instead of a handle string, since the caller doesn't have a handle yet, that's exactly what it returns ({handle, id}). Only the four owned short-id kinds (item/group_item/storage_location/group_storage_location) are wired up; group/category/file/workflow return 400 and the frontend falls through to ShortId.vue's debug view.

Fit-Zoom Preview Scaling

fitZoom fits the preview to its card without ever needing a horizontal scrollbar for a label this small: it magnifies short labels up to MAX_ZOOM rather than showing them at native (tiny) size, and never lets the preview grow past MAX_PREVIEW_HEIGHT_PX tall however long or wide the label itself runs. It returns the zoom actually used, so callers that care (redraw's ruler bookkeeping) don't have to re-derive it. When magnifying, the zoom is rounded down to a whole number rather than rounded or ceiled: image-rendering: pixelated only looks crisp when every source pixel maps to the same number of screen pixels, and at a fractional zoom (the common case, since the raw zoom is just whatever ratio the tape/card happen to produce) some source pixels get rounded up to one extra screen pixel and others don't, unevenly warping fine, already-pixel-perfect detail like a crisp QR module or a tiny bitmap font glyph. Flooring keeps the same "never bigger than available space" guarantee the raw zoom already had. Shrinking (zoom < 1) has no equivalent whole-factor snap to make, since downsampling always blends source pixels, so it's left as-is.

Mm Ruler Layout

The tape-fed preview's mm ruler is a horizontal track above the canvas and a vertical one to its left, both ticked in real physical millimeters rather than preview pixels, since what they're measuring is the actual label.

Preview Track No-Scroll Invariant

.preview-track holds the horizontal ruler and the canvas, and is deliberately never scrollable (no overflow-x: auto): fitZoom's zoom always satisfies canvas.width * zoom <= available, so the canvas can never actually be wider than this has room for, and a scrollbar here would let the ruler and canvas drift apart (or just look broken) for no reason. min-width: 0 only lets this flex item shrink to the card's real available width — it doesn't enable scrolling.

Label-Preview Override In Preview Track

.preview-track .label-preview overrides the standalone .label-preview rule: nested here, it must neither scroll nor center its canvas. overflow-x: visible (never auto) rules out a second, inner scrollbar, and text-align: left keeps the canvas flush with the ruler's zero tick instead of drifting to the middle of whatever spare width the card has. padding: 0 makes the canvas's own edges exactly this box's edges too, so the ruler's ticks line up with those same edges — any padding here would leave the ticks and the actual canvas misaligned.

Routing, Handles & Short IDs

Escaping Hash in Handles for URL Path Segments

router.js's encodeHandleForUrl/decodeHandleFromUrl embed/extract a handle in a URL path segment. # starts a URI's fragment component, so a group handle (#name@domain) or classification handle (origin#type:name) can't appear unescaped in a path segment. + stands in for # there instead of the usual %23 - safe to reverse unambiguously because every field a handle is built from is already required to exclude + (see docs/federation.md's "Reserved characters" and its "Embedding a #-bearing handle in a URL" section). The canonical handle itself never changes; this only affects how one gets embedded in, or read back out of, a URL path segment.

Owner-Filtered Overview Routes Use Path Segments, Not Query Strings

/inventory/:owner? and /storage-location/:owner? (Inventory.vue/StorageLocation.vue's owner prop) filter the overview to one owner - the caller, a group, or a friend - using the same handle shape/escaping as /groups/:handle (see Escaping Hash in Handles above). /inventory/new/:group? and /storage-locations/new/:group? (InventoryNew.vue/StorageLocationNew.vue's group prop) prefill the owner picker the same way when arriving from a filtered overview. All four are optional trailing path params (:name?), the same style as /workflows/:id/:phase? - deliberately not a query string: vue-router's query parser treats a bare + as an encoded space (the x-www-form-urlencoded convention), so the #->+ swap that works fine in a path segment would silently corrupt a group handle passed as ?owner=... instead.

These coexist safely with their more specific neighbors (/inventory/new/:group? next to /inventory/:owner? and /inventory/:handle/:id; /storage-location/:owner? next to the plural /storage-locations/... routes) because vue-router's matcher always ranks a route's more literal/static segments above a dynamic one at the same depth, regardless of declaration order - e.g. /inventory/new resolves to the static new segment, never :owner on /inventory/:owner?.

ownerOverviewRoute/ownerLocationOverviewRoute (router.js) only ever pick between the caller and their own group, never a friend, since a friend's items/locations are read-only (see Perform Update Rejects Non-Owned Items Explicitly below).

/i/:handle/:id and /s/:handle/:id are the self-contained label/short-link entry points for an item and a storage location respectively (see label.js's buildLabelContent/CONTENT_KINDS_BY_ID, whose buildUrl for each CONTENT_KINDS entry builds exactly this shape, and docs/design-in-progress/items-labels.md). :handle is already URL-escaped the same way /inventory/:handle/:id//storage-locations/:handle/:id expect it, so each is just a shorter alias for its own detail route, with no owner-is-the-viewer special case: InventoryItemViewSet.get_queryset()/StorageLocationViewSet.get_queryset() (see Owner-Handle Scoped Routes below) already treat "it's the viewer's own item/location" as one case of "the viewer may see this owner's item/location," not a separate path.

beforeEnter Guard vs redirect for /:short_id

This route uses a beforeEnter guard, not redirect: redirect is called synchronously and its return value is used as-is (never awaited), and it also must resolve to a valid location on every match (an unresolvable one throws, see vue-router's handleRedirectRecord) - it can't itself wait on fetchIdMap (see NEEDS_IDMAP) for the item/group_item kinds whose owner handle isn't resolvable from the token alone. A guard can return null/undefined to mean "proceed to the component instead," which is exactly what's needed here: when expandedRoute can't resolve yet (or ever - an unrecognized kind), stay on this same URL and mount ShortId.vue in place, which has full component-lifecycle async support and takes it from there - fetch idmap, retry, redirect once resolved, or keep showing the decode view. Vue Router guards support returning a Promise, not just a value, which is what makes this the right chokepoint for a Domain-Qualified Short ID too (see Domain-Qualified Short ID Resolution above): the guard is async, and for a foreign domain it awaits domainQualifiedRoute (a real network round-trip) before deciding whether to redirect or fall through to ShortId.vue, the exact same "resolve or fall through" shape as the synchronous idmap case.

Federation, Identity, Friends & Groups

Group Invite And Accept Self-Certifying Verification

verify_incoming_group_invite(request, raw_request_body, handle_field, key_field) is a self-certifying verifier for the two legs of the group invite/accept dance that land on a backend which doesn't have the caller cached as a KnownIdentity yet (see docs/design-in-progress/groups-mvp.md): the inviter delivering an invite to the invitee's own backend (handle_field='inviter', key_field='inviter_key'), and the invitee accepting on the group's home backend (handle_field='invitee', key_field='invitee_key'). It mirrors verify_incoming_friend_request exactly, just with configurable field names so the same logic serves both legs.

Group Membership Is Recorded On Both Sides, Like Friendship

Accepting a group invite used to only ever write membership on the group's own home backend (Group.members, via acceptGroupInvite); the invitee's own home backend just deleted its now-obsolete GroupInviteIncoming and forgot the invite ever happened. That meant a member's own backend had no way to answer "which groups is this identity in" for a group hosted elsewhere. recordGroupMembership (POST /api/v1/groupinvites/<pk>/accept/, local-auth) closes that gap: it's the second leg of the accept dance, called by the client right after the group-home-backend accept succeeds, and it converts the GroupInviteIncoming into a durable GroupMembership pointer (just user, group_name, group_domain — no roster, since this backend isn't authoritative for the group) instead of discarding it. GET /api/v1/groupmemberships/ (also local-auth) lists a user's own pointers; Groups.vue's "Other groups you're a member of" section is this list, filtered against the locally-hosted groups to avoid double-listing. This is deliberately the same shape as the friend-accept flow's second POST (see below): each side's own home backend independently records the relationship in its own local table, so neither side depends on the other's backend being reachable later to know it happened.

Owner-Handle Scoped Routes

InventoryItemViewSet/StorageLocationViewSet (toolshed/api/inventory.py) address every route — list/create/retrieve/update/destroy — by an owner handle URL parameter, resolved by resolve_owner_handle(handle) into (owner_user, owner_group). There's deliberately no bare, handle-less route: id is only unique within its own owner/owner_group scope, so a route spanning more than one such scope could match two different objects sharing a local id, raising MultipleObjectsReturned.

Detail routes (retrieve/update/destroy) still address the object by its owner-scoped id, not the internal row id. The router names the URL capture group pk, so lookup_url_kwarg stays 'pk' — only lookup_field changes, to 'id' — this is not a mismatch to "fix"; renaming lookup_url_kwarg to match would break the URL conf, which still names the capture group pk.

Perform Update Rejects Non-Owned Items Explicitly

Since get_queryset() (see Owner-Handle Scoped Routes above) can now return a friend's items/locations (read-only) on the same route, an object found there isn't necessarily writable. perform_update on both InventoryItemViewSet and StorageLocationViewSet checks _is_authorized and raises PermissionDenied explicitly, rather than silently no-op, which returning without saving would otherwise do.

Friend Request and Accept Protocol Flow

The friend request/accept flow spans two backends (A hosts x@A, B hosts y@B):

  1. x@A sends a friend request to y@B:
    1. x@A's client POSTs to A/api/v1/friendrequests/ with {from: x@A, to: y@B}.
    2. A's backend creates a FriendRequestOutgoing, containing x@A's identity and y@B's name.
    3. x@A's client POSTs to B/api/v1/friendrequests/ with {from: x@A, to: y@B, public_key: x@A's public key}.
    4. B's backend creates a FriendRequestIncoming, containing y@B's and x@A's identities.
  2. y@B accepts the friend request:
    1. y@B's client POSTs to A/api/v1/friendrequests/ with {from: x@A, to: y@B, public_key: y@B's public key}.
    2. A's backend matches the data to the FriendRequestOutgoing object, deletes both, and creates a Friend object containing x@A's and y@B's identities.
    3. y@B's client POSTs to B/api/v1/friends/ containing the id of the FriendRequestIncoming object.
    4. B's backend creates its own Friend object, using the identities from the FriendRequestIncoming object.

Fetch Item By Handle: Group vs Personal Handles

fetchItemByHandle in store.js branches on whether the handle is a group handle (leading #) or a personal/friend handle. A personal/friend handle goes through fetchForeignItem, which GETs the item directly by owner handle (see Owner-Handle Scoped Routes above). A group handle has no equivalent single-item GET route, so it resolves via fetchGroup (domain-routed by the handle, see GroupDetail in toolshed/api/group.py, addressed by handle directly) followed by the already-correct, already-authenticated group listing (fetchGroupInventoryItems), picking the matching item out of that list — which only ever contains this one group's own items, so an id collision with anything else can't happen.

Workflows Architecture

Workflow Catalog

frontend/src/workflows.js is the single source of truth for every workflow type known to the frontend: what it's called, what category/description/icons it has, how many steps it has and what they're called, what its initial payload looks like, and which Vue component renders it.

Each workflow has a fully co-located component + metadata as a static meta option on the component (Component.meta, right next to name/props/etc.) in @/components/workflow/workflows/*.vue - workflows.js simply imports those components and reads .meta off of them to build the catalog.

This replaces a previous design of a parallel BaseWorkflow class hierarchy (metadata) plus a separate per-step ComponentRegistry.js (components) - both concerns now live in one flat array with each component responsible for its own metadata and UI implementation.

Workflow Payload Is An Opaque String

The backend stores WorkflowInstance.payload as an opaque string - it never parses or understands it as JSON. The frontend is fully responsible for serializing it before sending and deserializing it after receiving.

Workflow meta Co-location

Workflow metadata (title, category, description, icons, step definitions, initial payload) is co-located with its implementation as a static meta property on the workflow's Vue component, so there is a single source of truth per workflow type. It's consumed by @/workflows.js (via Component.meta) to assemble the catalog used by the Workflows and WorkflowDetail views.

Bulk Item Import Workflow

BulkItemImportWorkflow.vue implements every step of the import-items workflow in a single component. Steps 1 (File Upload) and 6 (Import Items) have fully custom UI; the remaining steps (2-5, 7) currently fall back to a generic "in progress" placeholder driven by this workflow's own step metadata, and can be fleshed out later without touching any other file.

Staged Photos Are The Durable State

In FotoFirstBulkImportWorkflow.vue, photos' durable state is the WorkflowInstance.staged_files relation itself, kept in sync directly by stageFile()/unstageFile() rather than by writing to payload. On load, photos is seeded from the workflowInstance prop, not from payload. Entries restored this way have no local bytes yet (this session never uploaded them), so dataUrl stays null and the gallery falls back to fetching a thumbnail by hash via AuthenticatedImage. Symmetrically, when a photo is newly staged, it's persisted server-side right away (keyed to the workflow instance) so it survives a reload or a switch to another device - nothing about photos needs to go into payload too.

Thumbnail Lookup By Hash

thumbnailPathForHash() in FotoFirstBulkImportWorkflow.vue builds a derived storage path mirroring hash_upload() (files/models.py), matching how FileSerializer.name already builds file URLs elsewhere in the app (e.g. AuthenticatedImage's src for item files). files/media_urls.py's thumbnail_urls generates and disk-caches a resized JPEG at that path on first request, since a gallery card only needs a small image, not the full-size original.

Once a photo finishes uploading, the gallery switches it to the server-fetched thumbnail by re-looking it up by hash rather than mutating the closed-over photo object from the upload closure - that reference predates the this.photos.push() call, so it's the raw object, not the reactive proxy Vue tracks, and writing to it wouldn't trigger a re-render.

Bulk Label Print Workflow

BulkLabelPrintWorkflow.vue implements the bulk-label-print workflow: step 1 collects a kind/owner/id range plus a label layout (reusing LabelLayoutPreview.vue for the template grid, previewed against the range's first id); step 2 connects a printer (or falls back to PNG downloads, same device-chip/PNG-Export pattern as Print.vue) and, on start, sequentially renders and prints/downloads one label per id in the range via label.js's drawLabel, logging each id's outcome. MAX_RANGE_SIZE caps a single run so a mistyped range can't queue an unbounded number of labels.

Foto First Bulk Import Workflow

FotoFirstBulkImportWorkflow2.vue implements every step of the foto-first-bulk-import workflow (photo capture, image processing, item detail entry, import completion) in a single component. Keeping the whole workflow in one file avoids splitting closely related state (photos, processed images, completed items) across many small step components and their prop/emit boundaries.

Staged Files Are Identified By Hash Alone

A staged file's SHA-256 hash is enough to identify it, since the client computes content hashes the same way the backend does and can fetch bytes from a hash-derived storage path. This underpins several places in the workflow file-staging API:

  • WorkflowInstanceSerializer.staged_files (backend/toolshed/serializers.py) exposes only hashes, not a fuller FileSerializer representation like InventoryItemSerializer.files uses - for a file staged by this session there's nothing more to say, and for one staged elsewhere (another device/tab), the hash is what lets this session recognize and fetch it.
  • get_staged_files() (backend/toolshed/api/files.py) returns the same bare hash list, useful mainly for discovering what another session/device already staged on a workflow.
  • post_item_file()'s file_hash branch (backend/toolshed/api/files.py) lets a caller attach a file already staged on one of their own workflows by content hash, instead of re-uploading bytes already stored server-side. Workflows are always personally owned, so this only applies to a caller with a local account.

Offline Data Export & Import

File Naming Convention In Exports

Files attached to a user's inventory items are yielded by inventory_files() as (arcname, data) pairs, deduplicated by content hash and placed under a files/ subfolder in the export zip, e.g. files/<hash><ext>. The extension is guessed from each File's mime_type so attachments and images remain viewable once extracted, rather than sitting as extension-less blobs. This same files/<hash><ext> convention is used for the profile picture and is what available_files (passed into import_profile() and import_inventory()) is keyed by.

Profile Import Semantics

import_profile() is a fault-tolerant importer for profile.json. Only first_name, last_name, email, and profile_picture are applied to the account; profile_picture is only set when its value is a files/... path present in available_files (see File Naming Convention above). username and domain are ignored even if present in the payload, since they identify the account itself and cannot be changed by an import.

Account & Data Deletion

delete_user_data() permanently deletes everything that the data export covers, while keeping the account itself intact: inventory items (hard delete, bypassing soft-delete), storage locations, account preferences, the friends relation on the user's public identity, the profile picture, and any File blobs that become orphaned as a result. A File is only deleted by _delete_orphaned_files() once nothing else references it - no InventoryItem, no ToolshedUser (as a profile picture), and no WorkflowInstance (as a staged file) - since files are deduplicated by content hash and may be shared with other items or users. The ToolshedUser account (and its underlying KnownIdentity) is deliberately not deleted by delete_user_data(); see delete_account() in toolshed/api/offlinedata.py for that.

delete_user_account() builds on this: it calls delete_user_data() first and then deletes the ToolshedUser row itself, closing the local account. The KnownIdentity is still kept so that remote friends/history relating to this identity remain intact for other users - only the local account is closed. Both functions return a summary dict describing what was removed; delete_user_account()'s summary additionally sets account: True.

Location Import Ordering And Savepoints

import_locations() fault-tolerantly imports locations.csv into StorageLocation rows. Rows are read by header label; a row missing the required name column is skipped, and optional columns (description, category) are simply omitted if absent. Rows are processed in path-depth order (shallowest first) so that a child location's parent has already been created by the time it's needed. Each row runs inside its own transaction savepoint (transaction.atomic()), so a DB-level failure on one row (e.g. a constraint violation) can't poison the surrounding transaction and silently break every subsequent row - it only skips that one row.

Handle Resolution Semantics

A fully qualified handle (e.g. git:base#tag:drill) identifies one specific entity from one specific origin. _resolve_handle() therefore only ever resolves such a handle to an existing model instance and never creates one: silently creating a new local entity named after the raw handle string would be incorrect, since that's not the entity the handle actually points to. It raises _HandleNotFound (defined for exactly this purpose) when the handle's entity type doesn't match the expected one, or when no such object exists locally - which lets the caller (import_inventory()) skip just that row and report a helpful error instead of guessing.

Properties CSV Encoding

An inventory item's properties are encoded into the properties CSV cell as a comma-separated handle=value list by _encode_properties_cell(), and decoded back into (Property, value) tuples by _parse_properties_cell(). Because both the list separator (,) and the key/value separator (=) could otherwise appear inside a value, _quote_value_if_needed() wraps any value containing a comma or a quote character in CSV-style double quotes (doubling embedded quotes), and _split_quoted_comma_list() is the matching reader-side routine: it splits on commas while honouring quoted substrings so a quoted value's own commas aren't mistaken for separators, unescapes doubled quotes ("") back to a single literal quote, and strips only the single space that follows each ", " separator (as written by the encoder) rather than doing a blanket .strip() - so genuine leading/trailing whitespace inside a quoted value survives the round trip.

Inventory Import Semantics

import_inventory() fault-tolerantly imports inventory.csv into InventoryItem rows owned by the target user. The files column's paths are looked up in available_files (see File Naming Convention above); a reference to a file that wasn't successfully extracted from the zip is simply ignored rather than failing the row. If a row references a fully qualified tag/property/category handle that doesn't resolve locally (see Handle Resolution Semantics above), the whole item is skipped - rather than creating a bogus local entity - and a message is appended to the returned errors list. As with import_locations(), each row runs inside its own transaction savepoint so one row's DB-level failure can't break the rest of the import.

Camera Scanning & Inventory UI

Video Stream Attach And Resize Sync

setupVideoStream in Scan.vue attaches a MediaStream to the <video> element and is shared by startCamera and the camera-switch/reconnect paths, so the CameraScanner (which just keeps reading frames off the same <video> element) never needs to be recreated when the camera changes. Right after video.play(), video.videoWidth/videoHeight are not populated yet - the browser only reports them once the video's own resize (and, on first load, loadedmetadata) event fires, confirming the intrinsic dimensions actually took effect. onVideoResize listens for both events and is the one place that resizes the overlay <canvas> to match the video's current rendered size, replacing whatever stale size it had from before a switch to a camera with a different native resolution/aspect ratio.

printLinkFor in Inventory.vue and StorageLocation.vue (and the print buttons in InventoryDetail.vue/StorageLocationDetail.vue) all route to Print.vue with the same {kind, userHandle, id} query shape - the thing's raw identity - rather than any pre-built link. kind is one of CONTENT_KINDS' ids ("item"/"storage-location", see Content Kinds Registry above); label.js's buildLabelFields reads it generically since every kind's prefill now shares this one shape. That lets the print page derive every representation it needs (qualified handle, owner handle, URL, short link, …) itself, instead of being tied to whichever one the calling button happened to construct. Group-owned items have no individual owner handle - short-id.js's group_item kind resolves them via owner_group instead (see shortIdLink) - so there's no {userHandle, id} to build yet; Inventory.vue's printLinkFor returns null for them until group print support exists. Storage locations are always individually owned (see StorageLocationViewSet.get_queryset), so StorageLocation.vue's version never has that fallback to make.

Categories

Category Tree Reconstruction From Flat Path Strings

Category (backend/toolshed/models.py) is a real parent/children tree (self-referencing FK), but combined_info's /api/v1/info/ response (backend/toolshed/api/info.py) flattens every category to str(category) - a /-joined ancestor path (Category.__str__) - with every ancestor and descendant listed as its own separate string, not nested. Admin.vue's categoryTree computed rebuilds the hierarchy client-side by splitting each path on / and grouping nodes by shared prefixes, then renders it with TreeView.vue (frontend/src/components/TreeView.vue), a generic recursive collapsible tree component built for single-line slot content.

StorageLocation (backend/toolshed/models.py) is the same self-referencing-FK shape, but its StorageLocationSerializer keeps parent as a real owner-scoped id rather than flattening it away, so Admin.vue's storageLocationTree and StorageLocation.vue's locationTree computeds group nodes by that id directly instead of parsing path strings - the same tree-from-flat-list pattern, one step simpler since the parent relation already survives serialization.