Inline Editing UI for Document Detail View — Design

Goal

Let users edit structured-data values directly in the document detail view. On hover, each field visibly advertises that it's editable. On click, the field turns into an input in place. On Enter or blur, the edit is "saved" (visual flash, no backend). On Escape, the edit is cancelled.

This spec covers UI only. No backend changes, no persistence, no database writes. The edited value lives in the DOM for the life of the page and is discarded on reload. The goal is to prove the interaction feels right before committing to any server-side work.

Non-goals

Critical constraint: pixel-perfect stability

This is the design's hardest requirement. The user must see zero pixel movement when:

  1. A field transitions from rest → hover.
  2. A field transitions from hover → edit (click).
  3. An input gains focus and shows a caret.
  4. A field transitions from edit → saved.

The only visual motion allowed is:

All other pixels — including the first character of the text — must stay exactly where they were at rest.

Motivation

Today, every structured-data value in the detail view is static text emitted by reusable hiccup helpers (labeled-field, value-row, party-card, line-items-table, line-item-card, delivery-section) in src/com/getorcha/app/ui/components.clj. When LLM extraction is wrong — misread invoice number, wrong quantity, wrong matched GL account — the only way to fix it is to re-run ingestion with a different prompt, or to edit the row directly in the database.

This prototype answers the question: "If users could click any value and fix it in place, does it feel right?" Before we build the real save pipeline, we want to validate the interaction model itself: the hover affordance, the click-to-edit mechanics, the keyboard behavior, and — critically — the pixel-stability contract.

Current state

Rendering pipeline

The detail view is rendered by src/com/getorcha/app/http/documents/view/*.clj (one namespace per document type: invoice.clj, purchase_order.clj, goods_received_note.clj, contract.clj, notice.clj). Each view composes a shared set of hiccup helpers from src/com/getorcha/app/ui/components.clj:

CSS baseline

The rest-state metrics that must be preserved exactly:

Existing form inputs in the codebase (.form-group input, textarea) use padding 10px 12px, border 1px solid #30363d, border-radius 6px, background #0d1117, font 14px, focus style border-color: #58a6ff; box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1). These metrics do not match most display field metrics and cannot be reused directly for inline editing — the inline input must match the display, not the existing form inputs.

What's missing

Architecture

Client-side JavaScript, not HTMX

HTMX would require adding edit/save endpoints to the server just to bounce HTML back. Since the goal is UI-only, those endpoints would be stubs we'd have to remove before shipping the real feature. Client-side JS keeps the feature self-contained, fast (no network round-trip), and easy to demo.

Shared editable-value wrapper

One new hiccup helper in app/ui/components.clj:

(defn editable-value
  "Wraps a display value in a container the client JS turns into an inline edit.
   path       - vector like [:structured-data :invoice-number]
   field-type - :text | :number | :currency | :date | :enum | :multiline
   opts       - map: {:raw-value ... :options [...] :currency \"EUR\"}
   display    - the hiccup vector currently rendered (unchanged)"
  [path field-type opts & display]
  [:span.editable-value
   (cond-> {:data-field-path (pr-str path)
            :data-field-type (name field-type)}
     (:raw-value opts) (assoc :data-raw-value (str (:raw-value opts)))
     (:currency opts)  (assoc :data-currency (:currency opts))
     (:options opts)   (assoc :data-field-options
                              (cheshire.core/generate-string (:options opts))))
   display])

The wrapper is a plain <span> with display: inline, zero padding/margin/border at rest. It inherits all typography from its parent. The display child is emitted unchanged — at rest, the DOM looks pixel-identical to today except for an extra wrapper span.

Opt-in at display components

Rather than requiring callers to wrap every value call in editable-value, the core display components (labeled-field, value-row, party-card, etc.) gain an :editable option. When present, the component wraps its value child internally. When absent, the component renders as today.

;; Before:
(labeled-field "Invoice Number" invoice-number)

;; After (opt-in):
(labeled-field "Invoice Number" invoice-number
               :editable {:path [:structured-data :invoice-number]
                          :type :text
                          :raw-value invoice-number})

Callers that don't opt in get no behavior change. This means:

  1. Excluded fields (validation badges, confidence scores, math-check) simply don't pass :editable and are untouched.
  2. Rollout can be incremental — wire one component at a time and verify before moving on.

One delegated event handler

A single new file resources/app/public/js/editable-fields.js (~120 lines) attaches delegated listeners to document (filtered by .detail-container descendant match) for click, keydown, blur. No per-field listeners. No framework. Plain vanilla JS.

Files touched

src/com/getorcha/app/ui/components.clj          — add editable-value + :editable option
                                                   to ~6 display components
src/com/getorcha/app/http/documents/view/*.clj  — add :editable opts to call sites
                                                   (one per doctype)
src/com/getorcha/app/http/documents/view/shared.clj — include <script src="/public/js/editable-fields.js">
                                                       in the detail-container renderer
resources/app/public/css/style.css              — add ~80 lines of .editable-value rules
resources/app/public/js/editable-fields.js     — NEW FILE (~120 lines)

The script is loaded only on pages that render the detail container, following the existing pattern (orb.js on login/error, upload.js on upload pages, google-drive-picker.js on integrations). No global include in layout.clj.

No changes to routes, handlers, schemas, SQL, or tests.

Visual states

State transition diagram

  REST                HOVER                   EDIT                SAVING
┌──────────┐       ╔═══════════╗          ╔═══════════╗       ╔═══════════╗
│Total     │  →    ║Total      ║    →     ║Total      ║   →   ║Total      ║
│1.234,56 €│       ║1.234,56 € ║          ║[1.234,56 ]║       ║1.234,56 € ║
└──────────┘       ╚═══════════╝          ╚═══════════╝       ╚═══════════╝
 no chrome          outline .35          outline 1.0           green flash
 cursor default     bg blue .08          bg blue .12           400ms fade
 transparent        cursor text          input in place        back to rest
 border reserved

CSS rules

.editable-value {
  cursor: text;
  outline: 1px solid transparent;  /* reserved at rest, invisible */
  outline-offset: 0;
  background-color: transparent;
  border-radius: 3px;
  transition: outline-color 120ms ease, background-color 120ms ease;
}

.editable-value:hover {
  outline-color: rgba(88, 166, 255, 0.35);
  background-color: rgba(88, 166, 255, 0.08);
}

.editable-value.is-editing {
  outline-color: #58a6ff;
  background-color: rgba(88, 166, 255, 0.12);
  box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1);
}

.editable-value.is-saving {
  animation: orcha-edit-flash 400ms ease-out;
}

@keyframes orcha-edit-flash {
  0%   { outline-color: #3fb950; background-color: rgba(63, 185, 80, 0.20); }
  100% { outline-color: transparent; background-color: transparent; }
}

/* Inputs inherit display metrics exactly. */
.editable-value > input,
.editable-value > textarea,
.editable-value > select {
  all: unset;
  box-sizing: border-box;
  width: 100%;
  font: inherit;
  color: inherit;
  text-align: inherit;
  white-space: inherit;
}

Why this is pixel-stable

  1. outline is outside the CSS box model. It never affects layout, siblings, or flow. The 1px-transparent outline at rest reserves no space (outline doesn't reserve space anyway) and lets us animate the color freely.
  2. background-color does not affect layout.
  3. box-shadow does not affect layout — the focus ring sits outside the element visually but has zero layout impact.
  4. border-radius: 3px is purely cosmetic.
  5. Inputs use all: unset + inheritance so their text sits at exactly the same pixel position as the original text. No padding, no border, no background of their own.

Tricky rest-state preservation cases

The one accepted visual shift

The text caret inside an <input> blinks. That's inherent and unavoidable. Everything else holds.

Field types

Each data-field-type maps to exactly one edit surface. All inputs inherit typography from the wrapper; no type-specific padding or border.

data-field-type Edit surface Display at rest On click shows
:text <input type="text"> "INV-2024-0042" "INV-2024-0042"
:number <input type="text" inputmode="decimal"> "42.5" "42.5"
:currency <input type="text" inputmode="decimal"> (replaces .amount only) "1.234,56 EUR" "1.234,56" (EUR suffix stays)
:date <input type="text"> "15.03.2026" "15.03.2026"
:enum <select> positioned over badge styled badge native dropdown over badge
:multiline <textarea> sized to display multi-line text textarea at captured height

Per-type notes

:text — Plain text input. Invoice numbers, party names, tax IDs, descriptions.

:number — Text input with inputmode=decimal so mobile shows a numeric keyboard. Deliberately not <input type="number">: it adds spinner chrome (pixel shift), rejects comma decimals used in German format, and has hard-coded focus styling. Quantities, article counts.

:currency — The amount display splits into .amount + .currency-suffix spans. Only .amount becomes the input on edit. Currency code stays static. Pixel stable because both spans existed at rest.

:date — Plain text input showing the German-formatted date (DD.MM.YYYY). Deliberately not <input type="date">: it shows browser-native picker chrome that can't match the dark theme, and its intrinsic width differs from the text width, causing pixel shift.

:enum — The highest-risk case. The display is a styled badge (e.g. .badge-service-category, .badge-incoterm). Approach:

  1. On click, capture the badge's offsetWidth and offsetHeight.
  2. Insert a <select> as an absolutely-positioned child of the badge: position: absolute; inset: 0; width: 100%; height: 100%; appearance: none; background: transparent; padding: 0; border: none; color: transparent;.
  3. The select is invisible but covers the badge. The badge's own background and text stay visible underneath.
  4. Click → native dropdown opens (we can't style that, but it's brief and native).
  5. On change, update the badge text and remove the select.

Risk: native <select> dropdowns are OS-styled and can look inconsistent on Linux/macOS/Windows. This is acceptable for a prototype — the dropdown is transient; the badge (the part at rest) looks correct.

:multiline — On click:

  1. Capture element.offsetHeight.
  2. Replace inner text with a <textarea> with height: <captured>px; resize: none; overflow: hidden;.
  3. Add an input listener that auto-grows: this.style.height = 'auto'; this.style.height = this.scrollHeight + 'px';.
  4. Enter saves. Shift+Enter inserts a newline. Blur saves.

The textarea starts at exactly the display's rendered height. Growing is acceptable (only downward).

Composite fields are not a special case

An account ({:number "1200" :name "Cash" :confidence 0.98}) isn't a single :composite field type. It's three display elements, each independently editable (or not):

Debit Account                         ← label, not editable
[editable-value :text] [editable-value :text]   ← "1200" and "Cash"
98% confidence                        ← excluded (metadata)

Paths: [:structured-data :line-items 0 :debit-account :number] and [:structured-data :line-items 0 :debit-account :name].

Party cards: name, each address line (as one multi-line field), tax-id, email, phone, fax, matched-account-number, matched-account-name are all independent editable-value children of the card.

Line item cells: each column in line-items-table, each grid-cell in line-item-card meta-row, and each sub-field of each account in the accounts grid is an independent editable.

State machine

REST ──click── EDIT ──Enter/blur/Tab── SAVING (400ms) ── REST
 │              │                                         ▲
 │              └──Escape/cancel────────────────────────────┘
 │
 └──hover──► (CSS only, no JS state)

Pseudocode

// In editable-fields.js — loaded once by layout.

document.addEventListener('click', (e) => {
  const el = e.target.closest('.editable-value');
  if (!el || el.classList.contains('is-editing')) return;
  if (!document.querySelector('.detail-container')?.contains(el)) return;
  startEdit(el);
});

function startEdit(el) {
  el.dataset.originalHtml = el.innerHTML;   // for cancel
  const type = el.dataset.fieldType;
  const input = buildInput(type, el);
  el.innerHTML = '';
  el.appendChild(input);
  el.classList.add('is-editing');
  input.focus();
  if (input.select) input.select();
}

function buildInput(type, el) {
  switch (type) {
    case 'text':
    case 'number':
    case 'date':
      return makeTextInput(type, el.textContent);
    case 'currency':
      return makeCurrencyInput(el);  // replaces only .amount
    case 'enum':
      return makeEnumSelect(el);
    case 'multiline':
      return makeTextarea(el);
  }
}

// Keyboard / blur handlers registered on the input at creation time.

function onInputKeydown(e) {
  if (e.key === 'Escape') { cancel(e.currentTarget); return; }
  if (e.key === 'Enter') {
    if (e.currentTarget.tagName === 'TEXTAREA' && e.shiftKey) return; // newline
    e.preventDefault();
    commit(e.currentTarget);
  }
  if (e.key === 'Tab') {
    e.preventDefault();
    commit(e.currentTarget);  // save, no focus progression
  }
}

function onInputBlur(e) {
  // Guard against blur firing during cancel/commit's own DOM removal.
  if (!e.currentTarget.isConnected) return;
  commit(e.currentTarget);
}

function commit(input) {
  const el = input.closest('.editable-value');
  const newValue = input.value;
  const displayText = formatForDisplay(el.dataset.fieldType, newValue, el);
  el.innerHTML = displayText;
  el.classList.remove('is-editing');
  el.classList.add('is-saving');
  setTimeout(() => el.classList.remove('is-saving'), 400);
  delete el.dataset.originalHtml;
}

function cancel(input) {
  const el = input.closest('.editable-value');
  el.innerHTML = el.dataset.originalHtml;
  el.classList.remove('is-editing');
  delete el.dataset.originalHtml;
}

formatForDisplay re-applies the format-preserving transforms:

Keyboard behavior

Key Behavior
Enter (single-line) Save and exit edit mode.
Enter (multi-line, Shift held) Insert newline.
Enter (multi-line, no Shift) Save and exit edit mode.
Escape Cancel and restore original value.
Tab / Shift-Tab Save and exit edit mode. No focus progression to another editable field.
Blur (click outside) Save and exit edit mode.

Rationale for no Tab-to-next-field: predicting the "next" editable field is ambiguous in this layout. Fields are scattered across cards, tables, and sections with different semantic groupings. Naive DOM order would jump between unrelated sections; visual-order detection is fragile. Save-and-exit is consistent and predictable.

Scope: what's editable, what's not

Editable (primary captured + enrichments)

Not editable (system-computed metadata)

Verification

There are no automated tests for this feature. Verification is visual and manual, performed with a running development server and a real document loaded in the browser (Playwright CLI for reproducibility).

Pixel stability test

  1. Disable the feature (comment out :editable opts or remove the script tag).
  2. Take a full-page screenshot of an invoice detail view.
  3. Re-enable the feature.
  4. Take another full-page screenshot.
  5. The two screenshots must be byte-identical (or pixel-identical modulo PNG encoding) at the rest state. Any difference is a bug.

Interaction sweep

For each field type (:text, :number, :currency, :date, :enum, :multiline), on a real document:

  1. Hover over the field — outline + background tint appear.
  2. Click — input/select/textarea replaces the display, outline strengthens, focus ring appears.
  3. Type a new value.
  4. Press Enter — input is replaced by formatted display, green flash animates.
  5. Repeat the click, type something, press Escape — original value is restored, no flash.
  6. Repeat the click, type something, click elsewhere — saved (blur behavior).
  7. Repeat the click, type something, press Tab — saved, no jump to another field.

Composite sweep

  1. Edit an account's number — verify name and confidence are untouched.
  2. Edit an account's name — verify number and confidence are untouched.
  3. Edit a line-item cell — verify other cells in the same row and other rows are untouched.
  4. Edit a party-card address — verify the textarea starts at the rendered address's exact height, grows on newlines, and name / tax-id are untouched.

Multi-line specific

  1. Click a multi-line address — textarea appears at the exact height of the rendered lines.
  2. Press Shift+Enter — newline inserts, textarea grows by one line-height.
  3. Press Enter — saves, pre-line text rendering matches what the user typed.

Rollout order

The feature is strictly additive (opt-in at each call site), so it can be wired up one component at a time. Recommended order to derisk:

  1. labeled-field + invoice-header: simplest, single-value scalars. Establishes the wrapper mechanics and the CSS contract.
  2. value-row + payment-summary: right-aligned numerics, tests text-align: inherit.
  3. party-card fields: first multi-line textarea case for addresses.
  4. line-items-table cells: many independent scalars in a dense layout, stress-tests pixel stability in a table context.
  5. line-item-card cells + accounts: most complex composite; validates the "leaf-level editable" approach for nested structures.
  6. delivery-section + enum fields: highest-risk enum case, handled last after the text/number/date mechanics are proven.

At each step, run the pixel-stability test and the interaction sweep before moving on.

Open risks

  1. Enum overlay approach. Absolutely-positioning a transparent <select> over a badge is unconventional. OS-specific rendering quirks may break it. Mitigation: leave enum fields until last (step 6 of rollout); if the overlay approach fails, fall back to "click badge → replace badge with a native-styled select" and accept the pixel shift on enum edit only.
  2. Textarea height capture. On fonts with unusual descenders or on zoomed pages, offsetHeight may not reproduce the exact visual height when re-rendered in a textarea. Mitigation: manual verification on multi-line addresses during step 3 of rollout.
  3. click vs focus race on composite cells. Clicking an account number while another account number is already being edited (blur from old → click on new) could race. Mitigation: the commit handler is idempotent; the click handler checks is-editing before starting.
  4. Preserved whitespace in multi-line. Multi-line addresses use white-space: pre-line which collapses consecutive spaces. A user pasting formatted addresses could introduce whitespace that renders differently than the original. Acceptable for a prototype.