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.
localStorage.This is the design's hardest requirement. The user must see zero pixel movement when:
The only visual motion allowed is:
All other pixels — including the first character of the text — must stay exactly where they were at rest.
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.
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:
labeled-field — [:div.field-group [:div.field-label ...] [:div.field-value ...]]. The workhorse for scalar values (invoice number, dates, tax IDs).value-row — [:div.value-row [:span.value-label] [:span.value-amount]]. Right-aligned numeric rows in the payment summary.party-card — composite card for issuer/recipient/supplier with name, multi-line address (CSS white-space: pre-line), tax-id, email, phone, fax, matched account number + name + confidence.line-items-table — <table class="line-items-table"> with columns for description, article code, quantity, unit, unit price, amount. Cells have class .numeric for right-aligned monospace.line-item-card — complex multi-row composite for enhanced line items: description + amount header, meta-row (qty, unit, price, tax, discount, article code, bu code), accounts grid (debit/credit/cost-center/accrual with confidence badges and account name/number pairs).delivery-section — address, country badge, incoterm code badge, incoterm place.collapsible-section — generic accordion wrapper used by all sections.The rest-state metrics that must be preserved exactly:
.field-value — font 14px, color #c9d1d9, no padding, no border, no background..line-items-table td — padding 10px 12px, font 13px, color #c9d1d9, border-bottom: 1px solid #21262d..line-items-table td.numeric — above plus text-align: right; font-family: monospace..value-amount — font 13px monospace, color #c9d1d9, right-aligned (inline in flex .value-row)..party-card-name — font 15px weight 600, color #c9d1d9..party-card-address — white-space: pre-line, color #8b949e, line-height 1.4.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.
structured-data a clicked value corresponds to.[:div.field-value ...] or [:td ...].resources/app/public/js/ (e.g. orb.js, upload.js, google-drive-picker.js) and are loaded via page-specific [:script {:src "/public/js/X.js"}] hiccup in the handler that renders that page. There is no global JS bundle, no framework, no build step. New JS follows the same per-page-include pattern.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.
editable-value wrapperOne 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.
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:
:editable and are untouched.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.
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.
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
.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;
}
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.background-color does not affect layout.box-shadow does not affect layout — the focus ring sits outside the element visually but has zero layout impact.border-radius: 3px is purely cosmetic.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.Right-aligned numerics (.value-amount, .line-items-table td.numeric): the input inherits text-align: inherit so the user's text stays right-aligned during edit.
Multi-line addresses (.party-card-address with white-space: pre-line): the textarea inherits white-space: inherit so wrapped lines stay wrapped identically.
Currency fields: the display is a two-child composite:
<span class="editable-value currency-wrapper">
<span class="amount">1.234,56</span>
<span class="currency-suffix"> EUR</span>
</span>
On click, only .amount becomes the input. .currency-suffix stays static. Nothing shifts.
Enum badges: handled via a measurement-and-overlay approach (see Field types below).
The text caret inside an <input> blinks. That's inherent and unavoidable. Everything else holds.
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 |
: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:
offsetWidth and offsetHeight.<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;.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:
element.offsetHeight.<textarea> with height: <captured>px; resize: none; overflow: hidden;.input listener that auto-grows: this.style.height = 'auto'; this.style.height = this.scrollHeight + 'px';.The textarea starts at exactly the display's rendered height. Growing is acceptable (only downward).
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.
REST ──click── EDIT ──Enter/blur/Tab── SAVING (400ms) ── REST
│ │ ▲
│ └──Escape/cancel────────────────────────────┘
│
└──hover──► (CSS only, no JS state)
// 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:
text / number / date: return the string as-is.currency: wraps in <span class="amount">...</span><span class="currency-suffix"> EUR</span>.enum: returns the label text (badges are styled by parent class, not by content).multiline: returns the string as-is (the wrapper has white-space: pre-line inherited).| 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.
labeled-field calls from view namespaces (invoice number, PO number, dates, tax IDs, etc.).value-row amounts in payment-summary (subtotals, tax amounts, totals, discounts).:enum). Accrual period chips are not wired up in this prototype — they're a list-valued field that requires its own add/remove/edit UX, out of scope here.:enum).labeled-field.:enum select.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).
:editable opts or remove the script tag).For each field type (:text, :number, :currency, :date, :enum, :multiline), on a real document:
number — verify name and confidence are untouched.name — verify number and confidence are untouched.name / tax-id are untouched.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:
labeled-field + invoice-header: simplest, single-value scalars. Establishes the wrapper mechanics and the CSS contract.value-row + payment-summary: right-aligned numerics, tests text-align: inherit.party-card fields: first multi-line textarea case for addresses.line-items-table cells: many independent scalars in a dense layout, stress-tests pixel stability in a table context.line-item-card cells + accounts: most complex composite; validates the "leaf-level editable" approach for nested structures.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.
<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.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.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.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.