Compare commits

...

5 commits

Author SHA1 Message Date
7d9f67a77a stash 2026-08-21 19:56:55 +02:00
395a9b156a stash 2026-08-20 05:19:53 +02:00
c345372382 stash 2026-08-20 03:46:11 +02:00
ce1e5f1d62 stash 2026-08-20 01:54:37 +02:00
67c7415c3b stash 2026-08-19 23:09:21 +02:00
22 changed files with 2467 additions and 418 deletions

View file

@ -0,0 +1,113 @@
# Groups MVP (Design in Progress)
Status: not implemented. [groups.md](groups.md) works out the underlying model (what a group is,
how "acting as a group" is authenticated across federation). This document doesn't re-derive any
of that; it takes that design as given and asks a narrower question: what is the smallest set of
screens and actions that makes groups usable, reusing the UI patterns Toolshed already has for
friends and inventory rather than inventing new ones.
## Goals
- Ship something a user can actually click through: create a group, add/remove members, and have
the group own items, end to end.
- Reuse existing screens and interaction patterns wherever the shape already matches, instead of
designing new ones (see below).
- Leave every deferred piece as an explicit non-goal rather than a silent gap, so it's clear what
MVP does and doesn't cover.
## Non-goals (for now)
- **Group-to-group friending, and letting outside users friend a group.** groups.md's model
supports both, but the only audience for a group's items in MVP is the group's own members;
"private" already covers that (see "Availability policy for group items" below). Extending
visibility to non-members via the group's own friends list is real, valuable, and deferred as a
fast-follow, not designed here.
- **Transferring an existing personally-owned item's ownership to a group.** MVP only supports
creating a *new* item directly owned by a group. Moving an already-existing item across owners is
a separate piece of work (ownership transfer isn't something Toolshed supports for user-to-user
either today).
- **Deleting a group.** MVP has no explicit "delete this group" action. A group's lifecycle is just
"created, membership changes over time"; the orphaning guard (a group can't be left with zero
members, see groups.md's Known Gaps) means a group that's no longer wanted just sits unused rather
than needing a teardown flow.
- **Any governance, roles, or per-item permissions.** Same non-goal as groups.md: every member has
equal, full privileges over the group and everything it owns.
## User-facing features
### Creating a group
A new "Groups" section, entry point styled like Inventory's "Add" button. The form is just a name;
the handle (`#name@yourdomain`) is derived from it the same way a username becomes part of a user's
handle. The creator becomes the group's first member automatically — there's no empty-group state
to design for.
### My Groups list
A "Groups" nav entry/page, modeled directly on `Friends.vue`: a table of groups the current user is
a member of (name/handle, member count), each row linking into that group's detail page. No
separate "discover groups you're not in" browsing for MVP — you land in a group by being added to
it, the same way you become friends with someone by request/accept, not by browsing a directory of
all users.
Known limitation: this list only ever queries the member's own home backend, so it only shows
groups actually hosted there (groups you created, or joined on your own domain). Membership itself
works regardless of which backend hosts the group — a remote member can still be invited, accept,
and fully edit/delete the group's items (see "Owning items as a group" below) — but a group hosted
on someone else's backend won't show up in your own "My Groups" list, because unlike friendship
(which both sides record), group membership is only ever recorded on the group's own home backend,
and there's no index anywhere of "which other backends has this identity been added to." Making a
remote membership discoverable would need a small personal pointer index (written by the client at
join time) plus a handle-based group lookup on the group's own backend; deferred as a fast-follow
alongside group-friending.
### Group detail page
One page per group, with two sections, each reusing an existing pattern wholesale:
- **Members** — the add/remove-row pattern from `Friends.vue`'s friend list: an inline "add member"
field (enter a handle, `user@domain`), and a remove (trash icon) action per row. Any member can
add or remove any other member — flat privilege, no confirmation step beyond the orphaning guard
(removing the group's last member is blocked, with an error explaining why, rather than silently
emptying the group).
- **Group inventory** — the exact table/grid pattern from `Inventory.vue` (Name, Availability
Policy, Amount, Edit/Delete), scoped to items owned by this group, with the same "Add" button
leading into the existing item-creation form (see below).
A "Leave group" action removes the current user from Members; if they're the last member, it's
blocked by the same orphaning guard.
### Owning items as a group
The existing item create/edit form (`InventoryNew.vue` / `InventoryEdit.vue`) gets one new field:
an "Owner" selector, defaulting to "Myself," with the groups you belong to as the other options.
Every other field on that form (tags, properties, availability policy, storage location, quantity)
is unchanged, and a group-owned item behaves exactly like a personally-owned one everywhere else in
the app (edit, delete, detail view) — any member can edit or delete it, the same way the owner can
today.
### Where group-owned items show up
The main "Inventory" page stays scoped to items you personally own, unchanged from today. A group's
items are visible and managed in exactly one place: that group's detail page. This keeps "my
inventory" meaning one thing (what I personally own) and avoids merging two different item lists
with different edit semantics into one view for MVP.
### Availability policy for group items
The item form's existing Availability Policy field (private/share/lend/rent/sell) is unchanged and
applies to group-owned items the same way it does today. Since group-friending is out of scope for
MVP (see Non-goals), "share/lend/rent/sell" have no wider audience to expand to yet, only "private"
is fully meaningful right now — a private group item is visible to and editable by every group
member, which is already the core value the Problem statement in groups.md is after (a shared
workshop's members all having a say over shared equipment). The field stays as-is rather than being
trimmed down to just "private," so nothing needs to change on it once group-friending ships.
## Open scoping call
Deferring group-friending is the one judgment call in this document worth flagging explicitly:
it means an MVP group can't yet share an item with anyone outside its own membership, which is a
real limitation, not just a simplification. It was scoped out because it pulls in a second piece of
UI (a group's own Friends tab, and "who can accept a friend request on the group's behalf") that
isn't needed for the core "shared ownership among members" use case to work end to end. Worth
confirming this is the right line before building against it.

View file

@ -0,0 +1,233 @@
# Groups (Design in Progress)
Status: not implemented. This document collects the problem, goals, and open design questions for
adding groups to Toolshed. Nothing here is settled; it's a starting point for discussion.
## What a group is
A group exists to model shared ownership of items, the way a club, workshop, or company owns
equipment collectively rather than any one person owning it. A group has members, and all members
have equal privileges to edit the items the group owns; there's no owner-vs-member distinction
within a group, membership itself is the privilege.
This is a sharper (and more consequential) definition than "a label you can share things with":
it means a group needs to be able to *own* things, not just receive shared access to them the way
a friend does. That has implications worked out below.
## Problem
The only relationship Toolshed currently models between users is pairwise friendship (see
[federation.md](../federation.md)), and every item has exactly one owning user. That's enough for
"I trust this one specific person" and "I personally own this thing," but it breaks down for
anything collectively owned:
- A shared workshop, tool library, or team can't own equipment as a unit. Today it has to belong
to one specific person's account, which is a poor fit and doesn't reflect who actually has a say
over it.
- There's no way for several equally-privileged people to edit the same item; edit rights today
are entirely tied to the single `owner` field.
- Adding or removing a member of an informal group currently means renegotiating friendships and
re-sharing individually; there's no shared object whose membership can just be edited once.
## Goals
- Let a set of users collectively own items, with every member holding equal edit rights over
those items.
- Let membership be managed in one place instead of via N pairwise arrangements.
- A request from a client to any backend may not depend on any other backend being online at the
same time — not to construct the request, and not to verify it. Concretely: the receiving server
must be able to verify the request using only the request itself plus keys it has already cached
from prior trust (friend-accept), with the group's own authoritative backend and the requesting
member's home server both unneeded and unreachable-safe at verification time; and the client must
be able to send the request using only what it already has cached, with the group's authoritative
backend unneeded and unreachable-safe at send time too. This is the same property plain user
requests already have (see federation.md's Cryptography section); group requests must not regress
it on either side.
- Fit into the existing handle system (see federation.md's "Unique Handles" section): a group
should be nameable and referenceable using the same handle shape a user is.
- Stay optional and additive. Pairwise friendship and single-user ownership should keep working
exactly as they do now for people who never touch groups.
## Non-goals (for now)
- Group governance beyond flat, equal membership (voting, roles, hierarchies). Equal privileges
for all members is the whole model for now; anything more layers on top later if needed.
- Fine-grained per-item permissions within a group (e.g. "this member can edit but not delete").
Membership is the only privilege level.
## Open design questions
### Does a group need its own keypair?
No. A group's day-to-day existence is a membership roster maintained by whichever backend is
authoritative for the group's handle (the same "authoritative backend" idea a user's domain already
implies, see federation.md's Servers subsection). A request made "as the group" is an ordinary
request, signed with an actual member's own personal private key, together with a claim of which
group it's acting on behalf of (`acting_as`), signed as part of the same payload as the rest of the
request.
A receiving server must be able to authenticate such a request using only what arrives in the
request plus keys it already holds; no other server, including the group's own authoritative
backend, needs to be reachable at verification time. This is accomplished with **membership
certificates**: on request, a group's authoritative backend issues a current member a small
signed statement of the shape "handle X, public key P, is a member of group #G, valid from T1 until
T2," signed with the group's own private key. Issuance is on-demand (the member asks, rather than
the backend pushing renewals on a schedule), but it is its own separate action, decoupled from
sending any particular group request: a member fetches and refreshes this certificate from the
group's backend whenever they happen to be online, caches it locally, and later attaches whichever
certificate they currently hold to a request they make "as the group." Sending that request never
itself triggers a live fetch from the group's backend — if the cached certificate has expired and
the group's backend isn't reachable right then, the request simply can't be sent as the group yet;
the client doesn't fall back to contacting the group's backend synchronously to get one.
A receiving server checks two signatures, using keys it already has cached, with no outgoing call
to anyone: the member's signature over the request, using the public key embedded in the
certificate itself, and the certificate's own signature, using the group's public key, learned and
cached exactly the way any friend's key is, at the point the group was friended. If both check out
and the certificate hasn't expired, the request is authorized.
The certificate is what lets a receiving server trust a specific member's public key at all, for
members it has never individually friended: that trust is vouched for by the group's already-cached
key, rather than requiring a separate key-exchange with every member of every group a user happens
to be friends with. Equal privileges for all members falls out of this directly, since any member's
own key plus a valid certificate is sufficient proof.
Removing a member takes effect once their existing certificate expires, not the moment the
backend's roster is edited; certificate lifetime is the parameter that governs how quickly a
removal actually takes hold (see "How long should a membership certificate be valid for?" below,
and Security below).
### Are group handles different from user handles, or is a group just a special kind of user?
The handle should look almost exactly like a user handle, just prefixed with `#`: `#groupname@domain`
instead of `groupname@domain`. It's resolved the same way and referenceable in the same places (e.g.
as an item's owner, or as a friend-list entry), so the reuse of the existing federation model is
unaffected. The prefix exists only to keep group and user handles from occupying the same namespace on
a domain: without it, "is `groupname@domain` a user or a group" would depend on which one happened to
register the name first, and the two could never be told apart just by looking at the handle. With the
prefix, a domain can have both a `climbing@domain` user and a `#climbing@domain` group with no
collision and no ambiguity about which is which, and any code path that resolves a handle can dispatch
on the actor kind (user vs. group) from the handle's own shape, before it even needs to ask a server.
But underneath, a group isn't really "a special kind of user," it's backed by a fundamentally
different kind of identity: a membership list instead of a single keypair (see above). A user
handle answers "is this request authentically from this one identity," a group handle answers "is
this request from someone currently entitled to act for this collective identity." Those are
different questions even though the answer to both ends up being "yes, forward the request." Groups
and users are probably best thought of as two kinds of actor that share a handle format and most of
the surrounding plumbing (ownership, friendship, availability policy), rather than one being a
special case of the other.
### Should a group be able to grant read access to non-members ("group friends")?
Yes, this should reuse the same mechanism a user's own sharing already uses. Since a group is an
actor with a handle, it can have its own friends the same way a user does, and a group's owned
items can go through the same availability-policy check (private vs. visible to the group's
friends) that an individual's items already do. Nothing new needs to be invented here, it's the
existing friendship and availability-policy machinery applied to a second kind of actor.
### Should groups be able to befriend other groups?
Yes, for the same reason: if a group is an actor with a handle and a friends list, there's no
reason the other side of that friendship has to be a user specifically. Two clubs befriending each
other so each can see the other's shared equipment is the same mechanism as two users befriending
each other, just with both sides being groups instead of one or zero.
### Should anyone be able to share directly with a specific group, instead of with "my friends" generally?
This is the one piece that isn't just reuse of what exists today. Right now, an item's
availability policy is all-or-nothing across *all* of the owner's friends, there's no way to share
with a subset of friends, individually or as a named group. Letting an item be shared with one
specific group (a user's own item, shared with a club they belong to, say) instead of with every
friend equally would be a genuine generalization of the current sharing model, not something that
falls out of adding groups as an actor.
Worth noting: this capability would be just as useful for individual users wanting to share with a
subset of their friends, without a group being involved at all. It might make more sense to design
"share with a specific target (user, group, or named subset)" as its own piece of work, rather than
building it as a groups-only feature.
### How long should a membership certificate be valid for?
This is the tuning knob the certificate design introduces, and it's a real trade-off rather than a
detail to defer. A short validity window (say, hours) keeps the staleness window after a removal
small, but means a member who's offline for longer than that can't act as the group at all until
they reconnect and refresh. A long window (days or weeks) is more forgiving of intermittent
connectivity but leaves a removed member's old certificate usable for longer. Whatever default is
picked, a member should be able to fetch a fresh certificate well before the old one expires while
still online, so the common case isn't "offline for exactly the wrong amount of time."
## Interaction with availability policy
Items currently have an availability policy (private / share / lend / rent / sell) that's a
property of the item, not a list of who it applies to, "friends" is implicit and applies equally to
all of them. A group-owned item works the same way, just with the group's own friends as the
implicit audience instead of an individual's. Targeted sharing (the question above) would extend
this, not replace it.
## Known gaps in the design
None of these block the design, but they're real gaps that need an explicit answer before
implementation.
### Ambiguity
- **What exactly is signed.** The `acting_as` claim and the certificate's identifying fields must be
signed as part of the same payload the member's key signs, not as free-standing, unsigned data
alongside it. If they aren't inside the signed bytes, `acting_as` can be swapped after signing,
turning a personal request into a group one or vice versa, or one group's request into another's.
- **Who can change membership, and how.** Equal privileges to edit items doesn't by itself say
whether that equality extends to *requesting or renewing certificates for others, or editing the
authoritative backend's issuance list itself*. Flat and unilateral (any member can add or remove
any member) is the simplest reading of "no owner-vs-member distinction," but it's a materially
different trust model from "equal edit rights over items" and deserves its own explicit decision.
### Security
- **Confused deputy on `acting_as`.** Trust in an `acting_as` claim reduces to trust in the group's
authoritative backend's issuance decisions: a malicious or compromised backend can sign a
certificate for a handle that was never really a member, and every receiving server that trusts
the group's key will accept it. The backend's issuance discipline, and the security of its own
private key, is a single point of failure for the group as a whole.
- **Membership staleness window.** There is a window after a member is removed during which their
existing certificate keeps working: exactly the certificate's remaining validity period. This is a
strictly worse revocation story than individual friendship, where trust is keyed to a public key
learned once with no expiry, but the window is a bounded, chosen parameter (see "How long should a
membership certificate be valid for?" above) rather than open-ended.
- **Blast radius of a single compromised member key.** Because membership is flat and unilaterally
editable by any member, a compromised personal key doesn't just expose that person's own items,
as with an ordinary account compromise, it exposes edit rights over everything the group owns for
as long as that member's certificate remains valid, and can be used to obtain a certificate for an
attacker-controlled handle as a permanent member before anyone notices. This risk is inherent to
"equal privileges, no roles" as a model, worth flagging even though richer governance is a
non-goal for now.
- **Self-lockout / orphaning.** Nothing in the design stops a group's last member from leaving (or
removing everyone else) from the backend's issuance list, which would leave group-owned items with
no one able to obtain a valid certificate for that owner at all once existing certificates expire.
The backend should guard against removing the last member, but that guard doesn't address a
member unilaterally removing every *other* member, which the flat model otherwise permits.
- **Audit trail depends on discipline.** Since any member's signature plus a valid certificate
satisfies authorization, "the group edited this item" is never sufficient for an audit trail; the
actual signer's handle (from the certificate's embedded public key) must always be logged
alongside the group claim, or member-level accountability is lost entirely.
### Maintainability
- **Two actor kinds sharing one code path.** `Group` should have the same shape as `User` for the
things that matter (a `.friends` set, a `.handle`), so ownership/friending/availability-policy
code can stay actor-agnostic. That reuse only holds if future code is disciplined about not
special-casing `User` in ways that assume a single, non-expiring keypair (e.g. "cache the owner's
public key forever, no expiry check needed") — a shortcut that would silently break the moment
the owner turns out to be a group, where the *acting member's* key is only good until its
certificate expires.
- **Certificate issuance and refresh is a client responsibility.** A member's client needs to
refresh its certificate before it expires, handle in-flight group actions failing closed if it
didn't (the same as any expired-credential error), and surface refresh failures to the user
rather than as a confusing rejected request.
- **Expanded federation test surface.** Every existing federation test implicitly assumes the
request's signer and its authorized actor are the same handle. `acting_as` plus an embedded
membership certificate means the whole request-verification path needs testing for the
signer-vs-actor split and the certificate's own signature and expiry checks, including
cross-domain cases (group hosted on one domain, member's key registered on another, item owned by
the group sitting on a third) and expiry-boundary cases (certificate expires mid-flight, is
refreshed concurrently with a request, etc).

View file

@ -0,0 +1,166 @@
# Authenticated image caching (Design in Progress)
Status: not implemented. This document proposes a fix for a real performance gap: every
authenticated image in the app is refetched, re-verified, and re-decoded from scratch on every page
load, even though the backend already sends headers built for exactly the opposite.
## Problem
Images are served from `GET /media/<hash_path>` and `GET /media/<size>/<hash_path>/`
(`backend/files/media_urls.py`), both gated behind `SignatureAuthentication`
(`backend/authentication/signature_auth.py`): the client signs the full request URL with an Ed25519
key and sends `Authorization: Signature <user>@<domain>:<sig>`. There's no cookie and no
URL-embedded token — auth lives entirely in a request header that a browser has no way to attach to
a plain `<img src="...">`. So `AuthenticatedImage.vue` does it by hand: `fetch()` with the header,
`.blob()`, `URL.createObjectURL()`, assign that to `src` (`federation.js`'s `getRaw`,
`fileCache.js`). `fileCache.js` is a module-level `Map` — it dedupes concurrent requests and holds
decoded blobs for the life of the page, but it's memory-only. Reload the page (or just navigate
between the SPA's route-based chunks in a way that re-mounts things) and it's gone; every image the
user has already looked at gets fetched, signature-verified, and blob-decoded all over again.
Meanwhile the backend response already carries `ETag`, `Cache-Control: max-age=31536000, private,
immutable`, and a 365-day `Expires` (`_cache_headers`, `media_urls.py`) — because `src` is a
SHA-256 hash-addressed path, the same URL can only ever mean the same bytes, forever. Those headers
are correct and unused: nothing durable in the client ever consults them. This design closes that
gap using the browser's own Cache Storage API, without touching the backend.
## Goals
- Make a previously-viewed image load instantly on the next page load / browser restart, not just
within the current tab's JS session.
- Do it without weakening the authorization model: a signature is still required and verified
server-side for the *first* fetch of a given file by a given identity. Caching must not let one
identity's cached bytes leak to a different identity sharing the same browser.
- Reuse the backend's existing headers rather than inventing a parallel freshness scheme — content
is immutable, so a cache hit needs zero revalidation, ever.
- No backend changes. This is purely a client-side storage question.
## Non-goals (for now)
- **Revoking already-cached bytes when access changes** (e.g. an unfriend). The backend's own
1-year `Cache-Control` already accepts that risk today for anything an HTTP-compliant cache might
hold; a persistent client cache extends the shelf life of that same accepted risk, it doesn't
introduce a new one. Not solving revocation here.
- **Prefetching / warming the cache ahead of navigation.** Real optimization, separate piece of
work; this document is about not throwing away work already done.
- **A Service Worker that reinstates plain `<img src>`.** Sketched below as a follow-up because it's
the "real" fix for the root cause (no way to attach a header to an `<img>` request), but it's a
bigger lift (SW lifecycle, an extra message-passing bridge for signing) than the storage win alone
needs. Scoped out of this pass.
## Design: persist `fileCache` with the Cache Storage API
`window.caches` (the `CacheStorage` interface) is available to any page context, not just inside a
Service Worker — `caches.open(name)` gives a store of real `Request`/`Response` pairs that survives
reloads and browser restarts, backed by the browser's own disk quota. That's the missing tier;
nothing else about `fileCache.js`'s existing shape needs to change.
**Two tiers, not one:**
- **L1 — in-memory `Map<key, objectURL>`** (what exists today). Kept as-is: within a single page
session, components just want the already-created object URL back without re-touching storage at
all. Same LRU/budget logic (`MAX_BYTES`), unchanged.
- **L2 — `CacheStorage`**, consulted on an L1 miss, before falling back to the network. Holds raw
`Response` objects (not blobs), keyed by the same request used for the authenticated fetch.
Revised `get(key, fetcher)` flow:
1. L1 hit → return the object URL, as today.
2. L1 miss → check `cache.match(request)`. Hit → `.blob()` the cached response, create the object
URL, populate L1, done. **No conditional GET, no revalidation** — the response is `immutable`,
so if it's in the cache it's still correct by construction.
3. L2 miss → run the existing authenticated `getRaw()` fetch. On success, `cache.put(request,
response.clone())` before consuming the body, then proceed as today (`.blob()`, object URL,
populate L1).
**Namespacing by identity, not one global cache.** `Cache-Control: private` on the response is the
backend telling shared caches to stay out — correct, since access is per-requester
(`_accessible_files`'s friends-or-self check). A single browser-wide `CacheStorage` bucket keyed
only by URL would quietly turn into exactly the shared cache that header is warning off, *if* this
browser ever holds more than one local identity (switching accounts, a shared machine). Concretely:
open the cache as `images-${username}@${domain}` (derived from the active `state.keypair`, the same
identity that produces the signature) rather than a single `"images"` name. Same-identity re-fetches
get the full cache benefit; a different identity in the same browser starts with an empty bucket and
goes through the normal authenticated-fetch-then-verify path, same as it does today. `invalidate()`
and `clear()` already exist on `FileCache` but nothing calls them — wire `clear()` to also
`caches.delete(currentNamespace)` and call it on logout/identity-switch, which is the natural,
already-there hook for this.
**Storage budget.** L2 doesn't need its own hard byte cap the way L1 does — `CacheStorage` is
subject to the browser's own storage-pressure eviction, which is the right backstop for "durable but
not sacred" data like this. Optionally call `navigator.storage.persist()` once at startup to ask the
browser to exempt the origin from casual eviction under pressure; harmless to skip if declined.
**Net effect:** a returning user's already-seen images (inventory thumbnails, profile pictures,
friends' shared items) render from disk with zero network round-trips and zero re-verification,
using exactly the durability guarantee (`immutable`, hash-addressed) the backend already asserts.
First-time images are unaffected — same authenticated fetch as today, just now also written to L2 on
the way through.
## Follow-up worth flagging: a Service Worker to restore plain `<img>`
The deeper cost isn't just the network round-trip — it's that every image, cached or not, is forced
through manual `fetch → blob → createObjectURL`, so the browser's native image pipeline (off-main
thread decode, `loading="lazy"`, `fetchpriority`, responsive `srcset`) is unavailable, and object
URLs have to be manually revoked (`fileCache.js` already does this correctly, but every new call
site is a chance to leak one). The reason the app can't use plain `<img src>` at all is that nothing
can attach the `Authorization: Signature` header to a browser-initiated image request.
A Service Worker can, because its `fetch` handler intercepts requests — including image loads —
before they leave the page, and can substitute its own request in place of the original:
- On a `fetch` event where `event.request.destination === 'image'` and the URL matches `/media/`,
check the (identity-namespaced) `CacheStorage` first; hit → respond straight from cache, no
network at all.
- Miss → the SW doesn't have the signing key (it lives in page memory / `localStorage`, neither
reachable from a SW), so it asks the one controlled client (`self.clients.get(event.clientId)`
the specific tab that issued the request, not "any open tab") for a signature over this exact URL
via `postMessage`/`MessageChannel` — an in-process round trip, not a network call — attaches the
returned header, performs the real fetch, stores the result in `CacheStorage`, and responds with
it.
- Once this exists, `AuthenticatedImage.vue` can go back to `<img :src="mediaUrl" loading="lazy"
decoding="async">` directly; the SW is what makes that legal despite the custom auth scheme.
### Scoping the signing bridge: a compromised SW must not become a "sign anything" oracle
The message bridge above is the one new capability this design adds that doesn't exist today: a
channel through which something can ask the page to sign a URL on its behalf. A Service Worker is a
long-lived, network-interposing piece of code — exactly the kind of thing a supply-chain compromise
or an XSS-planted `registration.update()` would target. If the page's message handler blindly signs
whatever URL the request names, a compromised SW stops being "something that can read images this
identity can already see" and becomes "something that can get a validly-signed request for *any*
endpoint" — e.g. `POST /api/inventory/items/5/delete` or `POST /api/friends/accept` — and then just
replay it directly against the real backend. That's a full account-takeover primitive smuggled in
through what was supposed to be an image-caching optimization, and it's strictly worse than not
having the bridge at all.
The fix has to live on the page side of the channel, since the SW is the presumed-compromised
component in this threat model and can't be trusted to police itself. Treat the message handler as a
dedicated, narrow function — not a thin wrapper around the app's general-purpose signer
(`createSignAuth` in `federation.js`, which is used for arbitrary API calls elsewhere in the app) —
that:
- **Ignores any method the request claims and always signs as `GET`.** The bridge never accepts a
body/`data` field from the SW at all, which closes off the entire class of mutating requests
(`POST`/`PUT`/`PATCH`) regardless of what path is named.
- **Validates the path against a strict allowlist grammar before signing anything**, rather than a
loose "starts with `/media/`" check. `src` values are hash-addressed —
`/media/<hex>/<hex>/<64-hex-char-sha256>.<ext>` for originals, with an optional `/<32|64|256>/`
size prefix for thumbnails. Because the variable part is constrained to `[0-9a-f]`, a regex over
that exact shape is effectively a closed grammar: `.` and `/` (the characters path traversal or
extra-segment tricks would need) simply aren't in the hex alphabet, so there's no meaningfully
malformed input that still matches. Anything that doesn't match — a different endpoint, an
encoded traversal attempt, an extra query string — is refused, silently or with a logged warning,
never signed.
- Optionally also checks the URL's host against the identity's home domain or its current friend
servers (belt-and-suspenders — a signature is bound to the exact signed URL string, so it can't be
replayed against a different host than the one named in it, but this catches a compromised SW
fishing for signatures against a host that happens to also trust this key for unrelated reasons).
With this in place, the worst a fully compromised SW can do is obtain signed `GET`s for images the
current identity is already authorized to fetch — the same blast radius as "can read the
already-authorized image cache" — not an oracle for arbitrary authenticated mutation.
Deferred because it adds real surface area (SW registration/update lifecycle, this scoped
message-passing bridge, first-load-before-SW-is-active edge cases) beyond what the storage change
alone needs. Worth doing as a second pass once the simpler win above is in and paying off.

View file

@ -0,0 +1,131 @@
# Item Handles & Physical Labels (Design in Progress)
Status: not implemented. This document collects the problem, goals, and open design questions for
giving inventory items stable identifiers and physical (scannable) labels. Nothing here is
settled.
## Problem
As described in federation.md's "Unique Handles" section, an item today is identified only by a
local id scoped to its owner, it isn't given an explicit, portable handle the way a tag, property,
or category is. That's fine as long as the only thing ever addressing an item is the owning user's
own signed API traffic. It stops being fine the moment something *outside* that loop needs to
refer to the item:
- A friend who borrowed a physical tool has no way to look it up other than finding it in the
owner's shared inventory list by eye.
There's nothing you could put on a sticker.
- If items ever need to be referenced from outside their owner's own requests (a group's shared
view, a lending record, a printed label), there's currently no stable identifier to reference
that's meaningful outside the owner's own account.
- A local database id isn't something we'd want to expose or rely on externally: it's an
implementation detail of one backend's storage, not a handle with the same guarantees
(uniqueness, meaning, longevity) the rest of the federation model gives every other kind of
entity.
Put simply: every other kind of thing in Toolshed (users, tags, properties, categories) has a
handle that means something outside of one database. Items don't, and physical labeling is the
clearest case where that gap actually matters.
## Goals
- Give an item a handle that's meaningful and resolvable outside its owner's own account, without
requiring items to become shared/reusable entities the way tags are (an item is still owned by
exactly one person; see the "Items" subsection of federation.md for why that keeps things
simple).
- Support a physical label (QR code, barcode, or similar) that can be printed and stuck on a real
object, such that scanning it gets you to the right item on the right backend.
- Make the label survive the normal life of a physical object: it gets lent out, comes back,
maybe changes which storage location it lives in, all without needing a new label printed.
## Non-goals (for now)
- Turning items into shareable/reusable entities across owners (that's what tags/categories are
for; see [tags.md](tags.md)). An item handle identifies *this specific person's specific thing*,
not a class of thing.
- Solving inventory tracking/auditing (check-in/check-out logs) as a whole system; that can build
on top of a stable item handle once one exists, but isn't the same problem.
## Open design questions
**What does the handle look like?**
The natural extension of the existing scheme is owner handle + local id, that's enough to be
globally unique (no two users share a handle, and ids are already unique within one user's
inventory) without inventing a new namespace. Worth deciding whether the id should be the existing
internal database id (simple, but leaks a little implementation detail and a rough count of
someone's inventory) or a separate opaque id generated for exactly this purpose (see the
unguessability question below).
Two distinct formats are needed, because "an item handle" is used in two different situations:
- *A compact handle, for use where context already makes clear it's a Toolshed item.* Inside the
app, in exports, in logs, anywhere the reader already knows they're looking at Toolshed data,
the handle doesn't need to spell that out or be openable on its own. This can be as short as
`user@domain.tld:id`, the item's owner handle with `:id` appended, mirroring how a tag/category
handle already appends `:name` after its origin (see federation.md's Unique Handles section).
No new delimiter concept, just the same pattern applied to items.
- *A self-contained URL, for use with no context at all.* A physical label, a link shared outside
the app, has to work without the reader already knowing what it is or which server it belongs
to, so it needs to open directly to the right frontend, resolve the right backend, and land on
the right item. That means it has to encode the same information (owner handle + item id) as a
full URL, e.g. `https://toolshed.webdomain.tld/i/alice@example.com/42`, note the owner's handle
can be embedded in a path segment as-is (`@` doesn't need escaping in a URL path), which keeps it
one segment shorter than splitting the handle back into `domain/user`, and means the handle is
visible unmodified inside the link rather than reassembled from separate parts. If the owner is
ever a group rather than a user (see groups.md), its handle carries a leading `#`, which does need
the `+`-for-`#` substitution described in [handles-and-shortids.md](../handles-and-shortids.md)'s
Handle syntax section before it can sit in a path segment, e.g.
`https://toolshed.webdomain.tld/i/+climbing@example.com/42`. The frontend host
in this URL (`toolshed.webdomain.tld`) doesn't have to be, and generally won't be, the backend
authoritative for `example.com`, any frontend can resolve any handle (see federation.md's Servers
subsection), so this is just whichever frontend happens to be handling the link, not part of the
item's identity.
Both formats should stay as short as the encoded information allows, this matters most for the URL
form, since it's the one that ends up in a QR code or printed label where physical size is a real
constraint (see the labels goal above).
**How does this fit with the frontend's existing routes?**
There's already a `/inventory/shared/:user/:id` route (`InventoryDetailForeign`), but today
`:user` is just a bare username with no domain, i.e. it only works for a friend on the viewer's own
domain, and the view itself doesn't yet do anything domain-aware with that param. A resolvable
global handle needs the full `user@domain.tld` and a lookup step this route doesn't have yet. Two
ways to reconcile that: extend the existing route to take a full handle in the `:user` segment
(`/inventory/shared/alice@example.com/42`, no new route shape needed, just a richer meaning for the
param it already has), or treat the short `/i/...` URL as a dedicated, minimal entry point whose
only job is to resolve a handle and then hand off into whatever the richer in-app view ends up
being. The two aren't mutually exclusive: the short form is what needs to be small enough to print,
the in-app route doesn't have the same constraint and can stay more descriptive.
**What does scanning a label actually do?**
Probably: the label encodes a URL or handle-like string; scanning it opens the frontend, which
resolves the owner's domain the same way it resolves any other handle (see federation.md), and
lands on that item. This reuses the discovery mechanism that already exists for logging in as a
handle, rather than inventing a second one.
**Does resolving a label require authorization?**
An item's availability policy already controls who can see it (owner-only if private, friends if
shared, etc.). A label should presumably respect the same policy rather than being a backdoor that
makes a private item visible to literally anyone who finds the physical object and scans its code.
That means resolving a label isn't a free public lookup, it goes through the same friend/signature
checks as everything else, which has UX implications (an anonymous finder of a lost tool can't
necessarily see who it belongs to).
**Does the label need to be opaque/unguessable?**
If item ids are small sequential integers, a label built from a guessable id lets anyone enumerate
a user's items by scanning or guessing nearby numbers, even if each individual lookup is
authorization-checked. Probably wants some amount of unguessability even before authorization is
considered, as a defense-in-depth measure.
**What survives item changes?**
Storage location, availability policy, name, and description can all change over the life of an
object without it becoming a "different" item. The label should point at the handle, not at any of
that mutable data, so none of those changes require a new label. The one thing that probably *does*
need a decision is deletion: does a handle ever get reused, or is it retired for good once an item
is deleted (retiring seems safer, avoids an old label resolving to an unrelated new item later)?
**Relationship to lending/borrowing.**
A scannable label is the obvious hook for a future "mark as borrowed / returned" flow. Not solving
that now, but the handle scheme chosen here should be able to carry that later without a redesign,
i.e. it should be able to identify the item independent of who currently physically has it.

View file

@ -0,0 +1,252 @@
# Tags, Properties & Categories (Design in Progress)
Status: partially implemented (the handle scheme and strict resolution described in federation.md
exist today); this document is about the rough edges and open questions in that design, not a
proposal to build it from scratch.
## Problem
Tags, properties, and categories are handles of the form `origin#type:name` (see federation.md's
"Unique Handles" section), and a reference to a handle a server doesn't recognize is left
unresolved rather than guessed at or merged. That strictness is deliberate and avoids silent data
corruption, but it pushes a real cost onto users and creates some open problems:
- **Discoverability.** There's no way today to search or browse "what origins/taxonomies already
exist" before creating a new tag. In practice this likely means people invent their own local
tags for things a shared, canonical origin already covers, simply because they didn't know it
existed.
- **Fragmentation.** Because uniqueness is scoped per origin, nothing stops semantically identical
concepts from existing under different names or different origins at once (`drill` vs.
`power-drill`, one server's `origin:mytools` vs. another's `origin:community-tools`). Nothing
reconciles these; they just coexist.
- **No aliasing/synonyms.** If a user starts out with a local tag and later wants to adopt a shared
origin's equivalent, there's no supported way to merge or alias the two; existing items keep
pointing at the old handle.
- **Trust and quality of shared origins.** An origin is "whatever the classification is considered
to have come from," which could be an imported reference dataset. Nothing in the current design
addresses who maintains such a dataset, how it's kept correct, or what happens when it changes
(a category gets renamed or split upstream, e.g.).
- **Localization.** A tag/category name is a single string. There's no notion of the same concept
having a different display name per language while still resolving to the same handle.
- **Property values aren't typed.** A property's value on an item is a plain string. A property
definition can carry a unit (`unit_symbol`/`unit_name`), but nothing declares that its values are
actually numbers meant to be read in that unit, versus, say, free text that happens to mention a
unit. This is already a problem for one server in isolation (see the worked example below), and
federation doesn't create it, it just means it now has to be solved consistently across origins
instead of once.
- **Handle collisions.** `origin` is a free-text string with nothing enforcing that it's actually
unique to one definition. Fragmentation (above) is two different strings for the same thing;
this is the more dangerous mirror image, the same handle string ending up attached to two
different definitions, which is exactly the scenario the strict-resolution design was supposed
to make impossible (see the second worked example below).
### Worked example: filtering by a numeric range across origins
Say two independently-run servers both end up with a property called "voltage," each under its own
origin: `git:base#property:voltage` and `some-other-origin#property:voltage`. A user who's friends
with people on both wants to filter their combined view of shared items by, say, `0 < voltage <
4`. Two separate problems stack up:
1. **Are these the same property?** Per the fragmentation problem above, the two handles are, and
stay, unrelated as far as the system is concerned, same name, different origin, no connection.
A range filter built against one handle simply won't match items tagged with the other, even
though a person looking at both would probably call them "the same thing."
2. **Even if they were recognized as the same thing, are the values comparable?** A range filter
needs actual numbers in a known unit. If one server's items store `"3.7"` and the other's store
`"3700"` (volts vs. millivolts), a numeric comparison across the two silently produces nonsense
unless the unit is known and converted. If either side stores the value as loosely-formatted
text (`"3.7V"`, `"~3.7"`) rather than a bare number, it may not be reliably parseable as a
number at all.
So a cross-origin range filter needs both an aliasing/equivalence answer (are `voltage` and
`voltage` the same concept) and a units/typing answer (are their values actually numbers, in units
that convert cleanly into each other), and the first being solved doesn't imply the second is.
### Worked example: the same handle meaning two different things
Say `git:ee2` names a specific reference dataset that started life in one shared git history, and
two servers each imported it, at different times, from what has since become two diverging
branches (or forks) of that history. Both servers now have a property whose handle is the exact
same string, `git:ee2#property:charging_voltage`, but whose actual definition, say, unit, or
dimensions has since diverged between the two branches. Neither server did anything wrong; each
one faithfully imported "`git:ee2`" as it existed at the time.
This is a materially worse problem than the fragmentation/voltage example above. Fragmentation is
a missed opportunity, two things that should be linked aren't, and the failure is visible (the
filter just doesn't match as much as a person would expect). A handle collision is silent: nothing
about the two servers exchanging data suggests anything is wrong, both sides say
`git:ee2#property:charging_voltage`, so anything that trusts equal-handle-means-equal-definition
(exactly what the strict-resolution design promises, and exactly what a filter, an alias, or a
plain item-detail display would rely on) can silently combine or display incompatible values as if
they were the same thing. This is the specific failure mode the whole handle design exists to
prevent, so a scheme where it can still happen is a real gap, not just an inconvenience.
The root cause is that `origin` is a free-text label describing where something came from, not an
identifier that's actually bound to a specific, fixed piece of content. A name like `git:ee2` reads
as if it points to something immutable, but nothing about the origin field enforces that, `ee2`
could easily be a branch or tag name rather than a specific commit, i.e. a pointer that can keep
moving, and two importers pinned to it at different times without ever taking on a different
handle to show for it.
This isn't hypothetical, it's exactly how the reference data included in this repository already
works. The files in `backend/shared_data/` (`base.json`, `ee.json`, etc.) are origin datasets:
`configure.py`'s import step sets `origin = "git:" + filename` for everything a file defines
(`configure.py:119`), and a file can declare a fixed dependency on another one by name, e.g.
`ee.json`'s `"depends": ["git:base"]`. The intent is clearly that these files are immutable once
committed, and that a `depends` entry is a pin to a specific, settled parent, not a moving target,
but nothing in the code enforces that today, it's a convention people are expected to follow.
There's already a piece of the machinery needed to enforce it, though: import already computes a
sha256 of each file's raw content and stores it (`configure.py:125`, saved onto
`ImportedIdentifierSets.hash`, which is `unique=True` alongside `name`, `hostadmin/models.py:15`).
It's just not used for the thing it would be useful for, the "already imported, skipping" check
(`configure.py:129-130`) matches on `name` alone, it never recomputes the hash of the file being
imported and compares it against the hash already on record for that name. So even on a single
server, an edited `ee.json` re-imported under its old filename wouldn't be noticed as a change,
let alone flagged as a conflict, and the hash never leaves that server's own bookkeeping to be
compared against what a friend server has on record for the same name.
## Goals
- Make it easy to find and reuse an existing origin/handle before inventing a new local one, to
reduce fragmentation without weakening the strict-resolution guarantee that already exists.
- Give users a path to move a locally-invented tag onto a shared origin later, without losing or
having to manually re-tag their existing items.
- Keep the core guarantee intact: a handle always means one specific, traceable thing, nothing
should be implicitly merged or reinterpreted across origins.
- Make that guarantee actually hold, not just assumed: two servers that both use a given handle
should either really mean the same definition, or have some way to find out they don't, rather
than the collision staying silent.
## Non-goals (for now)
- Building a moderation/governance system for shared origins. Worth thinking about, but a bigger
problem than this document is trying to scope.
- Free-text/fuzzy tag matching in search. Search UX can layer on top of resolved handles without
changing what a handle means.
## Open design ideas
**A small set of well-known, shipped origins.**
Toolshed could ship with one or a few canonical origins covering common tool/inventory categories
out of the box, so that a fresh server already has a sensible baseline vocabulary to reuse instead
of every server reinventing "drill," "screwdriver," "power tools," etc. independently. Doesn't
solve fragmentation for everything, but raises the floor.
**Explicit aliasing rather than merging.**
Rather than trying to detect and merge "equivalent" tags automatically (risky, exactly the kind of
implicit behavior the strict-resolution design intentionally avoids), a tag could carry an explicit,
user-initiated "supersedes"/"alias of" pointer to another handle. Items already tagged with the old
handle could then be offered a one-time, explicit re-tag rather than a silent change of meaning.
For properties specifically, an alias needs to claim more than "these mean the same thing," it
needs to claim the values are comparable, which means recording a unit conversion (possibly just
"identical unit, factor 1") alongside the alias, not just a bare pointer. An alias with no stated
conversion should probably be treated as "same concept, values not (yet) comparable," a range
filter has no business guessing a conversion on its own.
**Filters default to per-handle, and only widen on an explicit alias.**
Following directly from the strict-resolution philosophy in federation.md: a range filter should
only ever combine two distinct property handles into one filterable facet because of an explicit
alias (see above) that also states the values are comparable, never because their names or units
happen to match. Absent that, two same-named properties from different origins should just show up
as two separate filters, visibly distinct, rather than the UI silently guessing they're the same
and producing a filter result that mixes incomparable values.
**Typed property values.**
Giving a property definition a declared value type (number, text, boolean, ...) in addition to its
existing unit metadata would let both the frontend and the alias/conversion mechanism above know
whether "range filter" even applies to a given property, and would close the gap where a value
happens to look numeric but isn't guaranteed to parse as one. This is useful even without
federation in the picture, cross-origin comparison just makes the gap load-bearing instead of
cosmetic.
**Origin metadata/versioning.**
If an origin represents an imported dataset, giving it its own version or changelog would let a
server know when the upstream taxonomy it imported has moved on, and decide explicitly whether to
re-import, rather than silently drifting from what other servers using the "same" origin now have.
This only helps if everyone's still on one shared timeline, though, it doesn't by itself address
diverging forks/branches ending up with the same name (see below).
**Pin origins to immutable content, not movable names.**
The `git:ee2` collision happens because the origin string names something mutable (a branch/tag)
rather than something fixed. If an origin string were derived from the content itself, e.g. a hash
of the definition, or a specific immutable commit rather than a branch, two independent imports
could never end up with the same string unless the content was actually identical at that point,
collisions would become structurally impossible rather than just unlikely. This is a bigger change
than the versioning idea above: it's not tracking change over time, it's making the identifier
itself incapable of silently referring to different things.
A concrete version of this: use the git blob hash of the file a property/tag/category was defined
in as (part of) its origin. If the reference dataset already lives in a git repo, this is free,
git's already computed it, and it's independently checkable, any server holding or able to fetch
the same repo can recompute the hash from the content and confirm for itself, rather than trusting
a label. This is a real improvement over free-text `origin` strings, but it isn't a free lunch:
- *Granularity.* A blob hash identifies a whole file, not a single property. If a file defines
several properties together, editing any one of them changes every other property's "identity"
in the same file too, even though nothing about them changed. Either definitions need to be
one-per-file for the hash to mean what's intended, or the hash needs to cover just the relevant
entry rather than the literal git blob.
- *It converts every edit into a fork.* Since any change, including a typo fix, changes the hash,
a routine upstream correction mechanically fragments what's still the same property into two
handles. That's consistent with "never silently reinterpret a handle," but it means the aliasing
mechanism above stops being a nice-to-have and becomes the primary upgrade path, every legitimate
edit needs an explicit "supersedes" link, or old items are stranded on a stale, now-orphaned hash.
- *A hash alone is an identity, not a location or a label.* It proves two things are the same (or
aren't), but doesn't say where to fetch the content from if you don't already have it, and isn't
human-readable. Pairing it with a location (which repo) and a mnemonic (which release/name it
corresponds to) alongside the hash keeps the discoverability goal intact instead of trading it
away for collision-proofing.
**Detect collisions on contact, as a backstop.**
Even with better-behaved identifiers going forward, existing data and human-typed origin strings
mean collisions can't be ruled out entirely. Whenever two servers interact over a handle they both
claim to know (e.g. as part of resolving an alias, or federated search), comparing a fingerprint of
the full definition, not just the handle string, would let a mismatch surface as an explicit
conflict to resolve, rather than being silently trusted. This is the same instinct as the "explicit
alias must state whether things are comparable" idea above, applied in the opposite direction, here
the handles already match and the system needs to actively check whether that trust is warranted.
The needed ingredient already exists locally and just isn't being used this way: the sha256 hash
already computed and stored per import (see above) is exactly the kind of definition fingerprint
this needs. Two changes would make it actually do the job: first, comparing it on every import
(including a "re-import" of a name already on record), not just recording it once, so a locally
edited file gets caught before it's ever presented to anyone else, and second, exchanging it as
part of whatever federated interaction references a shared-origin handle, so two servers can
compare hashes for the same name and find out they've diverged instead of assuming they haven't.
**What this means for the handle actually on the wire.**
Putting the above together, the everyday handle shouldn't change shape at all. It stays
`origin#type:name`, e.g. `git:ee2#property:charging_voltage`, exactly as it is today. The reason is
redundancy: this string is what appears on every single reference (every item's tag list, every
property assignment), potentially many times per item across many items, while a hash only ever
needs to be known once per origin. Carrying a full hash on every occurrence would repeat the same
value over and over for no benefit beyond what knowing it once already provides.
Instead, the hash stays where it already lives, attached to the origin as a whole (extending
`ImportedIdentifierSets`, see above), and gets exchanged at the points where two servers actually
need to agree on one, e.g. the first time a friend's item references an origin a server doesn't
already have a hash on record for. First contact just records it, same as resolving any unfamiliar
handle today; a later mismatch against what's on record is the collision, and that's the point
where it needs to become visible rather than silently trusted.
Only once a collision has actually been found does the wire format need to say more than
`origin#type:name`, because at that point there genuinely are two different things sharing a name
and something has to distinguish them for a person sorting it out. A short, abbreviated hash
appended to the origin, the same idea git itself relies on for short commit hashes, keeps that
escape hatch usable: `git:ee2~0f3a9c1e#property:charging_voltage` versus
`git:ee2~7bc82a04#property:charging_voltage`. This longer form is exception-path plumbing for
resolving an already-detected conflict, not something that changes the size or shape of handles in
the common case.
**Search across known origins.**
Before creating a new tag/category, a creation flow could search across origins the local server
already knows about (its own, plus any it's imported) and surface likely existing matches. This is
a UX/workflow fix rather than a change to the handle model itself, it doesn't need to touch
resolution semantics at all.
**Display name vs. handle.**
Separating "the name that appears in the handle" (stable, part of the identity) from "the label
shown to a user" (translatable, cosmetic) would allow localization without affecting resolution or
uniqueness, since resolution would stay keyed on the handle, not the display string.

141
docs/glossary-todo.md Normal file
View file

@ -0,0 +1,141 @@
# Glossary terminology TODO
Working list from a repo-wide audit of where code/docs use a different word for a concept that
[glossary.md](glossary.md) already gives a canonical name. Nothing here has been changed yet, this
is a collection point before any renaming/edit work starts. Grouped by glossary term; only real
inconsistencies are listed, not every correct usage that was checked and cleared.
## Backend
- `frontend/src/federation.js` — the whole module (`class ServerSet`, `add(server)`, every
request method) talks about "server" throughout where the concept is a **Backend**.
- `frontend/src/store.js``getHomeServers`, `getFriendServers`, `getAllKnownServers`,
`setAllFriendsServers`/`all_friends_servers`, `home_servers`, `lookupServer` (~line 313, 325,
332, 359, 76-77, 107-108, 334).
- `frontend/src/views/Friends.vue:16,43` — user-visible table column labeled "Server".
- `docs/design-in-progress/tags.md` (lines 10, 20, 45, 76-78, 117, 204, 228-229) — "server" used
throughout for what the glossary calls Backend (same looseness federation.md already has, but
worth normalizing here too since tags.md is in active editing).
- `issues.md:58` — "federated home servers" / "user identity" conflation.
- `deploy/dev/docker-compose.yml` (`instance_a`/`instance_b`) and `docs/development.md:106-108`
"instance"/"backend instance" as a near-synonym for Backend.
- `cli-client/toolshed-client.py:11-70`, `README.md:92``--host`/`self.host` for "which backend
to talk to".
## Discovery
- `frontend/src/store.js:313``lookupServer` action *is* the discovery operation, never named
"discovery".
- `frontend/src/store.js:347``could not resolve server for friend` — "resolve" used instead.
- `docs/design-in-progress/items-labels.md:99`, `docs/development.md:108` — describe the
discovery operation via "resolves"/"direct the frontend to the correct backend" without naming
it (minor, but candidates for a one-word tightening).
## Handle / User handle
- ~~`backend/authentication/models.py` (`class KnownIdentity`...), `signature_auth.py`
(`author_identity`...), `frontend/src/identity.js` (`serializeIdentityRecord`...)~~ — no longer a
finding: the glossary now has an explicit **Identity** entry (handle + keypair, held together as
the unit a backend trusts), and this is exactly what these already name. No renaming needed here;
if anything, these are the parts of the codebase the new Identity entry should point to as
reference implementations.
- `backend/toolshed/serializers.py:49-57` (`FriendSerializer`) — API field is literally named
`"username"` but its value is a full handle (`username + '@' + domain`). Already
self-acknowledged in a comment at `frontend/src/store.js:404`. **Highest-value single fix**
it's a live API contract, not just an internal name.
- `frontend/src/store.js` — several action params destructured as `{username}` that actually carry
a full handle: `lookupServer` (313), `getFriendServers` (359), `fetchFriendProfile` (401-405),
`login` (276-282).
- `frontend/src/views/Login.vue` (lines 24, 27-28, 82-83, 102-105, 115-117) — form label/variable
"Username" for a field that must be a full user handle (`user@domain`, per its own validation
message at line 103).
- `frontend/src/router.js:51` — route param `/inventory/shared/:user/:id` uses `:user` for what's
meant to eventually be a full handle; contrast with the sibling route at line 61 which already
correctly uses `:handle`. (Already called out by items-labels.md itself, so low-risk to leave
as-is, but listed for completeness.)
## Availability policy, Friend/Friendship, Signature/Signing, Strict resolution, Actor, Targeted sharing
No real inconsistencies found — implemented code already uses the glossary's own terms
consistently (`availability_policy` field name throughout backend+frontend; `friend`/`befriend`
consistently; `Signature`/`sign`/`verify` consistently; `_HandleNotFound`/`_resolve_handle` in
`backend/toolshed/offlinedata.py` implement strict resolution faithfully without needing to name
it; Actor and Targeted sharing are unimplemented with no competing name anywhere).
- Checked and cleared, not a real conflict: `frontend/src/neigbors.js`'s `NeighborsCache`/
"neighbor" vocabulary — refers to unreachable backend *domains* during discovery, not to
friendship, despite reading like a synonym at a glance.
## Group / Group handle / Membership list
- `backend/backend/settings.py:36``django.contrib.auth` ships a built-in `Group` model, shown
as "Groups" in the Django admin. Not unregistered anywhere. Will collide by name with the
proposed actor-type Group once that's implemented — worth a decision now (unregister the
built-in admin Group, or otherwise disambiguate) before the real feature lands.
- `issues.md` (issue #3, "Group Concept", ~lines 62-156) — a standalone proposal that conflicts
with the already-settled `docs/design-in-progress/groups.md` design on three points at once:
- `Group.public_key`/`private_key` fields (contradicts "does a group need its own keypair? No").
- `GroupMembership` backed by a signed `membership_certificate` rather than a plain membership
list (contradicts the glossary's Membership list entry).
- Bare `Group.handle` strings with no `#` prefix, e.g. `"makerspace-nord"`, and a
`GroupInvitationIncoming.group_handle` field/API surface (`POST /api/groups/` etc., ~lines
65-66, 117-121, 150-156) that never uses the `#groupname@domain` shape.
This is a design-conflict issue, not a wording tweak — `issues.md` should be reconciled with (or
explicitly marked superseded by) `groups.md` before anyone implements from it.
## Keypair / Private key / Public key
- Wire-format drift on the one field that actually crosses the network: `befriender_key` is used
for a public key at `frontend/src/store.js:435,449` and `backend/toolshed/api/friend.py:107`,
while the model field, serializer field, and UI all call the same value
`befriender_public_key`/`public_key` (`backend/authentication/models.py:144`,
`backend/toolshed/serializers.py:65`, `backend/toolshed/api/friend.py:118`,
`frontend/src/views/Friends.vue:81`).
- `cli-client/toolshed-client.py` (`--key`, `TOOLSHED_KEY`, `self.signing_key`, ~lines 12, 52, 57)
and `README.md:92` — never say "private key," just "key"/"Toolshed key", even though it's
specifically the private half.
## Origin
- `backend/configure.py:130` ("Identifier set {} already imported, skipping") and the model
`ImportedIdentifierSets` (`backend/hostadmin/models.py:13-19`) — call an imported origin dataset
an "identifier set".
- `issues.md:206-210` — Instance Admin TODO list: "identifier-sets" for **Origin** and bare
"identifiers" for **Classification handle**, both alternate terms not matching glossary names.
## Alias
- `backend/shared_data/ee_packages.json:40,64` — two tags already carry an `"alias"` field in real
data (e.g. `SOT54``alias: "TO-92"`), but shaped as a bare name string, not an
`origin#type:name` handle pointer, and with no unit-conversion concept. It's silently dropped on
import today (`Tag`/`TagSerializer` have no `alias` field). Not a different-word issue, but the
design doc (which calls Alias "Proposed") doesn't acknowledge this pre-existing, inert
precedent — worth reconciling either the data or the doc.
## Tag / Property / Category
- `frontend/src/components/workflow/workflows/BulkItemImportWorkflow.vue:495` — CSV
column-auto-mapping heuristic treats `"type"` as a synonym for Category:
`lowerColumn.includes('category') || lowerColumn.includes('type')`.
## Item Handle
- `frontend/src/views/Search.vue:30,52,110` — a field literally named `handle` is computed here
(`e.owner==this.user ? e.id : "shared/"+e.owner+"/"+e.id`), but it's a router-path fragment, not
an Item Handle: no domain-qualified `user@domain.tld:id` shape, and `e.owner` is a bare username.
Whoever implements the real Item Handle later is likely to collide with this existing variable.
## Item Label
- `frontend/src/components/workflow/workflows/FotoFirstBulkImportWorkflow.vue:567-572,822` and
`FotoFirstBulkImportWorkflow2.vue:593-598,847` — checkbox "Generate QR codes for items" /
`importOptions.generate_qr_codes` names exactly the Item Label concept but never uses that term
(and the option is currently unwired — declared and defaulted `true` but never read elsewhere).
## Item URL / Local id / Domain / Frontend / Definition fingerprint / Fragmentation / Handle collision / Classification handle
No real inconsistencies found — each already uses consistent, glossary-matching vocabulary
(`id`/`item_id` for Local id; `origin` kept cleanly separate from `domain` everywhere it's used;
`get_handle()` consistently for Classification handle; no competing names found anywhere for
Fragmentation, Handle collision, or Definition fingerprint, which also doesn't collide with the
unrelated `File.hash` content-hash field despite both being called "hash").

229
docs/glossary.md Normal file
View file

@ -0,0 +1,229 @@
# Glossary
This page collects one unambiguous name for each concept discussed in [federation.md](federation.md)
and the [design-in-progress](design-in-progress/) documents, so later writing can refer to them
consistently instead of reinventing or subtly renaming them. Entries are grouped by topic, and
alphabetical within each group. Each one notes whether the concept exists in Toolshed today or is
still a design proposal, and links back to where it's discussed in full.
Two terms are easy to conflate and worth telling apart up front: a **domain** is who a handle
belongs to; an **origin** is where a classification entry came from. They look similar in prose but
are unrelated concepts, see their entries below.
---
## Federation Basics
**Backend** (Implemented)
The server software that stores an [actor](#actor)'s data and is currently authoritative for a
[domain](#domain). Deliberately decoupled from both the domain (a backend isn't tied to one domain,
and can be authoritative for many at once) and the [frontend](#frontend) (the app a user loads isn't
necessarily served by the backend that ends up handling their requests). Prefer "backend" over the
looser word "server" when precision matters, "server" is used informally in places to mean either
backend or domain interchangeably.
*See: [federation.md](federation.md#servers)*
**Discovery** (Implemented)
The lookup a [frontend](#frontend) performs to find which [backend](#backend) is currently
authoritative for a [domain](#domain), given a [handle](#handle). Operational/DNS-level detail
about how this lookup works belongs in the deployment docs, not here, this glossary only fixes the
name for the concept.
*See: [federation.md](federation.md#servers)*
**Domain** (Implemented)
The half of a [handle](#handle) after the `@`, e.g. `toolsheddomain.tld`. An authority record, not
a location or a piece of software: it says which [backend](#backend) currently vouches for handles
under it, nothing more. Not the same thing as an [origin](#origin), see the note at the top of this
page.
*See: [federation.md](federation.md#servers)*
**Frontend** (Implemented)
The client application a user interacts with. Independent of any one [domain](#domain) or
[backend](#backend): given a handle, it performs [discovery](#discovery) to find the right backend
and talks to it directly, regardless of where the frontend itself was loaded from.
*See: [federation.md](federation.md#servers)*
**Handle** (Implemented)
A name that's unique within its own scope and carries, as part of itself, enough information to say
where it's authoritative, without needing a central registry to look it up. The general term
covering [user handles](#user-handle), [group handles](#group-handle) (proposed),
[classification handles](#classification-handle), and [item handles](#item-handle) (proposed).
*See: [federation.md](federation.md#unique-handles)*
**Strict resolution** (Implemented)
The rule that a reference to a [handle](#handle) a backend doesn't recognize is left unresolved
rather than guessed at or silently merged into something that looks similar. The foundational
guarantee the rest of the handle system, and most of the open problems in
[tags.md](design-in-progress/tags.md), are trying to preserve or actually make hold.
*See: [federation.md](federation.md#tags-properties-and-categories)*
## Actors, Identity & Sharing
**Actor** (Proposed)
Umbrella term for anything that can hold a handle, have friends, and own items. Today this only
means [user](#user). [Groups](#group) are a proposed second kind of actor, so that "friendship,"
"ownership," and "handle" all mean the same thing regardless of which kind of actor is involved.
*See: [groups.md](design-in-progress/groups.md#are-group-handles-different-from-user-handles-or-is-a-group-just-a-special-kind-of-user)*
**Availability policy** (Implemented)
A setting on an item (`private` / `share` / `lend` / `rent` / `sell`) controlling who besides the
owner can see it. Today the audience for any non-`private` policy is implicitly "all of the owner's
[friends](#friend-friendship)," equally, there's no way to name a narrower audience. See [targeted
sharing](#targeted-sharing) for the proposed alternative.
*See: [groups.md](design-in-progress/groups.md#interaction-with-availability-policy)*
**Friend / Friendship** (Implemented for users; proposed for groups)
A mutual, explicitly-established trust relationship between two [actors](#actor). Established by a
friend-request/accept exchange, which is also the point a [public key](#keypair-private-key-public-key)
is first trusted for that handle. Currently only exists between users; groups having friends, and
groups befriending groups, are proposed extensions of the same mechanism, not a new one.
*See: [federation.md](federation.md#cryptography), [groups.md](design-in-progress/groups.md#should-a-group-be-able-to-grant-read-access-to-non-members-group-friends)*
**Group** (Proposed)
A second kind of [actor](#actor), modeling collective ownership (a club, workshop, or company)
rather than any one person owning something. All members hold equal edit rights over what the group
owns; membership itself is the privilege, there's no separate owner/member distinction within a
group. Backed by a [membership list](#membership-list) rather than a [keypair](#keypair-private-key-public-key),
and identified by a [group handle](#group-handle).
*See: [groups.md](design-in-progress/groups.md#what-a-group-is)*
**Group handle** (Proposed)
A [group](#group)'s handle: a name and [domain](#domain) written like a [user handle](#user-handle)
but prefixed with `#`, e.g. `#groupname@toolsheddomain.tld`. The prefix keeps groups and users in
disjoint namespaces on the same domain (no squatting collision between a user and a group wanting
the same name) and lets an actor's kind be read directly off its handle, without a lookup.
*See: [federation.md](federation.md#users-and-groups), [groups.md](design-in-progress/groups.md#are-group-handles-different-from-user-handles-or-is-a-group-just-a-special-kind-of-user)*
**Identity** (Implemented)
A [user handle](#user-handle) paired with the [keypair](#keypair-private-key-public-key) that backs
it, held together as the one unit a [backend](#backend) actually trusts: not just a name, and not
just key material, but both at once. This is what gets established at registration for one's own
handle, and what gets recorded on [friend](#friend-friendship)-accept for someone else's handle.
Only [users](#user) have an identity in this sense, since a [group](#group) is deliberately backed
by a [membership list](#membership-list) instead of a keypair, there's no key half for a group
handle to pair with.
*See: [federation.md](federation.md#cryptography)*
**Keypair / Private key / Public key** (Implemented, users only)
The asymmetric keypair backing exactly one [user handle](#user-handle); together, a handle and the
keypair backing it are what's called an [identity](#identity). The private key signs requests made
as that user and never leaves their control; the public key is handed out to establish trust in the
handle (at registration, or via a [friend](#friend-friendship) exchange) and is used to verify
signatures. Only user handles carry a keypair, not groups, classification handles, or item handles.
*See: [federation.md](federation.md#cryptography)*
**Membership list** (Proposed)
The record of which [user handles](#user-handle) currently belong to a [group](#group), maintained
by whichever backend is authoritative for the group's handle. What backs a group's identity in
place of a keypair: a request "as the group" is a normal signed request from a current member, plus
a check against this list, not a request signed by some shared group key.
*See: [groups.md](design-in-progress/groups.md#does-a-group-need-its-own-keypair)*
**Signature / Signing** (Implemented)
The act of authenticating a request as genuinely coming from a specific [user
handle](#user-handle), unaltered, using that handle's private key. Establishes authenticity and
integrity only, not confidentiality (that's HTTPS's job) and not, today, protection against replay.
*See: [federation.md](federation.md#cryptography)*
**Targeted sharing** (Proposed)
Sharing an item with one specific [actor](#actor) (a particular friend, or a particular group)
instead of the current all-or-nothing [availability policy](#availability-policy) audience of every
friend equally. Flagged as a generalization useful beyond groups specifically, not a groups-only
feature.
*See: [groups.md](design-in-progress/groups.md#should-anyone-be-able-to-share-directly-with-a-specific-group-instead-of-with-my-friends-generally)*
**User** (Implemented)
The original, and currently only implemented, kind of [actor](#actor): backed by exactly one
[keypair](#keypair-private-key-public-key) and identified by a [user handle](#user-handle).
*See: [federation.md](federation.md#users-and-groups)*
**User handle** (Implemented)
A user's username paired with its [domain](#domain), written like an email address, e.g.
`user@toolsheddomain.tld`. Unique only within its domain, not across all of Toolshed. Contrast with
a [group handle](#group-handle), which is the same shape but prefixed with `#`. Paired with its
[keypair](#keypair-private-key-public-key), the two together are called an [identity](#identity).
*See: [federation.md](federation.md#users-and-groups)*
## Classification: Tags, Properties & Categories
**Alias** (Proposed)
An explicit, one-directional "supersedes"/"alias of" pointer from one [classification
handle](#classification-handle) to another, asserting they mean the same thing. Never inferred
automatically, always a deliberate act, in keeping with [strict resolution](#strict-resolution). For
a property alias specifically, also states a unit conversion (even if it's "identical unit, factor
1"); without one, the values behind the two handles aren't assumed to be comparable.
*See: [tags.md](design-in-progress/tags.md#open-design-ideas)*
**Classification handle** (Implemented)
Umbrella term for a [tag](#tag-property-category), [property](#tag-property-category), or
[category](#tag-property-category) handle, of the form `origin#type:name`, e.g.
`git:base#property:length`. Distinct from a [user handle](#user-handle) or [item
handle](#item-handle): it names a reusable classification concept, not an actor or an owned thing.
*See: [federation.md](federation.md#tags-properties-and-categories)*
**Definition fingerprint** (Partially implemented)
A hash of a classification entry's full definition, used to tell whether two [actors](#actor)
that both use the same handle actually mean the same thing. A sha256 of each `shared_data/*.json`
file is already computed and stored per import (`ImportedIdentifierSets.hash`), but it's only ever
recorded, not compared, so it doesn't yet catch a [handle collision](#handle-collision) in
practice.
*See: [tags.md](design-in-progress/tags.md#open-design-ideas)*
**Fragmentation** (Known problem)
Two different [classification handles](#classification-handle) that mean, or were intended to
mean, the same real-world concept (different origins, or a locally-invented tag versus a shared
one). The opposite failure from a [handle collision](#handle-collision): visible and merely
wasteful, rather than silent and dangerous.
*See: [tags.md](design-in-progress/tags.md#problem)*
**Handle collision** (Known problem)
Two [actors](#actor) independently ending up with the exact same [classification
handle](#classification-handle) string backing two different definitions, e.g. two servers that
each imported `git:ee2` from what has since become diverging branches. The dangerous mirror image
of [fragmentation](#fragmentation): silent, because nothing about the interaction signals that
anything's wrong, both sides just say the same string.
*See: [tags.md](design-in-progress/tags.md#worked-example-the-same-handle-meaning-two-different-things)*
**Origin** (Implemented)
The first component of a [classification handle](#classification-handle), naming where that tag,
property, or category came from, e.g. `git:base` in `git:base#property:length`. Not necessarily a
server or a domain, it can equally be a shared reference dataset (like the files in
`backend/shared_data/`) or a server's own locally-invented taxonomy. Not the same thing as a
[domain](#domain), see the note at the top of this page.
*See: [federation.md](federation.md#tags-properties-and-categories)*
**Tag / Property / Category** (Implemented)
The three kinds of classification entity an item can reference, each identified by a
[classification handle](#classification-handle). A property additionally carries unit metadata
(`unit_symbol`/`unit_name`), though property *values* on an item are plain, undeclared-type
strings today.
*See: [federation.md](federation.md#tags-properties-and-categories), [tags.md](design-in-progress/tags.md)*
## Items & Physical Labels
**Item Handle** (Proposed)
The compact identifier for a specific item that's meaningful outside its owner's own account:
`user@domain.tld:id`, the owner's [user handle](#user-handle) plus a [local id](#local-id). Used
where it's already clear from context that it's a Toolshed item, e.g. inside the app, in exports,
in logs, so it doesn't need to spell that out or be openable on its own. Contrast with [Item
URL](#item-url), the self-contained form for when no such context can be assumed.
*See: [items-labels.md](design-in-progress/items-labels.md#open-design-questions)*
**Item Label** (Proposed)
A physical, scannable encoding (QR code, barcode, or similar) of an item's [Item URL](#item-url),
meant to be printed and stuck on the physical object it refers to.
*See: [items-labels.md](design-in-progress/items-labels.md#goals)*
**Item URL** (Proposed)
The self-contained URL form of an [Item Handle](#item-handle), for use with no context at all, e.g.
an [Item Label](#item-label): `https://<any frontend>/i/user@domain.tld/id`. Has to open directly
to the right frontend and land on the right item on its own, since the reader can't be assumed to
already know what it is or which server it belongs to. Any frontend can serve this URL, the host
named in it isn't part of the item's identity, only the handle in its path is.
*See: [items-labels.md](design-in-progress/items-labels.md#open-design-questions)*
**Local id** (Implemented)
An item's identifier as it exists today: unique only within its owner's own inventory, not
meaningful outside that owner's account. The starting point both the [Item
Handle](#item-handle) and [Item URL](#item-url) build on.
*See: [federation.md](federation.md#items), [items-labels.md](design-in-progress/items-labels.md#problem)*

View file

@ -0,0 +1,168 @@
# Handles and Short IDs
This is the syntax-level reference for two related but separate naming schemes. [federation.md](federation.md)'s
"Unique Handles" section covers *why* Toolshed hands out handles at all and what each kind (user,
group, tag/property/category) means conceptually; this document covers the parsing rules those
handles have to follow once they're written down or embedded somewhere - legal characters and
escaping. It also covers short ids end to end: a separate, newer scheme for packing small integer
id chains into a compact token, implemented in `frontend/src/short-id.js`.
## Handle syntax
### Reserved characters
A username ends up embedded, unescaped, in several composite formats beyond its own handle, so it
can't contain any character that already means something else in one of those: `@` (the
user/domain separator in a user handle), `#` (the group-handle prefix, and the origin/type
separator in a classification handle, see federation.md's Tags, Properties, and Categories
section), `:` (the id delimiter in a proposed Item Handle, the type/name delimiter in a
classification handle, and the delimiter in a signed request's `Authorization` header), `+`
(reserved as the URL-embedding escape for `#`, see below), `~` (the short-id token prefix, see
Short IDs below, and a proposed collision-disambiguation suffix delimiter on a tag's origin), and
`/` (the path-segment delimiter every handle and id ultimately sits next to once embedded in a
URL). This has to be enforced by an explicit validator rather than left to Django's default
`UnicodeUsernameValidator` (`^[\w.@+-]+\Z`), which currently permits both `@` and `+` (its own
regex doesn't happen to allow `#`, `:`, or `/`, but that's incidental, not a designed restriction).
### Embedding a `#`-bearing handle in a URL
A literal `#` can't appear unescaped in a URL path segment: per RFC 3986, `#` starts the URI's
fragment component, so any URL-parsing client (a browser, a QR scanner, a link preview) treats
everything from the first unescaped `#` onward as a fragment and never sends it to the server at
all, before a request is even made, not merely a server-side quirk to work around. The usual fix is
to percent-encode it (`%23`), but that's exactly the encode/decode step a self-contained item URL
(a physical label, a link shared outside the app) is designed to avoid for anything that sits
directly in a path segment (`@` needs no such treatment). Instead, whenever a handle containing a
`#` (a group handle, or a tag/property/category
handle) has to appear as a raw URL path segment, substitute `+` for `#` in that rendering only:
`#groupname@domain` becomes `+groupname@domain` in a URL, and `origin#type:name` becomes
`origin+type:name`. This is a URL-embedding convention, not a second handle format: the canonical
handle, the one used in the API, in signed requests, in the database, and everywhere else a handle
is written or displayed, is unchanged and is still written `#groupname@domain`. Reversing the
substitution when parsing a path segment back into a handle is unambiguous only because `+` is
otherwise forbidden in every field a handle is built from (see Reserved characters above); if a
group name or tag name could itself contain a literal `+`, it would be indistinguishable from an
escaped `#` once decoded.
Implemented in `frontend/src/handle-url.js` (`encodeHandleForUrl`/`decodeHandleFromUrl`).
## Short IDs
A general encoding for turning a small, fixed-shape list of integers into a compact, URL-safe
token, with no server-side lookup table involved: the code *is* the data, nothing is stored
server-side to make it resolvable. Originally proposed to answer items-labels.md's open "what does
the handle/URL actually look like" question, but the encoding itself isn't item-specific; anything
currently addressed by a short chain of small integers is a candidate. Implemented and tested in
`frontend/src/short-id.js`; try it live at `/~<token>` (`frontend/src/views/ShortId.vue`), which
decodes whatever token is in the URL and also lists worked examples for every registered kind.
### Shape: a kind tag, then a fixed list of integers
Every short id starts with a small, fixed-width **kind** tag saying which schema the rest of the
bits should be read against, followed by exactly the integer fields that kind's schema calls for,
in a fixed order. `kind` is a small, closed, slow-growing set, so it doesn't need to be
self-delimiting the way the integer fields do: 2 bits directly name kinds 0-2, and the all-ones
value (3) is an escape meaning "the real kind follows as the next field, offset by this direct
range" - so kind 3 is encoded as escape + chunked-int `0`, kind 4 as escape + `1`, and so on. This
costs nothing for a kind that already fits in the direct range, and keeps the tag itself extensible
forever without ever having to widen it out from under codes that were already printed. A narrow
tag only pays off if kind usage is actually skewed the way id values are (a few kinds dominate),
which is why the registry below is ordered by expected frequency, cheapest (most-used) kind first:
| kind | name | fields | notes |
|---|---|---|---|
| 0 | `item` | `owner_identity_id`, `item_local_id` | dominant case - the primary physical-label use case |
| 1 | `storage_location` | `owner_identity_id`, `storage_location_id` | also label-printed |
| 2 | `category` | `category_id` | label-adjacent (tagging); global, no owner |
| 3 | `workflow` | `owner_identity_id`, `workflow_id` | shared in-app, not printed - first to pay the escape's cost |
| 4 | `group` | `group_id` | shared even less often; global, no owner |
| 5 | `file` | `file_id` | least often shared standalone; global, deduplicated by content hash |
`owner_identity_id` is `KnownIdentity.pk` (`backend/authentication/models.py`), not
`ToolshedUser.pk`. Every local account already has exactly one stable `KnownIdentity` row
(`ToolshedUser.public_identity`, created once at registration and never recreated), and every
friend this backend knows about - local or remote - is represented by that same table, unique on
`(username, domain)`. So one small integer already stands in for "this owner, as known by this
backend" for both cases, with no separate local-vs-remote branching needed, and it's the same row
federation.md's Cryptography section already treats as the trust anchor for a handle's public key.
It appears on `item`, `storage_location`, and `workflow` because their backing models
(`InventoryItem`, `StorageLocation`, `WorkflowInstance`) all FK `ToolshedUser` directly; `category`,
`group`, and `file` skip it because their models are global/unscoped (`Group` has an unowned
`members` M2M, `File` is deduplicated globally by content hash), so a bare row id is already
everything needed to look them up.
A short id is inherently scoped to the backend that minted it (an "owner" field is a row that only
exists in, and only means anything to, that one backend's database), not a portable replacement for
a `user@domain.tld` handle, which stays the form to use anywhere cross-domain resolution actually
matters. Resolving a short id still goes through the same friend/signature checks as everything
else, unchanged; nothing about how the code looks grants any authority of its own (see Guessability
below).
### Packing one integer: dynamic bit depth
Each integer field is made self-delimiting with **continuation chunking** (UTF-8/LEB128-style):
split the value into fixed-size chunks (4 data bits each, most-significant chunk first), each
preceded by one continuation bit meaning "another chunk follows" (`1`) or "this is the last chunk"
(`0`). A value like `42` (`0b101010`) needs two 4-bit chunks, costing 10 bits total (2 × (1
continuation + 4 data)); `7` fits in one chunk, costing 5 bits. This was chosen over an
Elias-gamma-style unary/delimiter scheme (encode the value's bit-length in unary, then that many
literal bits): unary is cheaper for single-digit values but its prefix grows every time the value's
bit-length grows, so it never wins once ids pass single digits, which is the common case here (auto
increment database ids realistically sitting in the tens through low-hundred-thousands over an
installation's life). A 4-bit chunk width is a reasonable fixed default across that whole range;
per-field tuning was checked against both a uniform and a skewed (geometric) distribution and never
won by more than a fraction of a character, not enough to justify a tuning knob.
### From bits to text: base64 without the byte layover
Standard base64 assumes byte-aligned (8-bit) input, grouping 3 bytes into 4 output characters and
padding to a byte boundary before encoding. Since there's no byte layer here to begin with, the
bit-packed stream is instead packed directly into 6-bit groups and mapped straight onto the
URL-safe base64 alphabet (RFC 4648 §5: `-` and `_` in place of `+` and `/`), with the final
character's unused low bits padded with zeros. That padding is safe by construction: the decoder
always knows exactly how many integers a given kind calls for, and a chunk's continuation bit is
`1 = more follows`, so a run of zero-padding at the very end can never be misread as "one more
chunk" - it decodes as a terminated chunk, at which point every field the schema called for has
already been produced and decoding simply stops. No `=` padding characters are needed either; those
exist in classic base64 purely to communicate trailing-byte padding, and there is no byte layer
here to need that.
### The leading `~`
Every token is prefixed with a literal `~`, so a short id in a URL looks like `~DyU`. Its only job
is to mark "everything after me decodes as one of these": URL-safe base64 never produces a `~`
itself, so the prefix can never be confused with the payload, and none of Toolshed's other
path-segment formats (bare usernames, `user@domain` handles, slugs, plain numeric ids) start with
`~` either. `~` is one of RFC 3986's `unreserved` characters (§2.3, the same class as letters,
digits, `-`, `.`, and `_`), a stronger guarantee than merely being legal in a path segment: it's
never a target for percent-encoding and never carries special meaning in any URI component, so a
short id can be handed to any part of the stack without first checking which encoding rules apply
there.
### Worked examples
Encoding `kind = item` (0), `owner_identity_id = 7`, `item_local_id = 42`:
- `kind`: 2 fixed bits → `00`
- `owner_identity_id = 7`: fits in one 4-bit chunk → 5 bits (`00111`)
- `item_local_id = 42`: needs two 4-bit chunks → 10 bits (`1001001010`)
Total: 17 meaningful bits, padded to the next multiple of 6 (18) with one zero bit, yielding 3
base64 characters: **`~DyU`**.
The same worked-out form for one example of every registered kind - `Bits` is the same
space-separated segmentation (kind tag, escape offset if present, each field, then padding) the
Examples table on `/~<token>` (`frontend/src/views/ShortId.vue`) shows for every registered kind:
| Kind | Fields | Serialized | Bits | Token |
|---|---|---|---|---|
| `item` | `owner_identity_id: 7`, `item_local_id: 42` | `[0, 7, 42]` | `00 00111 1001001010 0` | `~DyU` |
| `storage_location` | `owner_identity_id: 3`, `storage_location_id: 1000` | `[1, 3, 1000]` | `01 00011 100111111001000 00` | `~Rz8g` |
| `category` | `category_id: 5` | `[2, 5]` | `10 00101 00000` | `~ig` |
| `workflow` | `owner_identity_id: 2`, `workflow_id: 9` | `[3, 2, 9]` | `11 00000 00010 01001 0` | `~wCS` |
| `group` | `group_id: 11` | `[4, 11]` | `11 00001 01011` | `~wr` |
| `file` | `file_id: 123` | `[5, 123]` | `11 00010 1011101011 0` | `~xXW` |
`workflow`, `group`, and `file` are kinds 3-5, so their `Bits` column shows the escape tag (`11`)
followed by its own offset segment payload fields.

View file

@ -9,5 +9,6 @@ This is the documentation for the Toolshed project. It is a work in progress.
- [Deploying Toolshed](deployment.md)
- [Development Setup](development.md)
- [About Federation](federation.md)
- [Handles and Short IDs](handles-and-shortids.md)

View file

@ -0,0 +1,9 @@
# Pixel fonts used for small label text (see ../../../scss/_pixel-fonts.scss)
- **Tom Thumb** (`TomThumb.ttf`) - by Brian Swetland, TTF conversion by gheja
(https://github.com/gheja/tom-thumb-ttf). Licensed CC0 or CC-BY 3.0 (original:
https://robey.lag.net/2010/01/23/tiny-monospace-font.html).
- **PICO-8** (`PICO-8.ttf`) - reproduction by Jacob Pierce
(https://github.com/jacobpierce/pico-8-font). MIT License, Copyright (c) 2016 Jacob Pierce.
- **Silkscreen** (`Silkscreen-Regular.woff2`) - by Jason Kottke, served via Google Fonts
(https://fonts.google.com/specimen/Silkscreen). SIL Open Font License 1.1.

Binary file not shown.

Binary file not shown.

View file

@ -7,7 +7,7 @@
<div class="template-grid d-flex flex-wrap align-items-start">
<div v-for="t in labelTemplates" :key="t.id" class="template-option d-flex flex-column text-center"
:class="{'template-option-disabled': !isAvailable(t)}"
:title="isAvailable(t) ? '' : 'Not available - open this page from an item to fill in the fields this layout needs.'"
:title="isAvailable(t) ? '' : 'Not available - fill in the fields this layout needs above.'"
role="button" @click="isAvailable(t) && $emit('input', t.id)">
<canvas :ref="el => setTemplateCanvasRef(t.id, el)"
class="img-thumbnail template-thumb-canvas"
@ -52,12 +52,13 @@
</style>
<script>
import {LABEL_TEMPLATES, drawFallbackLabel, templateIsAvailable, templateContent} from "@/label-drawing.js";
import {drawFallbackLabel} from "@/label.js";
import {LABEL_TEMPLATES, templateIsAvailable, templateContent} from "@/label-layouts.js";
export default {
name: "LabelLayoutPreview",
props: {
// Named content fields the templates draw from (see label-content.js's buildLabelFields)
// Named content fields the templates draw from (see label.js's buildLabelFields)
// - kept in sync by the parent, not owned here. A field missing from this object (rather
// than present-but-empty) means a template that needs it is unavailable right now.
fields: {
@ -109,7 +110,7 @@ export default {
continue;
}
const content = templateContent(t, this.fields);
if (!this.isAvailable(t) || (!content.qr && !content.text)) {
if (!this.isAvailable(t) || !content) {
canvas.width = 1;
canvas.height = 1;
continue;

View file

@ -1,45 +0,0 @@
// Turns a {kind, components} prefill into the literal string a print label should show/encode.
// Keeping this keyed by `kind` rather than having each caller build its own string means the
// format for a given kind of label content only has to be gotten right in one place.
export const LABEL_CONTENT_BUILDERS = {
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a
// printed label actually encodes, since scanning it has to resolve the right
// frontend/backend/item with no other context, not just this browser's history.
"item-url": ({user, id}) => `${window.location.origin}/i/${user}/${id}`,
};
export function buildLabelContent(prefill) {
if (!prefill) {
return "";
}
const build = LABEL_CONTENT_BUILDERS[prefill.kind];
return build ? build(prefill.components) : "";
}
// Named fields the field-specific label templates (see label-drawing.js's LABEL_TEMPLATES) draw
// from - keyed by `kind` for the same reason LABEL_CONTENT_BUILDERS is. A field missing from the
// result (rather than present-but-empty) is what LabelLayoutPreview.vue treats as "not available",
// so builders should only include a field once its inputs actually check out.
const LABEL_FIELD_BUILDERS = {
"item-url": ({user, id}) => {
if (!user || !id) {
return {};
}
return {
itemUrl: `${window.location.origin}/i/${user}/${id}`,
// The compact "owner handle + id" form from docs/design-in-progress/items-labels.md -
// meaningful only where context already makes clear it's a Toolshed item, unlike itemUrl.
itemHandle: `${user}:${id}`,
userHandle: user,
itemId: String(id),
};
},
};
export function buildLabelFields(prefill) {
if (!prefill) {
return {};
}
const build = LABEL_FIELD_BUILDERS[prefill.kind];
return build ? build(prefill.components) : {};
}

View file

@ -1,329 +0,0 @@
import QRCode from "qrcode";
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
/* A quiet zone narrower than the spec's usual 4 modules: the printer's own
feed margin already keeps the code clear of the tape edge and the cut. */
const QUIET_ZONE_MODULES = 2;
/* Sizing a label needs numbers only the driver can supply. */
export function tapeFromStatus(status) {
const printAreaPx = status?.tape?.printAreaPx;
const dpi = status?.printer?.dpi;
if (!(printAreaPx > 0) || !(dpi > 0)) {
return null;
}
return {
printAreaPx,
dpi,
mediaWidthMm: status.tape.mediaWidthMm,
printLengthPx: status.tape.printLengthPx > 0 ? status.tape.printLengthPx : 0,
/* Brother's documented margin for the mounted tape, in raster columns. */
leadPx: status.tape.marginsMm
? Math.round(status.tape.marginsMm * dpi / 25.4)
: TRAILING_PADDING_PX,
};
}
/*
Draw the QR code as large as the tape allows, centered in a square, with an
integer number of pixels per module so it stays crisp at printer
resolution rather than blurring at a fractional scale.
*/
function drawQrLabel(canvas, qr, tape) {
const modules = qr.modules.size + QUIET_ZONE_MODULES * 2;
const maxLength = tape.printLengthPx
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
: Infinity;
const scale = Math.floor(Math.min(tape.printAreaPx, maxLength) / modules);
if (!(scale >= 1)) {
throw new Error("This text needs a bigger QR code than the tape allows — "
+ "try a shorter value or a wider tape.");
}
const square = modules * scale;
const width = tape.printLengthPx || (square + tape.leadPx + TRAILING_PADDING_PX);
canvas.width = width;
canvas.height = tape.printAreaPx;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, canvas.height);
const left = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - square) / 2);
const top = Math.floor((canvas.height - square) / 2);
ctx.fillStyle = "#000";
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
left + (col + QUIET_ZONE_MODULES) * scale,
top + (row + QUIET_ZONE_MODULES) * scale,
scale, scale,
);
}
}
}
}
// Each template names which field (see label-content.js's buildLabelFields) feeds its QR code
// and/or its printed text - `null` means that half of the layout is skipped. `text` can also be
// an array of field names, one per printed line, for a stacked multi-line layout. A template is
// only selectable once every field it names is actually available (see templateIsAvailable).
export const LABEL_TEMPLATES = [
{id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.",
qr: "value", text: null},
{id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
qr: "value", text: "value"},
{id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits.",
qr: null, text: "value"},
{id: "item-handle", name: "Item handle", qr: null, text: "itemHandle",
description: "The compact owner@domain:id handle - meaningful in-app, not scannable on its own."},
{id: "item-url", name: "Item URL", qr: null, text: "itemUrl",
description: "The full item URL as text, with no code - for copying rather than scanning."},
{id: "owner-handle", name: "Owner handle", qr: null, text: "userHandle",
description: "Just the owning user's handle, as text only."},
{id: "item-id", name: "Item ID", qr: null, text: "itemId",
description: "Just the bare item id, as text only."},
{id: "owner-id-text", name: "Owner + item ID", qr: null, text: ["userHandle", "itemId"],
description: "The owner's handle and the item id, as two lines of text - no code."},
{id: "item-url-qr-handle", name: "Item URL + handle", qr: "itemUrl", text: "itemHandle",
description: "Scannable item URL, with the item's compact handle printed alongside."},
{id: "item-url-qr-owner", name: "Item URL + owner", qr: "itemUrl", text: "userHandle",
description: "Scannable item URL, with the owner's handle printed alongside."},
{id: "item-url-qr-id", name: "Item URL + item ID", qr: "itemUrl", text: "itemId",
description: "Scannable item URL, with the bare item id printed alongside."},
{id: "item-url-qr-owner-id", name: "Item URL + owner + ID", qr: "itemUrl", text: ["userHandle", "itemId"],
description: "Scannable item URL, with the owner's handle and the item id on two lines alongside."},
];
// keysOf/templateIsAvailable/templateContent are the single place that understands the `qr`/
// `text` field-name shape above (including `text` sometimes being an array) - both
// LabelLayoutPreview.vue's thumbnail grid and Print.vue's big preview resolve a template through
// these rather than each re-implementing the same lookup.
function keysOf(spec) {
if (!spec) {
return [];
}
return Array.isArray(spec) ? spec : [spec];
}
export function templateIsAvailable(t, fields) {
return [...keysOf(t.qr), ...keysOf(t.text)].every(key => fields[key] !== undefined);
}
/* Resolves a template's field names against actual field values. `text` comes back as an array
whenever the template's `text` spec is an array (multi-line), or a plain string otherwise -
drawLabel/drawFallbackLabel below accept either. */
export function templateContent(t, fields) {
const resolve = (spec) => {
if (!spec) {
return null;
}
return Array.isArray(spec) ? spec.map(key => fields[key]) : fields[spec];
};
return {qr: resolve(t.qr), text: resolve(t.text)};
}
function measureAtHeight(ctx, text, px) {
ctx.font = `${px}px sans-serif`;
return ctx.measureText(text).width;
}
/* Picks the largest integer font size (down to a floor) that fits every one of `lines` within
maxWidth, stacked within maxHeight - this is a label, not a paragraph, so each line shrinks to
fit rather than wrapping. */
function fitTextSize(ctx, lines, maxWidth, maxHeight) {
const minPx = 8;
let px = Math.max(minPx, Math.floor(maxHeight / lines.length));
while (px > minPx && lines.some(line => measureAtHeight(ctx, line, px) > maxWidth)) {
px -= 1;
}
return px;
}
/* The qr-text/text-only layouts, tape-fed. Kept separate from drawQrLabel above (rather than
generalizing it) so the plain QR-only path - the common case - is untouched by this. */
function drawLabelWithText(canvas, tape, qrContent, textContent) {
const qr = qrContent ? QRCode.create(qrContent) : null;
const lines = Array.isArray(textContent) ? textContent : [textContent];
const availableHeight = tape.printAreaPx;
const maxLength = tape.printLengthPx
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
: Infinity;
let scale = 0, qrSize = 0;
if (qr) {
const modules = qr.modules.size + QUIET_ZONE_MODULES * 2;
// The QR only gets half the length budget on a fixed-length tape, so a long text value
// can't starve it down to unreadable - the rest goes to the text next to it.
const qrBudget = maxLength === Infinity ? Infinity : maxLength / 2;
scale = Math.floor(Math.min(availableHeight, qrBudget) / modules);
if (!(scale >= 1)) {
throw new Error("This text needs a bigger QR code than the tape allows — "
+ "try a shorter value, a wider tape, or the text-only layout.");
}
qrSize = modules * scale;
}
const gap = qr ? Math.round(availableHeight * 0.15) : 0;
const textBudget = maxLength === Infinity ? Infinity : maxLength - qrSize - gap;
if (!(textBudget > 0)) {
throw new Error("No room left for the text next to the QR code on this tape — "
+ "try a wider tape or the QR-only layout.");
}
const measureCtx = canvas.getContext("2d");
const textPx = fitTextSize(measureCtx, lines, textBudget, availableHeight);
const textWidth = Math.max(...lines.map(line => measureAtHeight(measureCtx, line, textPx)));
if (textBudget !== Infinity && textWidth > textBudget) {
throw new Error("This text doesn't fit on this tape even at the smallest readable size — "
+ "try a shorter value, a wider tape, or a bigger label.");
}
const contentWidth = qrSize + gap + textWidth;
const width = tape.printLengthPx || Math.ceil(contentWidth + tape.leadPx + TRAILING_PADDING_PX);
canvas.width = width;
canvas.height = availableHeight;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, canvas.height);
ctx.fillStyle = "#000";
let cursor = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2);
if (qr) {
const top = Math.floor((canvas.height - qrSize) / 2);
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
cursor + (col + QUIET_ZONE_MODULES) * scale,
top + (row + QUIET_ZONE_MODULES) * scale,
scale, scale,
);
}
}
}
cursor += qrSize + gap;
}
ctx.font = `${textPx}px sans-serif`;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
// Lines stack as a block vertically centered in the label, rather than each line centered on
// its own - keeps a two-line block reading as one unit instead of drifting apart.
const lineHeight = Math.ceil(textPx * 1.15);
let y = (canvas.height - lineHeight * lines.length) / 2 + lineHeight / 2;
for (const line of lines) {
ctx.fillText(line, cursor, y);
y += lineHeight;
}
}
/* Dispatches to the right tape-fed layout - drawQrLabel is untouched so the plain QR-only
layout keeps its exact original pixel output. `content` is {qr, text}, each either the string
to encode/print or null/undefined to skip that half of the layout (see LABEL_TEMPLATES). */
export function drawLabel(canvas, tape, content) {
if (content.qr && !content.text) {
drawQrLabel(canvas, QRCode.create(content.qr), tape);
} else {
drawLabelWithText(canvas, tape, content.qr, content.text);
}
}
const FALLBACK_SCALE_PX = 8; /* pixels per QR module in the no-webusb preview/PNG */
const FALLBACK_QUIET_ZONE_MODULES = 4; /* the spec's usual quiet zone - there's no printer feed margin to lean on here */
/* Same idea as drawQrLabel, but without a real device to ask for tape dimensions: just a
plain square QR code, sized for a PNG someone downloads and prints some other way. */
function drawQrSquare(canvas, qr) {
const modules = qr.modules.size + FALLBACK_QUIET_ZONE_MODULES * 2;
const size = modules * FALLBACK_SCALE_PX;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = "#000";
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
(col + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX,
(row + FALLBACK_QUIET_ZONE_MODULES) * FALLBACK_SCALE_PX,
FALLBACK_SCALE_PX, FALLBACK_SCALE_PX,
);
}
}
}
}
const FALLBACK_LABEL_HEIGHT_PX = 200; /* target content height for the qr-text/text-only fallback layouts */
const FALLBACK_TEXT_MARGIN_PX = 16; /* left/right margin around a text-only/qr-text fallback label */
/* The qr-text/text-only layouts for the no-webusb fallback preview/PNG. There's no real tape
to fit into here, so - unlike drawLabelWithText - the canvas just grows to fit its content. */
function drawFallbackLabelWithText(canvas, qrContent, textContent) {
const qr = qrContent ? QRCode.create(qrContent) : null;
const lines = Array.isArray(textContent) ? textContent : [textContent];
let scale = 0, qrSize = 0;
if (qr) {
const modules = qr.modules.size + FALLBACK_QUIET_ZONE_MODULES * 2;
scale = Math.max(1, Math.floor(FALLBACK_LABEL_HEIGHT_PX / modules));
qrSize = modules * scale;
}
const gap = qr ? Math.round(FALLBACK_LABEL_HEIGHT_PX * 0.15) : 0;
const measureCtx = canvas.getContext("2d");
const textPx = fitTextSize(measureCtx, lines, Infinity, FALLBACK_LABEL_HEIGHT_PX);
const textWidth = Math.max(...lines.map(line => measureAtHeight(measureCtx, line, textPx)));
const height = Math.max(qrSize, FALLBACK_LABEL_HEIGHT_PX);
const width = qrSize + gap + textWidth + FALLBACK_TEXT_MARGIN_PX * 2;
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = "#000";
let cursor = FALLBACK_TEXT_MARGIN_PX;
if (qr) {
const top = Math.floor((height - qrSize) / 2);
for (let row = 0; row < qr.modules.size; row++) {
for (let col = 0; col < qr.modules.size; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(
cursor + (col + FALLBACK_QUIET_ZONE_MODULES) * scale,
top + (row + FALLBACK_QUIET_ZONE_MODULES) * scale,
scale, scale,
);
}
}
}
cursor += qrSize + gap;
}
ctx.font = `${textPx}px sans-serif`;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
const lineHeight = Math.ceil(textPx * 1.15);
let y = (height - lineHeight * lines.length) / 2 + lineHeight / 2;
for (const line of lines) {
ctx.fillText(line, cursor, y);
y += lineHeight;
}
}
/* Dispatches to the right fallback layout - drawQrSquare is untouched so the plain QR-only
layout keeps its exact original pixel output. `content` is {qr, text}, see drawLabel above. */
export function drawFallbackLabel(canvas, content) {
if (content.qr && !content.text) {
drawQrSquare(canvas, QRCode.create(content.qr));
} else {
drawFallbackLabelWithText(canvas, content.qr, content.text);
}
}

View file

@ -0,0 +1,205 @@
// Each template's `layout` is a tree as described in label.js, with "qrcode"/"text" leaves'
// `content` a function from the resolved field values (see label.js's buildLabelFields) to what
// they render - `null`/`undefined` from that function means the field isn't available yet (see
// templateIsAvailable below). A template is only selectable once every leaf's `content` resolves
// to a value.
const GAP = {type: "empty", "min-width": "1mm", "min-height": "1mm"};
export const LABEL_TEMPLATES = [
{
id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.",
required_vars: ["text"],
layout: [{type: "qrcode", content: c => c.text}]
},
{
id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
required_vars: ["text"],
layout: [{type: "qrcode", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}]
},
{
id: "qr-text-below", name: "QR code + text below",
description: "The code with the encoded text printed below it.",
required_vars: ["text"],
layout: [[{type: "qrcode", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}]]
},
{
id: "id-qr-text-vertical", name: "ID + QR code + text below",
description: "The code with the encoded text printed below it.",
required_vars: ["itemId", "text", "userHandle"],
layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, {
type: "qrcode",
content: c => c.text
}, GAP, {type: "text", content: c => c.userHandle}]]
},
{
id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits.",
required_vars: ["text"],
layout: [{type: "text", content: c => c.text?.split("\n")}]
},
{
id: "item-handle", name: "Item handle",
description: "The compact owner@domain:id handle - meaningful in-app, not scannable on its own.",
required_vars: ["itemHandle"],
layout: [{type: "text", content: c => c.itemHandle}]
},
{
id: "item-url", name: "Item URL",
description: "The full item URL as text, with no code - for copying rather than scanning.",
required_vars: ["itemUrl"],
layout: [{type: "text", content: c => c.itemUrl}]
},
{
id: "owner-handle", name: "Owner handle", description: "Just the owning user's handle, as text only.",
required_vars: ["userHandle"],
layout: [{type: "text", content: c => c.userHandle}]
},
{
id: "item-id", name: "Item ID", description: "Just the bare item id, as text only.",
required_vars: ["itemId"],
layout: [{type: "text", content: c => c.itemId}]
},
{
id: "owner-id-text", name: "Owner + item ID",
description: "The owner's handle and the item id, as two lines of text - no code.",
required_vars: ["userHandle", "itemId"],
layout: [{type: "text", content: c => [c.userHandle, c.itemId]}]
},
{
id: "item-url-qr-handle", name: "Item URL + handle",
description: "Scannable item URL, with the item's compact handle printed alongside.",
required_vars: ["itemUrl", "itemHandle"],
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemHandle}]
},
{
id: "item-url-qr-owner", name: "Item URL + owner",
description: "Scannable item URL, with the owner's handle printed alongside.",
required_vars: ["itemUrl", "userHandle"],
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.userHandle}]
},
{
id: "item-url-qr-id", name: "Item URL + item ID",
description: "Scannable item URL, with the bare item id printed alongside.",
required_vars: ["itemUrl", "itemId"],
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => c.itemId}]
},
{
id: "item-url-qr-owner-id", name: "Item URL + owner + ID",
description: "Scannable item URL, with the owner's handle and the item id on two lines alongside.",
required_vars: ["itemUrl", "userHandle", "itemId"],
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, {type: "text", content: c => [c.userHandle, c.itemId]}]
},
{
id: "item-url-qr-owner-id2", name: "Item URL + owner + ID",
description: "Scannable item URL, with the owner's handle and the item id on two lines alongside.",
required_vars: ["itemUrl", "userHandle", "itemId"],
layout: [{type: "qrcode", content: c => c.itemUrl}, GAP, [{
type: "text",
content: c => c.userHandle
}, GAP, {type: "text", content: c => c.itemId}]]
},
];
// Every field name any template's required_vars names, in first-seen order.
export const KNOWN_VARS = [...new Set(LABEL_TEMPLATES.flatMap(t => t.required_vars))];
// A derived var 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 below). `inputs` names every var (base or, in principle,
// another derived one - see itemUrl/itemHandle below, which both read the derived userHandle)
// `calc` reads - declared up front rather than inferred from calc's body so BASE_VARS below 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.
export const DERIVED_VARS = {
// The full owner handle (see federation.md's Unique Handles section / ToolshedUser's
// separate username/domain columns) - kept as two base vars (user, domain) rather than one,
// since that's how the account itself is actually shaped, with this just the display/URL form.
userHandle: {
inputs: ["user", "domain"],
calc: (f) => `${f.user}@${f.domain}`,
},
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a printed
// label actually encodes, since scanning it has to resolve the right frontend/backend/item
// with no other context, not just this browser's history. `webdomain` defaults to this
// browser's own origin (see Print.vue) but is editable, since any frontend can resolve any
// handle - the label doesn't have to point back at whichever frontend happened to print it.
itemUrl: {
inputs: ["webdomain", "userHandle", "itemId"],
calc: (f) => `${f.webdomain}/i/${f.userHandle}/${f.itemId}`,
},
// The compact "owner handle + id" form from docs/design-in-progress/items-labels.md -
// meaningful only where context already makes clear it's a Toolshed item, unlike itemUrl.
itemHandle: {
inputs: ["userHandle", "itemId"],
calc: (f) => `${f.userHandle}:${f.itemId}`,
},
};
// What Print.vue's content form offers a plain text input for: every KNOWN_VAR a template
// references directly, minus the derived ones, plus every var a DERIVED_VARS calculation itself
// needs (like "webdomain", which no template ever names). A template still lights up only once
// every one of its own required_vars, base or derived, has a value (see templateIsAvailable
// below).
export const BASE_VARS = [...new Set([
...KNOWN_VARS.filter(v => !(v in DERIVED_VARS)),
...Object.values(DERIVED_VARS).flatMap(d => d.inputs).filter(v => !(v in DERIVED_VARS)),
])];
// Runs every DERIVED_VARS calculation against `fields` (already holding the base vars - see
// Print.vue), returning a copy with each one's result added wherever all of its own inputs are
// present, so a caller never has to know DERIVED_VARS' internal {inputs, calc} shape.
export function withDerivedVars(fields) {
const result = {...fields};
for (const [name, {inputs, calc}] of Object.entries(DERIVED_VARS)) {
if (inputs.every(v => result[v])) {
result[name] = calc(result);
}
}
return result;
}
function walkLeaves(node, fn) {
if (Array.isArray(node)) {
node.forEach(child => walkLeaves(child, fn));
} else {
fn(node);
}
}
function mapTree(node, fn) {
return Array.isArray(node) ? node.map(child => mapTree(child, fn)) : fn(node);
}
// A content leaf's resolved value counts as present only if every part of it is - a single
// string for "qrcode"/plain "text", every line for a multi-line "text" (see LABEL_TEMPLATES'
// "owner-id-text" and "item-url-qr-owner-id").
function isResolved(value) {
return Array.isArray(value) ? value.every(isResolved) : value !== undefined && value !== null;
}
// LabelLayoutPreview.vue's thumbnail grid and Print.vue's big preview both resolve a template
// through these two functions rather than each re-implementing the leaf-walking/field-resolving
// logic itself.
export function templateIsAvailable(t, fields) {
let available = true;
walkLeaves(t.layout, leaf => {
if (leaf.type !== "empty" && !isResolved(leaf.content(fields))) {
available = false;
}
});
return available;
}
/* Resolves a template's `content` functions against actual field values, turning its layout
tree into one ready for label.js's drawLabel/drawFallbackLabel - or null if there's nothing to
render yet (every leaf's value is still empty, e.g. before the user has typed anything). */
export function templateContent(t, fields) {
const tree = mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)});
let hasContent = false;
walkLeaves(tree, leaf => {
if (leaf.type !== "empty" && leaf.value && (!Array.isArray(leaf.value) || leaf.value.some(Boolean))) {
hasContent = true;
}
});
return hasContent ? tree : null;
}

438
frontend/src/label.js Normal file
View file

@ -0,0 +1,438 @@
import QRCode from "qrcode";
import {encodeHandleForUrl} from "@/handle-url";
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
/* Sizing a label needs numbers only the driver can supply. */
export function tapeFromStatus(status) {
const printAreaPx = status?.tape?.printAreaPx;
const dpi = status?.printer?.dpi;
if (!(printAreaPx > 0) || !(dpi > 0)) {
return null;
}
return {
printAreaPx,
dpi,
mediaWidthMm: status.tape.mediaWidthMm,
printLengthPx: status.tape.printLengthPx > 0 ? status.tape.printLengthPx : 0,
/* Brother's documented margin for the mounted tape, in raster columns. */
leadPx: status.tape.marginsMm
? Math.round(status.tape.marginsMm * dpi / 25.4)
: TRAILING_PADDING_PX,
};
}
/*
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: "qrcode", content} / {type: "text", content} draw a QR code
or text block, where `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.
*/
const QR_ASPECT = 1; /* a QR code is always square */
const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */
// Below 10px, a general-purpose sans-serif gets blurry/illegible, so drawTextLeaf switches to
// whichever of these bitmap-style fonts (see ../scss/_pixel-fonts.scss) is designed closest to -
// and no smaller than - the box's actual effective size. Ordered smallest first; fontFamilyFor
// below picks the first tier whose belowPx clears the requested size, sans-serif once none do.
const PIXEL_FONT_TIERS = [
{belowPx: 6, family: "Tom Thumb"}, /* designed for ~5px */
{belowPx: 7, family: "PICO-8"}, /* designed for ~6px */
{belowPx: 10, family: "Silkscreen"}, /* designed for ~7-8px */
];
function fontFamilyFor(fontPx) {
return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx)?.family ?? "sans-serif";
}
// A pixel font (see PIXEL_FONT_TIERS above) is what makes a size down here still legible - a
// plain sans-serif this small would fail the old MIN_READABLE_TEXT_PX=8 floor that predates them.
// Below this, drawTextLeaf leaves that one field blank rather than drawing illegible ink - see
// there for why that's a quieter failure than rejecting the whole label over it.
const MIN_READABLE_TEXT_PX = 5;
function isSplit(node) {
return Array.isArray(node);
}
function parseMm(value, key) {
const match = typeof value === "string" && /^([\d.]+)mm$/.exec(value);
if (!match) {
throw new Error(`An "empty" layout node needs a "${key}" like "2mm", got ${JSON.stringify(value)}.`);
}
return parseFloat(match[1]);
}
/* 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 snapQrToCrispSize below: 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. */
function relation(node, ownAxis, wantWidth, pxPerMm) {
if (!isSplit(node)) {
if (node.type === "qrcode" && node.crispSize !== undefined) {
return {a: 0, b: node.crispSize};
}
if (node.type === "qrcode" || node.type === "text") {
const aspect = node.aspect;
return wantWidth ? {a: aspect, b: 0} : {a: 1 / aspect, b: 0};
}
const key = ownAxis ? "min-width" : "min-height";
return {a: 0, b: parseMm(node[key], key) * pxPerMm};
}
const axis = !ownAxis; // this split's own row(true)/column(false) axis
const combinesAsWidth = axis; // a row sums widths for a shared height
const parts = node.map(child => relation(child, axis, combinesAsWidth, pxPerMm));
const a = parts.reduce((sum, p) => sum + p.a, 0);
const b = parts.reduce((sum, p) => sum + p.b, 0);
if (combinesAsWidth === wantWidth) {
return {a, b};
}
return {a: 1 / a, b: -b / a}; // invert: solve the affine relation the other way
}
/* Top-down: given the fixed (width, height) box `node` must exactly fill, assigns that box to
it and, recursively, an appropriately-shaped box to every descendant. `ownAxis` carries the
same meaning as in relation() above. */
function layoutTree(node, ownAxis, width, height, pxPerMm) {
node.box = {width, height};
if (!isSplit(node)) {
return;
}
const axis = !ownAxis;
for (const child of node) {
if (axis) {
const {a, b} = relation(child, axis, true, pxPerMm);
layoutTree(child, axis, a * height + b, height, pxPerMm);
} else {
const {a, b} = relation(child, axis, false, pxPerMm);
layoutTree(child, axis, width, a * width + b, pxPerMm);
}
}
}
/* Second top-down pass: turns each node's already-sized box into an absolute (x, y) position,
placing a row's children left to right and a column's top to bottom. Kept separate from
layoutTree since a node's size doesn't depend on its position, only on its box dimensions. */
function positionTree(node, ownAxis, x, y) {
node.box.x = x;
node.box.y = y;
if (!isSplit(node)) {
return;
}
const axis = !ownAxis;
let cursor = axis ? x : y;
for (const child of node) {
if (axis) {
positionTree(child, axis, cursor, y);
cursor += child.box.width;
} else {
positionTree(child, axis, x, cursor);
cursor += child.box.height;
}
}
}
/* 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 square a bare aspect ratio of 1 would suggest. Called once every qrcode leaf has a
provisional (scale-free) box from a first layoutTree pass, this pins each one's real box.width
(in row context) or box.height (in column context - whichever axis its box doesn't already
share with its siblings) as `crispSize`, so relation() above 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 layoutContent) then resizes everything
else around that real footprint, so nothing downstream reserves - and leaves unfilled - room
for a squarer QR code than what actually gets drawn. */
function snapQrToCrispSize(node) {
if (isSplit(node)) {
node.forEach(snapQrToCrispSize);
return;
}
if (node.type === "qrcode") {
const modules = node.qr.modules.size;
const scale = Math.floor(Math.min(node.box.width, node.box.height) / modules);
if (!(scale >= 1)) {
throw new Error("This text needs a bigger QR code than the tape allows — "
+ "try a shorter value or a wider tape.");
}
node.crispSize = modules * scale;
}
}
// Always measures in sans-serif at the fixed reference size, even though drawTextLeaf may end up
// actually drawing in one of PIXEL_FONT_TIERS' fonts - the final effective size (and so which
// font applies) isn't known until layoutTree has already sized the box this aspect ratio feeds
// into. The pixel fonts are close enough in proportion for basic Latin/digits that the tiny
// resulting mismatch is invisible in practice at these sizes, and the MIN_READABLE_TEXT_PX check
// still catches anything that genuinely doesn't fit.
function measureTextBlock(ctx, lines, referencePx) {
ctx.font = `${referencePx}px sans-serif`;
const width = Math.max(...lines.map(line => ctx.measureText(line).width));
const height = referencePx * 1.15 * lines.length;
return {width, height};
}
/* Turns a resolved content tree (see templateContent below - leaf objects carry a `value`
rather than a `content` function) into one ready for layout: a QR leaf gets its actual
QRCode.create() object, 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. */
function buildRenderTree(ctx, node, referencePx) {
if (isSplit(node)) {
return node.map(child => buildRenderTree(ctx, child, referencePx));
}
if (node.type === "qrcode") {
return {type: "qrcode", aspect: QR_ASPECT, qr: QRCode.create(node.value)};
}
if (node.type === "text") {
const lines = Array.isArray(node.value) ? node.value : [node.value];
const {width, height} = measureTextBlock(ctx, lines, referencePx);
return {type: "text", aspect: width / height, naturalHeight: height, lines};
}
return {type: "empty", "min-width": node["min-width"], "min-height": node["min-height"]};
}
function drawQrLeaf(ctx, node) {
const modules = node.qr.modules.size;
const scale = Math.floor(Math.min(node.box.width, node.box.height) / modules);
const size = modules * scale;
const left = node.box.x + Math.floor((node.box.width - size) / 2);
const top = node.box.y + Math.floor((node.box.height - size) / 2);
for (let row = 0; row < modules; row++) {
for (let col = 0; col < modules; col++) {
if (node.qr.modules.get(row, col)) {
ctx.fillRect(left + col * scale, top + row * scale, scale, scale);
}
}
}
}
// Returns the effective font size drawn at (or that would have been, if it's too small to draw -
// see below) - drawTree collects these into drawLabel/drawFallbackLabel's textSizesPx.
function drawTextLeaf(ctx, node, referencePx) {
const fontPx = referencePx * (node.box.height / node.naturalHeight);
// Even the smallest PIXEL_FONT_TIERS entry stops being legible below this - rather than
// reject the whole label over one field that's too small (the old behavior), just leave this
// leaf blank; its box was already accounted for, so nothing else in the layout shifts.
if (fontPx < MIN_READABLE_TEXT_PX) {
return fontPx;
}
const lineHeight = node.box.height / node.lines.length;
const family = fontFamilyFor(fontPx);
ctx.font = `${fontPx}px "${family}"`;
// A @font-face family already in use elsewhere on the page loads in time for this, 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 the load
// here 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.
if (family !== "sans-serif") {
document.fonts.load(ctx.font);
}
ctx.textBaseline = "middle";
ctx.textAlign = "center";
const centerX = node.box.x + node.box.width / 2;
// Lines stack as a block, each centered under the last - keeps a multi-line field reading as
// one unit rather than drifting apart.
let y = node.box.y + lineHeight / 2;
for (const line of node.lines) {
ctx.fillText(line, centerX, y);
y += lineHeight;
}
return fontPx;
}
// Flip this to true (in a debugger or a local edit) to outline every leaf's box - including
// "empty" ones, normally invisible - in a color that can't be mistaken for real label ink. Handy
// for checking a layout's actual padding/alignment; never wanted on a real printed label, so it's
// a manual toggle rather than something wired up to any UI.
let DEBUG_LEAF_BORDERS = false;
function drawDebugBorder(ctx, node) {
ctx.save();
ctx.strokeStyle = "red";
ctx.lineWidth = 1;
// Inset by half a pixel so the 1px stroke lands crisply on-pixel instead of straddling the
// box edge and rendering as a blurry 2px line.
ctx.strokeRect(node.box.x + 0.5, node.box.y + 0.5, node.box.width - 1, node.box.height - 1);
ctx.restore();
}
// `textSizesPx` collects each "text" leaf's effective font size as drawTree walks the tree - see
// drawLabel/drawFallbackLabel, which hand it back to the caller (Print.vue shows it alongside the
// tape width) so a field rendering blank (see drawTextLeaf's MIN_READABLE_TEXT_PX check) shows up
// as a suspiciously small size here rather than just silently not being there.
function drawTree(ctx, node, referencePx, textSizesPx) {
if (isSplit(node)) {
node.forEach(child => drawTree(ctx, child, referencePx, textSizesPx));
return;
}
if (node.type === "qrcode") {
drawQrLeaf(ctx, node);
} else if (node.type === "text") {
textSizesPx.push(drawTextLeaf(ctx, node, referencePx));
}
// "empty" leaves carry no ink - their box just reserves the space.
if (DEBUG_LEAF_BORDERS) {
drawDebugBorder(ctx, node);
}
}
/* Builds, sizes and validates the tree for a fixed `height` (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. `height` and the tree's content
fully determine its overall width; `maxLength`, when finite (a fixed-length/die-cut tape),
rejects content that doesn't fit rather than shrinking it.
Sizing runs twice: a first pass treats every qrcode leaf as the scale-free square its aspect
ratio of 1 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
width reflect what's actually drawn rather than the idealized square no QR code ever quite
fills. */
function layoutContent(ctx, content, height, maxLength, referencePx, pxPerMm) {
const tree = buildRenderTree(ctx, content, referencePx);
const measured = relation(tree, false, true, pxPerMm);
layoutTree(tree, false, measured.a * height + measured.b, height, pxPerMm);
snapQrToCrispSize(tree);
const {a, b} = relation(tree, false, true, pxPerMm);
const width = a * height + b;
if (maxLength !== Infinity && width > maxLength) {
throw new Error("This doesn't fit on this tape — "
+ "try a shorter value, a different layout, or a bigger label.");
}
layoutTree(tree, false, width, height, pxPerMm);
return {tree, width};
}
/* The tape-fed layout - draws a fully resolved content tree (see templateContent) at the tape's
real pixel dimensions. See DEBUG_LEAF_BORDERS above to outline every leaf's box. Returns
{textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right,
top-to-bottom order. */
export function drawLabel(canvas, tape, content) {
const maxLength = tape.printLengthPx
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
: Infinity;
const measureCtx = canvas.getContext("2d");
const pxPerMm = tape.dpi / 25.4;
const {tree, width: contentWidth} = layoutContent(
measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm);
const width = tape.printLengthPx || Math.ceil(contentWidth + tape.leadPx + TRAILING_PADDING_PX);
canvas.width = width;
canvas.height = tape.printAreaPx;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, canvas.height);
ctx.fillStyle = "#000";
const originX = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2);
positionTree(tree, false, originX, 0);
const textSizesPx = [];
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
return {textSizesPx};
}
const FALLBACK_LABEL_HEIGHT_PX = 200; /* reference height the no-webusb preview/PNG scales from */
const FALLBACK_DPI = 203; /* reference resolution for turning "empty" leaves' mm sizes into px */
/* 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. Returns {textSizesPx}, see drawLabel. */
export function drawFallbackLabel(canvas, content) {
const measureCtx = canvas.getContext("2d");
const pxPerMm = FALLBACK_DPI / 25.4;
const {tree, width: contentWidth} = layoutContent(
measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm);
canvas.width = Math.ceil(contentWidth);
canvas.height = FALLBACK_LABEL_HEIGHT_PX;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#000";
positionTree(tree, false, 0, 0);
const textSizesPx = [];
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
return {textSizesPx};
}
// Turns a {kind, components} prefill (see Print.vue's `prefill` prop) into the literal string a
// print label should show/encode. Keeping this keyed by `kind` rather than having each caller
// build its own string means the format for a given kind of label content only has to be gotten
// right in one place.
export const LABEL_CONTENT_BUILDERS = {
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a
// printed label actually encodes, since scanning it has to resolve the right
// frontend/backend/item with no other context, not just this browser's history.
"item-url": ({user, id}) => `${window.location.origin}/i/${encodeHandleForUrl(user)}/${id}`,
};
export function buildLabelContent(prefill) {
if (!prefill) {
return "";
}
const build = LABEL_CONTENT_BUILDERS[prefill.kind];
return build ? build(prefill.components) : "";
}
// Seeds for the *base* label-layouts.js vars (see BASE_VARS there) - keyed by `kind` for the same
// reason LABEL_CONTENT_BUILDERS is. Format-string vars derived from these (itemUrl, itemHandle)
// aren't built here; they're calculated live from whatever the base vars currently are (see
// label-layouts.js's DERIVED_VARS), prefill or hand-typed alike. A field missing from the result
// (rather than present-but-empty) is what label-layouts.js's templateIsAvailable treats as "not
// available", so builders should only include a field once its inputs actually check out.
const LABEL_FIELD_BUILDERS = {
// `user` here is already a full "user@domain" handle (that's the form login usernames take -
// see Login.vue/store.js), so it's split into label-layouts.js's separate `user`/`domain`
// base vars the same way store.js's own lookupServer does, rather than stuffing the whole
// handle into one field the way userHandle (now derived from these two) used to be.
"item-url": ({user, id}) => {
if (!user || !id) {
return {};
}
const at = user.indexOf("@");
return {
user: at === -1 ? user : user.slice(0, at),
domain: at === -1 ? "" : user.slice(at + 1),
itemId: String(id),
};
},
};
export function buildLabelFields(prefill) {
if (!prefill) {
return {};
}
const build = LABEL_FIELD_BUILDERS[prefill.kind];
return build ? build(prefill.components) : {};
}

View file

@ -0,0 +1,21 @@
// Specialized bitmap-style fonts label.js's drawTextLeaf switches to below an effective text
// size of 10px, where a general-purpose sans-serif gets blurry/illegible - each is designed for
// (and named after) roughly the pixel size it's used at. See
// ../assets/fonts/pixel/LICENSE.md for sources/licenses.
@font-face {
font-family: "Tom Thumb";
src: url("../assets/fonts/pixel/TomThumb.ttf") format("truetype");
font-display: block;
}
@font-face {
font-family: "PICO-8";
src: url("../assets/fonts/pixel/PICO-8.ttf") format("truetype");
font-display: block;
}
@font-face {
font-family: "Silkscreen";
src: url("../assets/fonts/pixel/Silkscreen-Regular.woff2") format("woff2");
font-display: block;
}

View file

@ -92,6 +92,7 @@ $body-color: $gray-700;
@import "forms";
@import "tags";
@import "dropdown";
@import "pixel-fonts";
#root, body, html {
height: 100%;

View file

@ -40,6 +40,9 @@
<a :href="`/inventory/${item.id}/delete`" @click.prevent="deleteInventoryItem(item)">
<b-icon-trash></b-icon-trash>
</a>
<router-link :to="shortIdLink(item)">
<b-icon-link></b-icon-link>
</router-link>
</td>
</tr>
</tbody>
@ -65,11 +68,14 @@
</div>
<div class="btn-group">
<button class="btn btn-danger btn-sm"
@click="deleteInventoryItem(item.id)">Delete
@click="deleteInventoryItem(item)">Delete
</button>
<router-link :to="`/inventory/${item.id}/edit`"
class="btn btn-primary btn-sm">Edit
</router-link>
<router-link :to="shortIdLink(item)" class="btn btn-secondary btn-sm">
<b-icon-link></b-icon-link>
</router-link>
</div>
</div>
</div>
@ -94,6 +100,7 @@ import {mapActions, mapGetters, mapMutations, mapState} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import AuthenticatedImage from "../components/AuthenticatedImage.vue";
import {encodeShortId, serializeShortId} from "@/short-id";
export default {
name: "Inventory",
@ -120,6 +127,13 @@ export default {
const loc = this.storage_locations.find(loc => loc.id === item.storage_location)
return loc ? loc.path : null
},
shortIdLink(item) {
// owner_identity_id is a placeholder until the backend exposes a KnownIdentity pk
// per item (see docs/handles-and-shortids.md) - it doesn't decode to a
// meaningful owner yet.
const ints = serializeShortId({kind: 'item', owner_identity_id: 0, item_local_id: item.id});
return '/' + encodeShortId(ints);
},
},
async mounted() {
await this.fetchInventoryItems()

View file

@ -12,6 +12,40 @@
alternatives below.
</div>
<div class="row mb-3">
<div class="col-lg-8">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Label content</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div :class="v === 'text' ? 'col-12' : 'col-md-6'" v-for="v in baseVars" :key="v">
<label class="form-label">{{ varLabel(v) }}</label>
<textarea v-if="v === 'text'" class="form-control" rows="3"
v-model="varValues[v]"
placeholder="https://example.com/…"></textarea>
<input v-else type="text" class="form-control" v-model="varValues[v]">
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Calculated</h5>
</div>
<div class="card-body">
<div v-for="v in derivedVars" :key="v" class="mb-2">
<div class="small text-muted">{{ varLabel(v) }}</div>
<div class="text-break">{{ fields[v] || "—" }}</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="!usbSupported" class="row">
<div class="col-lg-7">
<div class="card">
@ -19,13 +53,7 @@
<h5 class="card-title mb-0">Label preview</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">QR code content</label>
<input type="text" class="form-control" v-model="value"
placeholder="https://example.com/…" autofocus>
</div>
<div class="label-preview mb-3" v-show="selectedContent.qr || selectedContent.text">
<div class="label-preview mb-3" v-show="selectedContent">
<canvas ref="fallbackCanvas"></canvas>
</div>
@ -60,21 +88,47 @@
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Label</h5>
<small v-if="tape" class="text-muted">{{ tape.mediaWidthMm }} mm tape</small>
<small v-if="tape" class="text-muted">{{ tape.mediaWidthMm }} mm tape{{ textSizesSummary }}</small>
</div>
<div class="card-body">
<p v-if="!tape" class="text-muted">
Connect a printer to preview and print a label.
</p>
<template v-else>
<div class="mb-3">
<label class="form-label">QR code content</label>
<input type="text" class="form-control" v-model="value"
placeholder="https://example.com/…" autofocus>
</div>
<div class="label-preview mb-3" v-show="selectedContent.qr || selectedContent.text">
<canvas ref="labelCanvas"></canvas>
<div class="preview-row mb-3" v-show="selectedContent">
<div class="ruler-v">
<div class="ruler-v-corner"></div>
<div class="ruler ruler-v-ticks"
:style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}">
<span v-for="t in verticalRulerTicks" :key="'tick-' + t.mm"
class="tick" :class="{'tick-major': t.major}"
:style="{top: t.pos + 'px'}"></span>
<span v-for="t in verticalRulerTicks.filter(t => t.major)"
:key="'label-' + t.mm" class="tick-label"
:style="{top: t.pos + 'px'}">{{ t.mm }}</span>
</div>
</div>
<div class="preview-track">
<div class="ruler ruler-h"
:style="{width: (printedWidthPx * zoom) + 'px'}">
<span v-for="t in horizontalRulerTicks" :key="'tick-' + t.mm"
class="tick" :class="{'tick-major': t.major}"
:style="{left: t.pos + 'px'}"></span>
<span v-for="t in horizontalRulerTicks.filter(t => t.major)"
:key="'label-' + t.mm" class="tick-label"
:style="{left: t.pos + 'px'}">{{ t.mm }}</span>
</div>
<!-- The tape's full physical width, printable area included - the
print head can't mark all the way to the tape's outer edges, so
the canvas (printAreaPx tall) is narrower than this and centered
within it; the rest is real, if unprintable, tape margin. -->
<div class="tape-full"
:style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}">
<div class="label-preview">
<canvas ref="labelCanvas"></canvas>
</div>
</div>
</div>
</div>
<div class="row g-2 align-items-end">
@ -149,8 +203,8 @@ import BaseLayout from "@/components/BaseLayout.vue";
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
import {buildLabelContent, buildLabelFields} from "@/label-content.js";
import {LABEL_TEMPLATES, tapeFromStatus, drawLabel, drawFallbackLabel, templateContent} from "@/label-drawing.js";
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields} from "@/label.js";
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
// own emscripten glue resolves its .wasm sibling relative to *its own*
@ -159,6 +213,18 @@ import {LABEL_TEMPLATES, tapeFromStatus, drawLabel, drawFallbackLabel, templateC
const BLOB_URL = "/vendor/libweblabel.js";
const MAX_ZOOM = 4; /* never magnify the preview more than this */
const MAX_PREVIEW_HEIGHT_PX = 300; /* never let the on-screen preview grow taller than this */
// How far apart plain and labeled/major ticks sit, both coarser the longer the ruler itself runs
// - tightly spaced ticks (and their labels) get too cramped to read/render once there are enough
// of them. Ordered smallest threshold first; rulerTicks below uses the last entry whose `aboveMm`
// the ruler's own length clears, so add a finer/coarser tier here 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.
const RULER_TIERS = [
{aboveMm: 0, tickMm: 1, majorEveryMm: 5},
{aboveMm: 100, tickMm: 1, majorEveryMm: 10},
{aboveMm: 500, tickMm: 5, majorEveryMm: 25},
];
export default {
name: "Print",
@ -188,8 +254,33 @@ export default {
connected: null,
tape: null,
labelBitmap: null,
// The tape-fed preview's current on-screen scale and printed pixel width (see
// fitZoom/redraw) - tracked reactively, rather than read straight off the canvas
// element, purely so the mm ruler below can recompute its tick positions whenever
// either one changes.
zoom: 1,
printedWidthPx: 0,
// Each "text" leaf's effective font size in the current render (see label.js's
// drawLabel) - shown alongside the tape width so a field rendering blank (too small
// even for the smallest pixel font) shows up as a suspiciously tiny number here rather
// than just silently not being there.
textSizesPx: [],
value: buildLabelContent(this.prefill),
// One input per *base* template variable (see label-layouts.js's BASE_VARS) - the
// derived ones (userHandle, itemUrl, itemHandle) are format strings calculated from
// these, not typed directly, so they're only ever shown (see the `fields` computed
// below), never stored here. Prefilled from the ?kind=& query params where
// buildLabelContent/buildLabelFields have a value for them, editable from there so a
// template needing e.g. domain isn't stuck depending on a prefill that never arrives.
varValues: {
...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
text: buildLabelContent(this.prefill),
// Defaults to wherever this page itself is being served from - editable since any
// frontend can resolve any handle (see label-layouts.js's DERIVED_VARS.itemUrl),
// so a label doesn't have to point back at this particular one.
webdomain: window.location.origin,
...buildLabelFields(this.prefill),
},
copies: 1,
selectedTemplate: LABEL_TEMPLATES[0].id,
@ -200,11 +291,30 @@ export default {
};
},
computed: {
// Named content fields the field-specific templates draw from, plus the free-text
// `value` field the generic qr/qr-text/text templates use. A field this doesn't have
// (rather than one that's merely empty) is what LabelLayoutPreview.vue greys out.
// The base variables the "Label content" form renders an input for, and the derived ones
// it instead calculates and lists read-only beside that form - plain passthroughs, but
// keep the template from importing label-layouts.js just for these.
baseVars() {
return BASE_VARS;
},
derivedVars() {
return Object.keys(DERIVED_VARS);
},
// Named content fields the templates draw from: the form's own base vars, plus every
// DERIVED_VARS format string calculated live from those - so typing a userHandle and
// itemId (whether by hand or via prefill) recalculates itemUrl/itemHandle the same way
// either way. A blank/uncalculated value is dropped 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.
fields() {
return {value: this.value, ...buildLabelFields(this.prefill)};
const base = {};
for (const v of BASE_VARS) {
if (this.varValues[v]) {
base[v] = this.varValues[v];
}
}
return withDerivedVars(base);
},
currentTemplate() {
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
@ -230,14 +340,65 @@ export default {
canPrint() {
return Boolean(this.tape && this.labelBitmap && !this.busy);
},
// On-screen pixels per real millimeter of tape, at the preview's current zoom - what
// turns a physical mm into a tick position the ruler can actually draw. Only meaningful
// for the tape-fed preview (see redraw's printedWidthPx) - the no-webusb fallback preview
// isn't fed from any particular real tape/dpi, so it gets no ruler (see the template).
tapePxPerMm() {
return this.tape ? (this.tape.dpi / 25.4) * this.zoom : 0;
},
// The physical length, in mm, each ruler axis actually needs to cover - see
// horizontalRulerTicks/verticalRulerTicks below for what each one measures and why.
horizontalTotalMm() {
return (this.tape && this.printedWidthPx) ? this.printedWidthPx / (this.tape.dpi / 25.4) : 0;
},
verticalTotalMm() {
return this.tape ? this.tape.mediaWidthMm : 0;
},
// The single RULER_TIERS entry both rulers draw from, keyed off whichever axis is
// physically longer - so a long label's ruler doesn't end up coarser (or finer) than the
// tape-width ruler right next to it just because the other axis happens to be shorter.
rulerTier() {
return RULER_TIERS.filter(t => Math.max(this.horizontalTotalMm, this.verticalTotalMm) >= t.aboveMm)
.at(-1);
},
// Ticks along the tape's length (the printed bitmap's actual width, lead/trailing feed
// margin included, since that's real physical tape too).
horizontalRulerTicks() {
if (!this.tape || !this.printedWidthPx) {
return [];
}
return this.rulerTicks(this.horizontalTotalMm);
},
// Ticks across the tape's full physical width, mediaWidthMm - not printAreaPx/dpi: a
// print head can't reach the tape's outer edges, so the printable area (see .tape-full in
// the template) is genuinely narrower than the tape itself, by an amount that isn't a
// fixed/predictable fraction of it. The ruler still has to show the *whole* tape - its
// container is sized from mediaWidthMm too (see the template's inline height) precisely so
// these ticks can't run past it, the way they did when both were sized from printAreaPx.
verticalRulerTicks() {
return this.tape ? this.rulerTicks(this.verticalTotalMm) : [];
},
// "(5px, 23px)" for the current render's text leaves (see data's textSizesPx), or "" once
// there's nothing to show - appended straight onto the tape-width <small>, so the blank
// string here just means that text is left with no trailing space.
textSizesSummary() {
if (!this.textSizesPx.length) {
return "";
}
return ` (${this.textSizesPx.map(px => Math.round(px) + "px").join(", ")})`;
},
},
watch: {
value() {
if (this.usbSupported) {
this.redraw();
} else {
this.redrawFallback();
}
varValues: {
handler() {
if (this.usbSupported) {
this.redraw();
} else {
this.redrawFallback();
}
},
deep: true,
},
selectedTemplate() {
if (this.usbSupported) {
@ -258,6 +419,26 @@ export default {
},
},
methods: {
// Turns a camelCase variable name (see label-layouts.js's KNOWN_VARS) into a form label,
// e.g. "itemHandle" -> "Item Handle" - so adding a new template variable doesn't also
// require hand-writing a label for it here.
varLabel(v) {
return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
},
// Ticks from 0 up to totalMm, each positioned in on-screen pixels via tapePxPerMm - shared
// by the horizontal/vertical ruler computeds above. Both the plain tick spacing and the
// labeled/major one come from the shared rulerTier (see above), not from this totalMm, so
// both rulers always coarsen together once *either* axis is long enough to need it.
rulerTicks(totalMm) {
const {tickMm, majorEveryMm} = this.rulerTier;
const ticks = [];
for (let mm = 0; mm <= totalMm; mm += tickMm) {
ticks.push({mm, pos: mm * this.tapePxPerMm, major: mm % majorEveryMm === 0});
}
return ticks;
},
async guard(fn) {
this.error = null;
this.busy = true;
@ -326,7 +507,7 @@ export default {
redraw() {
this.labelBitmap = null;
const content = this.selectedContent;
if (!this.tape || (!content.qr && !content.text)) {
if (!this.tape || !content) {
return;
}
const canvas = this.$refs.labelCanvas;
@ -334,23 +515,26 @@ export default {
return;
}
this.resizeObserver.observe(canvas.parentElement);
let textSizesPx;
try {
drawLabel(canvas, this.tape, content);
({textSizesPx} = drawLabel(canvas, this.tape, content));
} catch (e) {
this.error = e.message;
return;
}
this.error = null;
this.textSizesPx = textSizesPx;
const bitmap = canvasToBitmap(canvas);
bitmapToCanvas(canvas, bitmap);
this.labelBitmap = bitmap;
this.fitZoom(canvas);
this.printedWidthPx = canvas.width;
this.zoom = this.fitZoom(canvas);
},
redrawFallback() {
this.fallbackReady = false;
const content = this.selectedContent;
if (!content.qr && !content.text) {
if (!content) {
return;
}
const canvas = this.$refs.fallbackCanvas;
@ -382,16 +566,20 @@ export default {
/* Fit the preview to its card without ever needing a horizontal
scrollbar for a label this small, magnifying short labels up to
MAX_ZOOM rather than showing them at native (tiny) size. */
MAX_ZOOM rather than showing them at native (tiny) size - and never
past MAX_PREVIEW_HEIGHT_PX tall, however long/wide the label itself
runs. Returns the zoom actually used, so callers that care (see
redraw's ruler bookkeeping) don't have to re-derive it. */
fitZoom(canvas) {
const available = canvas.parentElement.clientWidth;
if (!(available > 0)) {
return;
return 1;
}
const zoom = Math.min(MAX_ZOOM, available / canvas.width);
const zoom = Math.min(MAX_ZOOM, available / canvas.width, MAX_PREVIEW_HEIGHT_PX / canvas.height);
canvas.style.width = `${canvas.width * zoom}px`;
canvas.style.height = `${canvas.height * zoom}px`;
canvas.style.imageRendering = zoom >= 1 ? "pixelated" : "auto";
return zoom;
},
print() {
@ -410,7 +598,7 @@ export default {
for (const entry of entries) {
const canvas = entry.target.querySelector("canvas");
if (canvas) {
this.fitZoom(canvas);
this.zoom = this.fitZoom(canvas);
}
}
}, 100);
@ -459,15 +647,127 @@ export default {
.label-preview {
overflow-x: auto;
padding: .75rem;
background: rgba(127, 127, 127, .08);
//background: rgba(127, 127, 127, .08);
border-radius: .35rem;
text-align: center;
max-height: 300px;
}
.label-preview canvas {
display: inline-block;
background: #fff;
box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
//box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
}
/* The tape-fed preview's mm ruler (see Print.vue's template/script) - 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-row {
display: flex;
align-items: flex-start;
}
/* Holds the horizontal ruler and the canvas - 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. */
.preview-track {
flex: 1 1 auto;
min-width: 0;
}
/* Overrides the standalone rule above: nested here, .label-preview 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 this card has. padding:0 so the canvas's own edges are exactly
this box's edges too - the ruler's ticks (see .ruler-h/.ruler-v-ticks below) line up with those
same edges, so any padding here would leave the ticks and the actual canvas misaligned. */
.preview-track .label-preview {
overflow-x: visible;
text-align: left;
padding: 0;
}
/* The tape's full physical width (see the template) - a print head can't mark all the way to a
tape's outer edges, so .label-preview/the canvas is narrower than this and centered within it
(the print area sits centered on the tape, with equal margin on both sides); a faint tint
distinguishes the margin as real (if blank, unprintable) tape rather than empty space. */
.tape-full {
display: flex;
flex-direction: column;
justify-content: center;
background: rgba(127, 127, 127, .08);
border-radius: .35rem;
}
.ruler-v {
flex: none;
width: 2.2rem;
color: rgba(127, 127, 127, .9);
}
/* Matches .ruler-h's own height below - the vertical ruler's ticks start only after this, so tick
0 lines up with the canvas's top edge rather than the horizontal ruler sitting above it. */
.ruler-v-corner {
height: 1.6rem;
}
.ruler-v-ticks {
position: relative;
}
.ruler-h {
position: relative;
height: 1.6rem;
color: rgba(127, 127, 127, .9);
}
.ruler .tick {
position: absolute;
background: currentColor;
}
/* Ticks anchor to the edge nearest the canvas (right for the vertical ruler, bottom for the
horizontal one) and grow outward from it, so they read as pointing at the label; the mm labels
sit on the opposite, outer edge, out of the ticks' way. */
.ruler-v-ticks .tick {
right: 0;
width: .4rem;
height: 1px;
}
.ruler-v-ticks .tick-major {
width: .7rem;
}
.ruler-h .tick {
bottom: 0;
width: 1px;
height: .4rem;
}
.ruler-h .tick-major {
height: .7rem;
}
.tick-label {
position: absolute;
font-size: .6rem;
line-height: 1;
white-space: nowrap;
}
/* transform, not a fixed em nudge, so the label's actual center - not its edge - lands on the
tick's mm position (t.pos, set inline), whatever the text's width/height happens to be. */
.ruler-v-ticks .tick-label {
left: 0;
transform: translateY(-50%);
}
.ruler-h .tick-label {
top: 0;
transform: translateX(-50%);
}
.copies-input {