Appearance
Managing attributes (admin)
In the admin: Attributes — the screen this page documents (opens in the running app).
What/why: The product catalog — what this screen manages and why it exists.
The Attributes screen under /admin/attributes manages the catalog of comparison features themselves — the rows of the comparison, independent of any one system's value for them. Use it to add, rename, recategorize, describe, or retire attributes as the product line evolves. It is restricted to the data editor capability (edit_data); see admin-access.md.
This is distinct from managing-systems.md, which edits a system's value for an attribute. Here you manage the attribute definition.
The attributes list (/admin/attributes)
Attributes are grouped by feature category (e.g. Performance, System Features, Glazing Options), each showing its key and value type. Category headings are ordered by the managed category set (see below).
- New attribute reveals a create form: name, an optional key (defaults to a slug of the name; unique, kebab-case), a category — chosen from the managed set (see Categories below), not free-typed — a value type, and an optional customer-facing description.
- New category (in the list header) adds a category to the managed set directly, without editing any specific attribute — one of the two inline-create entry points.
- Edit opens the attribute; Retire soft-deletes it.
Name rules (create and edit). The name you type is validated on save so malformed names can't be introduced:
- Trimmed and internal whitespace collapsed — leading/trailing spaces are dropped and any run of spaces inside the name becomes a single space (so
Door␣␣Closersaves asDoor Closer). This uses the same shared normalizer as category names. - Empty is rejected — a name that is blank after trimming can't be saved.
- Unfinished-looking punctuation is warned, not blocked — if the name contains a
?(anywhere) or ends in a trailing…or., the form flags it inline so you can confirm it's intentional. The save still goes through; the warning just guards against the "looks unfinished" names the content audit found (Warranty on…,…/Revit?PDF/Spec). It appears under the name field on the attribute's edit screen — including right after you create one, so you can immediately clean it up.
The key is immutable once set — a later name edit never re-keys the attribute, so stored per-system values and any references stay intact.
Categories (the managed set)
Attribute categories are a managed, controlled set — not free text. Promoting them from an arbitrary string to a first-class set is what keeps the vocabulary clean (MECE): you can't introduce Performance and performance as two categories, or a one-off typo'd heading. The Categories screen under /admin/categories lists every category with its attribute count and lets you create, rename, merge, and delete categories — the full lifecycle.
- Names are validated on create and rename: trimmed, internal whitespace collapsed, empty rejected, and case-insensitive duplicates rejected — so the set stays a clean controlled vocabulary. (A rename may recase its own name; it only collides against a different category.)
- The set is the single source of category names and their ordering. The grouped views (
/admin/attributesand each system's edit screen) order their category headings by the set, and attributes reference a category by FK — a category rename or merge is a set operation, not a bulk string rewrite. - Display order is admin-arrangeable. The Display order card on the Categories screen lets you rearrange the set with ↑/↓ controls and save; that order drives the category headings on the system surface. It defaults to alphabetical until you set an explicit order, and changes only where categories display — never the attributes inside them. See category ordering for the source-of-truth.
Rename, merge, delete (the lifecycle)
Each category row on the Categories screen carries its lifecycle controls:
- Rename — edit the name inline and save. The category's key is immutable (like an attribute's), so only the display name changes; every attribute grouped under it follows automatically through the FK — nothing is left orphaned.
- Merge — pick another category to merge into. All of this category's attributes are reassigned to the target, then this (now-empty) category is removed. Idempotent and audited.
- Delete — guarded: only a category with no attributes can be deleted. A non-empty category's Delete is disabled with a clear "merge or reassign its N attributes first" prompt — so you never strand attributes by deleting their category out from under them.
Every rename / merge / delete writes an audit record; a blocked or rejected action writes nothing.
Uncategorizedis a display bucket for attributes with no category — it is not itself a managed category unless someone explicitly creates it.
Two ways to add a category inline (so categorizing never forces a detour):
- From the attributes overview — the New category header button adds to the set directly, independent of any one attribute.
- From an attribute's create or edit form — the category picker's "+ New category" affordance adds a category to the set and selects it without leaving the half-filled form.
Existing free-text categories were migrated into the managed set on rollout with no data loss — every previously categorized attribute now points at its managed category.
Value types
Every attribute declares a value type that tells the per-system editor and the comparison how to treat its values:
boolean · conditional (yes/no + a caveat) · enum (a list) · dimension (imperial + metric) · range (numeric bounds) · tier (cost tier) · text (free text).
(The explicit No Testing / N/A / None states are per-value states set on a system, not attribute-level types — see managing-systems.)
Changing an attribute's value type (safe by design)
You can change an attribute's value type on the edit screen. This is handled safely:
- Existing per-system values carry their own type in storage, so changing the attribute's type never rewrites or corrupts them — there is no destructive migration.
- If values already exist, the editor warns you how many, and that they keep their current type until re-edited on the relevant system. Until then a value may not match the attribute's new type.
So the type is best thought of as the default/expected type for new values and the hint the editor offers first — changing it is low-risk and reversible.
Managing an attribute's values
Where it makes sense, an attribute's detail page also manages the values the per-system editor may use. What "values management" means depends on the value type:
enum — allowed-values catalog
A multiple-choice attribute's detail page lists its allowed values with usage evidence (which systems use each value).
Any value can be renamed, in use or not. A system references a value by its identity — an opaque id assigned once when the value is created — not by its name, so renaming one updates every place it appears and cannot orphan anything. The rename edits the value's label alone; the old name is kept as an alias, so a Drupal sync or an older stored payload carrying it still resolves to the same value. Nothing about the rename touches a system's data.
That is a deliberate reversal of this catalog's original design (issue #147), where the name was the identity and an in-use value therefore had to be locked. See decision #1852 for why identity was separated from label, and #1844 for the staged migration that delivered it.
The usage evidence stays, because knowing which systems depend on a value is worth seeing — it just no longer gates renaming. What it still gates is removal: a value used by ≥1 system can't be removed (blocked in the UI and rejected server-side naming the usage), because deleting a value takes the identity every reference points at away with it. Indirection makes renaming safe; it does nothing for a delete.
Two naming rules remain, for operator sanity rather than data safety: a label already worn by another value in the same attribute is rejected, and so is one that is a former name of a different value — that string is how the other value resolves, so a rename must not take it.
Unused values are free to edit or remove, and new choices can be pre-provisioned before any system uses them. Values fed by the Drupal ingest are auto-added to the catalog (matched against aliases first, so a rename never causes the next sync to re-add the old name as a duplicate).
The Values tab is a catalog; the editor opens on demand
Each value's editor is collapsed by default (#1866), so the tab reads as one row per value — name, in-use badge, usage, actions — and stays scannable on a vocabulary of real size. Click a value's name to expand its editor; the caret and hover cue mark the name as a control, and it works from the keyboard (Tab to it, Enter or Space to toggle). Several values can be open at once, so you can compare two values' education copy side by side.
Rename does not require expanding anything. The Rename button, Remove, and the in-use lock all work from a collapsed row, and while the rename editor is open the name is a text field rather than a toggle — so a click meant for the input can never collapse or expand the row underneath it.
Each allowed value is an education point. Inside that panel every value has a description field (buyer-facing copy explaining that choice) and a Media panel — the same reusable-library gallery as everywhere else (see Managing media) — because a picture or video often illustrates the difference between options (glazing types, finish options) better than the bare value string. Notes:
- In-use values stay fully editable. Usage guards removal only; a value's name, description and media are all editable while systems use it, and none of those edits touches stored system data.
- Renaming carries education content along — for free, now. The description lives on the value's own row and the media attach to its id, so a rename simply isn't visible to them.
- Removing a value with education content asks first. The confirm names exactly what goes (the description, N attached media items); media assets stay in the library, only this value's attachments are removed.
- Description edits are audited like every other catalog change.
tier — the global tier ladder (/admin/tiers)
Cost tiers are one global, ordered ladder shared by every tier attribute and each system's budget tier — managed on the dedicated Cost tiers page, not per-attribute (tier attributes link there). The ladder starts as Budget < Mid-range < Premium < Ultra Premium and:
- Order drives right-sizing. "A cheaper system" means "earlier on the ladder" — reordering the ladder changes recommendation right-sizing with no code change.
- Renaming is unguarded, removal is — exactly like enum values, and for the same reason. A rung is referenced by its identity, so renaming "Premium" to "Premium plus" updates every system, rule and comparison showing it while the stored data stays untouched. Removing a tier a system references is still blocked, with the usage named.
- Reordering is always allowed (it's a cost-model decision, not a data risk) and every move is audited with the resulting ladder order.
- Add appends at the expensive end; move it into place with the order arrows.
- Sync-fed tier values are auto-added to the ladder, like the enum catalog — alias-matched first, so a rename never makes the next sync append a duplicate rung under the old name.
The per-system value editor and the system form's budget-tier choices both offer the live ladder — a tier not on the ladder is rejected server-side.
text — distinct values + convert to a list
A free-text attribute's detail page shows the distinct values systems currently store for it, with usage counts and links — a derived view (nothing new is stored). Several text attributes are de-facto enums (the same few phrasings reused across systems); when the values read as a fixed set of choices, Convert to a list promotes the attribute:
- The attribute's value type switches to
enum, and every distinct stored value is seeded into the allowed-values catalog (most-used first — that becomes the display order). - Existing per-system values are not rewritten — they keep their
texttyping until re-edited (the same non-corrupting semantics as any type change) — but they count as usage, so the seeded values are protected from removal from the moment of conversion. They stay renameable, like every other value. - The conversion is guarded: only a
textattribute with at least one stored value converts, and the conversion (with the seeded values) lands in the audit trail.
range — label phrasings + display convention
Ranges have continuous numeric bounds, so there is no discrete-value catalog (a catalog of measurements would be meaningless). What drifts is the label phrasing ("Low as 0.2" vs "As low as 0.24"), so a range attribute's detail page shows:
- The distinct label phrasings in use, with usage counts and system links — the dominant phrasing first, the outliers to clean up after it (a derived view).
- A per-attribute display convention: a unit or phrase (e.g.
psf) every new or re-edited label must contain (case-insensitive), rejected server-side with a clear message otherwise. Existing stored labels are never rewritten — setting or changing the convention is advisory for old data, a gate for new data. Setting and clearing the convention are audited.
Other types
Types with nothing to manage (boolean, conditional, dimension, and the explicit No Testing / N/A / None states) show no values-management controls — deliberately, not as an omission.
Enable / disable (quick on/off)
Every attribute has an enable/disable toggle on the list — a quick on/off distinct from retirement, following the right-sizing rules precedent:
- Disabled = temporarily excluded from buyer-facing surfaces: it disappears from the comparison and from guided-flow/recommendation inputs. It stays fully visible and editable in the admin (a Disabled badge marks it), and the Drupal data keeps feeding it — values and baselines continue to update; only the display hides. A comparison deep link that references a disabled attribute degrades gracefully: the row is silently omitted, never an error.
- Retired = archived (see below). The two states are independent.
Both enable and disable are recorded in the audit trail. The Drupal data page marks a mapping whose attribute is disabled with a disabled chip, and its attribute picker labels disabled attributes — they remain valid mapping targets.
Usage-guarded disable (and retire)
An attribute that is still referenced cannot be disabled — or retired — until the references are resolved. References are:
- a selection factor whose option influences the attribute (
factor_option_attributes), or - a right-sizing rule with a condition thresholding the attribute (
rule_conditions).
A blocked action opens a dialog listing every affected factor (with the referencing option) and rule, each linked to its editor. Remove those references and retry — the guard always reads the live state, so nothing needs manual unblocking.
Retire vs. delete (soft delete)
Retiring an attribute is a soft delete: it drops the attribute out of comparison and selection immediately, but preserves its definition and every per-system value keyed to it. Retired attributes move to a Retired section; Restore returns them. Nothing is hard-deleted. Retire carries the same usage guard as disable (above).
What this maps to
- List / create / enable / disable / retire / restore / add-category →
src/routes/admin/attributes/+page.server.ts - Edit + type-change guard →
src/routes/admin/attributes/[id]/ - Managed categories: view + create + rename / merge / delete →
src/routes/admin/categories/; read / create / validate / lifecycle lib →src/lib/server/admin/categories.ts; the FK-aware grouping helper →src/lib/admin/ui/category-groups.ts; the form picker →src/lib/admin/ui/CategorySelect.svelte - Persistence + validation + reference guard →
src/lib/server/admin/attributes.ts(the value-type scheme lives insrc/lib/server/values.ts; see data-access.md). Name normalization + empty-reject + the punctuation warning isvalidateAttributeNamethere, over the sharednormalizeNameinsrc/lib/server/admin/names.ts. - Enum allowed-values catalog →
src/lib/server/admin/enum-values.ts - Tier ladder page + module →
src/routes/admin/tiers/,src/lib/server/admin/tiers.ts(ordinal threading into recommendations:src/lib/server/recommend.ts).