Skip to main content

helm-editable doctrine

Every inline-edit surface in the Hub uses the same three-state visual vocabulary, sharing one canonical CSS chassis. The dashed underline is the universal cue — the operator never has to learn a tab's local edit convention; the same affordance on Sales means the same thing on Service, on Customers, on Trade-in, and on the Pending PO bucket.

Shipped v0.6.332 (2026-06-02)

Pencil-icon purge. Three previously divergent chasses unified. One vocabulary, every till.

The three states

StateVisualCursor
RestPlain text, no glyph, no underlineDefault
HoverDashed blue underline + faint blue tintText-edit (text)
EditingSolid blue border + 3px blue focus ringText caret

That's the whole language. No pencil icon at any state. The underline carries the signal; the cursor confirms it on hover; the focus ring confirms commit-mode while typing.

The three chasses, unified

Before v0.6.332, three CSS classes carried three slightly different inline-edit looks:

ClassUsed onPre-v0.6.332 look
.helm-editableCustomer card, Service slot, Trade-in formPencil glyph on hover (::after { content:" ✎" }), thin underline
.helm-price-editSales till line price cellExcel-style cell border on hover, no glyph
input.bucket-line-costPending PO unit-cost fieldNative <input> border, no glyph

All three now render identically. The class names survive because the JS that wires the editor differs per surface (<span contenteditable> vs <input>), but the operator sees one thing.

Why no pencil

The pencil glyph was an accumulation of failure modes:

  1. It read as "is this clickable?" rather than "this is editable on click." Operators paused over the icon trying to figure out what it would do.
  2. Pencils on every editable span was visually noisy. A customer card with 12 editable fields looked like a craft store.
  3. It was redundant. Hover already revealed the dashed underline + text cursor — every input device the operator has (mouse, touch, stylus) communicates "I can edit here" without the glyph.
  4. It bred sibling icons. , , ✏️, 📝 — four different pencils accumulated across the codebase. Each one reinforced "this is editable" in a slightly different style. The cleanup removed all four.

The replacement rule:

  • Editable cells → dashed underline (the doctrine)
  • Edit actions that need a button (rename a synonym group, edit a ticket-details block) → btn-sm chassis with the text label "Edit"

What got purged

RemovedLived onReplaced with
.helm-editable:hover::after { content:" ✎" } (global CSS)Every editable spanNothing — the dashed underline is the cue
td-edit-btn (Service slot ticket-details block)Slot row buttonsbtn-sm btn-edit with text "Edit"
syn-edit-btn (synonym groups in Settings)Synonym group cardsbtn-sm with text "Edit"
✏️ Cheque (Tender modal)Tender method picker📄 (page)
📝 Card-manualTender method picker⌨ (keyboard)
📝 CheckTender method picker📄 (page)

When to use each surface

  • Inline-editable value cell — a field whose primary affordance is editing. Customer Name, line price, PO unit cost, line qty. Apply the .helm-editable class (or one of its siblings); the dashed-underline cue is automatic; the click handler edits in place via contenteditable or a native <input>.
  • Inline-affordance + modal save (composite tills, v0.6.383+) — see the next section.
  • Action button — an action that's adjacent to a value or section. Rename a synonym group, customize a tile. Use the btn-sm btn-edit text-label button — never an icon-only pencil.

The rule of thumb: if the operator's intent is "I want to change this number / word in place," use inline edit. If it's "I want to open the editor for this section," use a labelled button. Pencils blur that distinction; the v0.6.332 cleanup re-draws it.

The composite-till mode — dashed-underline cue, modal save (v0.6.383 → v0.6.386)

Some tills carry composite forms: Service Slot ticket details (11 fields), Trade-in intake (11 fields), Customer card (a dozen-plus fields). Inline-editing each field independently fragments the operator's mental model — they save one, get prompted on the next, lose track of what they've changed across the form.

For those surfaces, the doctrine evolved into a two-part pattern:

  1. The visual affordance stays. Every editable field on the till still carries .helm-editable with its dashed-underline + cursor:pointer styling. The operator reads the field as clickable, same as before.
  2. The click handler routes to a per-till edit modal. Instead of contenteditable chained per field, the click opens a dedicated modal (#slot-edit-modal, #customer-edit-modal, #tradein-edit-modal) focused on the clicked field via openXxxEditModal({focusField}). The operator sees all fields, types, tabs through them, and clicks Save once.

This compresses 60-line contenteditable chains down to ~10-line click-routers. Save reads each input, parses (regex-clean money → cents, year range-checks 1900–2100, etc.), writes back to the till state, repaints the inline spans via the existing setField + fmtMoney helpers, and fires refreshSummary + updateActionState — same downstream as the inline chain used to.

The visual signal to the operator is unchanged. The implementation underneath compresses a lot of duplicated contenteditable plumbing into one modal per till.

When to choose modal-save vs inline-save

Choose inline-saveChoose modal-save
One value, one saveComposite form with cross-field validation
The operator's intent is "fix this number"The operator's intent is "edit this record"
Save can complete without leaving the surface (e.g., line price recompute)Save needs typed parsing (money / dates / year ranges) before commit
Examples: Sales till line price, PO unit cost, customer nameExamples: Service Slot ticket details, Trade-in intake, Customer card composite edit

The dashed-underline is the same cue in both modes. The operator doesn't have to learn which surfaces are inline vs modal — the click takes them where they need to go.

What doesn't change

The class names (helm-editable, helm-price-edit, bucket-line-cost) survive because:

  • The DOM wiring per surface differs — contenteditable for spans, <input> for fields with native validation, etc.
  • The save handlers per surface differ — Customer Name PUTs to /api/customers/:id; ticket findings PUT to /api/service-tickets/:id; PO unit cost POSTs to the bucket endpoint.
  • Selector-level CSS overrides (e.g. price cells right-align) need a hook.

What the operator sees is unified. What the code does to make that happen is per-surface. That's a clean substrate boundary.

See also

  • Header-icon doctrine — the other cross-tab visual discipline (icon-as-state on title bars + Charge-button full-colour discipline)
  • No Walk-In on Service — companion enforcement doctrine; layered defenses for an invariant
  • In-situ editing — the philosophy this chassis implements
  • Tooltip protocol — the same kind of operator-language-first discipline, applied to scaffolding tooltips