Focus entry — the wizard + summary form pattern
Copy-exact. This document is the whole specification: a worker who has never
opened another form builds one by following this file alone. Every rule below
names the shared piece it reuses (Combobox.svelte, NumberWheel.svelte,
#lib/feedback, the kbOpen effect, the wizard-open class, row-highlight)
— if a rule here has no shared piece, say so in §18 rather than inventing a new
component.
Reference implementations (the floor — this row does not restyle them):
the quote pages src/lib/components/quote/ (NewSummary, TermEditor,
DharaPopup, YarnPopup, NewSavedPopup, QuoteListTable) and the order
form src/lib/components/orders/OrderEntryForm.svelte. The shared pieces every
rollout composes are Combobox.svelte, NumberWheel.svelte,
SuggestionChips.svelte and
#lib/feedback.js (the quote pages ship their own step markup for the popup
rules and have no wheel step — §17 row 7, §18). Origin of the chips: the sale
order's field portions (SoOrderForm.svelte, C404/C407); C409 moved the quote
terms out of their popup onto the same inline chips under the tapped row.
Origins:
src/routes/leads/new/+page.svelte (owner-signed 2026-09-20), PLANNING
§211–226, §234, §251, §253–§258, §321, §326.
Copy it to the last detail, then verify at phone width — the evidence list
is §12.6 and the rules are pinned by
tests/c151-focus-entry-doc-copy-exact.test.ts: a rule documented here and a
rule that exists in the code are the same fact, and the test fails if either
side drifts.
Companion:
UIPATTERN.md(per-screen language),ReviewSheet.svelte(the confirm sheet the summary mirrors),quoteListUtils.ts(shared list columns).
0. The lifecycle in one paragraph (read this first)
Tap the form's name in the navigation pane → the previous-records table page opens → tap New record → the new form opens with the wizard already open over the real, original, still-mounted form → Save → the check-and-save pop-up opens → its Save → the saved pop-up shows the new record's number + the important details + the animated tick for about 5 seconds → the screen shows the table again with the new row highlighted briefly. Every back button returns to the screen the user came from, and a half-filled form is offered as a draft at the TOP of the table page with Complete or Delete. §14 spells each leg out; §15 gives the consistency rules; §16 gives the table format; §17 is the quote → orders correction ledger.
1. The idea in one line
The field list comes from the map, not the screen: every field the full-flow map lists for it, with its listed mode, is on the form you build (§11 rule 6). And a wizard step is only for an important field — one that is compulsory to enter and with no default value set (§11 rule 5).
A read-only, sectioned summary of the record sits on the page (looking exactly like the old "Check & save" review sheet), and every field is edited one-at-a-time in a centred pop-up wizard over it. Number-heavy fields use a typeable slot-machine wheel (rate) or a hold-to-repeat stepper (qty). Save shows the check-and-save pop-up, then an animated tick pop-up carrying the record number, then lands on the previous-records table with the new row highlighted.
Why: a plain long form looks like an information display and is hostile on a phone. One-field-at-a-time + big tap targets + sound/haptic = data entry.
2. Page skeleton (<route>/new/+page.svelte)
Order of the parts — build them in this order, top to bottom:
- page shell → 2. title (+ status badges) → 3. dynamic fill hint →
- server error alert → 5. sectioned read-only summary (§3) →
- Clear / Save bar → 7. the hidden real form (§11) → 8. wizard overlay (§4) → 9. field pop-ups (§4b) → 10. check-and-save pop-up (§7) →
- saved pop-up (§7).
<div class="min-h-screen bg-base-200 p-4 sm:p-6">
<div class="mx-auto max-w-2xl">
<h3 class="text-lg font-bold">{Title}</h3>
<p class="mb-2 text-xs text-base-content/60">…dynamic fill hint…</p>
{#if form?.error}
<div class="alert alert-error mb-3 py-2 text-sm">{form.error}</div>
{/if}
<!-- sectioned read-only summary (see §3) -->
<div class="mt-3 flex items-center justify-between gap-2">
<button type="button" class="btn btn-ghost btn-sm" onclick={resetForm}>Clear</button>
<button type="button"
class="btn btn-sm {requiredMissing > 0 ? 'btn-disabled' : 'btn-primary'}"
disabled={requiredMissing > 0}
onclick={onSave}>Save</button>
</div>
<!-- hidden real form: POST names/values live here, untouched -->
<form method="POST" action="?/create" use:enhance={enhanceGate} bind:this={formEl} class="hidden">
<input type="hidden" name="field" value={fieldState} />
…
</form>
</div>
</div>
<!-- wizard overlay (§4) -->
<!-- check-and-save pop-up (§7) -->
<!-- saved pop-up (§7) -->
Rules (each names its shared piece):
- Title is
h3 text-lg font-bold(NOTh1 tracking-tight) — it must match the check-&-save heading exactly. - Subtitle is dynamic:
{n} required to fillintext-warningwhile required fields are empty, elseAll required fields filled — tap Save. - The real
<form>isclass="hidden"and stays mounted — POST names, values anduse:enhanceare byte-identical to the old form. Never{#if}the inputs away. Shared piece: none — this is the data contract (§11). onSaveopens the check-and-save pop-up (§7) on forms that gate saves, and callsformEl?.requestSubmit()where they do not. Never submit straight past the summary.- Extra cards that belong to a form (dictation panel, duplicate hint, drafts picker) sit between the title and the summary; they never move a POST name or a server validation.
- There is no section menu. A grid of big "section" cards (one per section,
numbered, with a done tick and a per-section status line) is not a part of
this skeleton and never may be added to one: it is a second, coarser index
over the same fields, it jumps to a wizard step rather than to the field a
row names, and it has no place in the order above — so the moment a summary
grows a Clear/Save bar of its own, the cards end up as the last thing on the
page. §3's summary is the form's only index; every field is one tap from its
own row. (C184 removed exactly this grid from
/bookings/new.)
3. The sectioned summary (the "check & save" look)
Exact markup per section:
{#each summarySections as g (g.title)}
<h4 class="mb-1 mt-3 text-xs font-semibold uppercase tracking-wider text-base-content/50">{g.title}</h4>
<div class="divide-y divide-base-200 rounded-box border border-base-300 bg-base-100">
{#each g.rows as r (r.label)}
{#if rowStep(r) < 0}
{#if summaryRowPopup(r.field, t) === 'field'}
<button type="button"
class="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-base-200/60"
onclick={() => { tap(); fieldPop = r.field; }}>
<span class="shrink-0 text-base-content/60">{r.label}{#if r.required}<span class="text-error"> *</span>{/if}</span>
<span class="truncate font-medium">
{#if r.value}{r.value}
{:else if r.required}<span class="text-warning">Tap to add</span>
{:else}<span class="text-base-content/40">Optional</span>{/if}
</span>
</button>
{:else}
<div class="flex w-full items-center justify-between gap-3 px-3 py-2 text-sm">
<span class="shrink-0 text-base-content/60">{r.label}{#if r.required}<span class="text-error"> *</span>{/if}</span>
<span class="truncate font-medium">
{#if r.value}{r.value}
{:else if r.required}<span class="text-warning">Tap to add</span>
{:else}<span class="text-base-content/40">Optional</span>{/if}
</span>
</div>
{/if}
{:else}
<button type="button"
class="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-base-200/60"
onclick={() => { tap(); openWiz(rowStep(r)); }}>
<span class="shrink-0 text-base-content/60">{r.label}{#if r.required}<span class="text-error"> *</span>{/if}{#if r.kind}<span class="badge badge-ghost badge-xs ml-1">{r.kind}</span>{/if}</span>
<span class="truncate font-medium">
{#if r.value}{r.value}
{:else if r.required}<span class="text-warning">Tap to add</span>
{:else}<span class="text-base-content/40">Optional</span>{/if}
</span>
</button>
{/if}
{/each}
</div>
{/each}
Rules (each names its shared piece):
- One line per field, label left, value right, value truncated
(
truncate) so a long party name never wraps the row. - A row that owns no wizard pop-up is still reachable — by its OWN box, or not
at all (C180). Inside the
rowStep(r) < 0branch,summaryRowPopup(r.field, t)decides, in this order:'wizard'never reaches here (that row opens its wizard step, the{:else}of the outer{#if});'field'renders a real button that setsfieldPop = r.fieldand opens that field's own §4b box;'none'renders the static<div>— readable, never tappable. So the branch is not "static row" any more, it is "a row with no wizard step", and a form built from the pre-C180 shape here builds a form where a stepped-out field cannot be edited at all. Shared piece:summaryRowPopup/STEP_LESS_ENTERABLE/STEP_LESS_LOCKEDin the form's ownorderLogic.ts. - Required rows carry a red
*and read amber "Tap to add" when empty; optional rows read muted "Optional". - A field that has kinds (trading vs job-work, say) shows a
badge badge-ghost badge-xsbeside the label — shared piece: daisyUI badge. - The summary is read-only: tapping a row opens that field's own wizard
step. Shared piece:
tap()from#lib/feedback.js(§8). - A row resolves its OWN pop-up, never a fallback. Every row carries the
field name it edits and
rowStep(r)asks the shared resolver —tapOpensPopup(field, kind)insrc/routes/orders/new/orderLogic.ts, built onwizardStepIndexForField— which returns that field's step index, or-1when the field has no pop-up of its own. A-1row renders a static<div>: not a button, nothing to tap. It is a field the owner has taken out of the wizard (payment dhara, brokerage, pass-on on a job-work order) or a plain input typed where it stands. Never write a fallback to the wizard's first step, and no row anywhere falls back to the wizard's first step:openWiz(n)clamps withMath.max(0, …), so a-1handed to it silently opens step 0 — the row's field and the pop-up the user sees stop matching. A row may also be forced static on purpose withopen: false. - Section heading is
text-xs font-semibold uppercase tracking-wider text-base-content/50; the group box isdivide-y divide-base-200 rounded-box border border-base-300 bg-base-100. - The value slot carries a VALUE, never a sentence (C197). The slot may hold
exactly three things: the value, a neutral marker —
Autofor a document number the system mints on save,Automaticfor something that happens by itself, or the doc's own — when there is nothing yet — or, for a field with a default, amber "Tap to add" / muted "Optional". An instruction is not a value:staticValue: 'Auto on save — the next GP number'is the bug this rule was written for (the owner's report: "a lot of fields are carrying data instructions? … in beam in gatepass GP no shows Auto on save").FocusEntry.sveltereads the slot with one line (staticValue ?? values[id]), so the shell is where the rule lives, and an explanation rides the separatestaticNote, kept for the row's own identity — C334 stopped printing it: a step and a pop-up now carry the short label and the field only, no explanation line under either (§4b), so a sentence that must be read belongs in this document, on the record page or in the guard's job:
A static row with a note is tappable and opens that read-only pop-up; a static row without one stays a plain{ id: 'gpNoStatic', label: 'GP no', staticValue: 'Auto', staticNote: 'Auto on save — the next GP number', kind: 'A', mode: 'static' }<div>. Nothing is lost: a sentence either moves into this document or onto the record page. A row that has a real value and a sentence keeps the value verbatim (${totalMtrs} mtrs · beam meter). A state chain with no verb — the status ladderEntered — picked → delivered → confirmed → reconciled— is a value and stays whole. Named exceptions, where a sentence deliberately stays in the slot, are listed in §18. - Section-merge rule (owner): related fields share one section — e.g. Quality + Quantity in one, Rate + Remarks in one.
Data shape (a $derived):
type SummaryRow = { field: string; label: string; required: boolean; value: string; kind?: string; open?: boolean };
// rowStep(r) = (r.open === false ? -1 : tapOpensPopup(r.field, kind))
const requiredMissing = $derived(/* count of empty required fields */);
const summarySections = $derived([
{ title: 'Party / Broker', rows: [{ field: 'party', label: 'Party', required: true, value: party || '' }] },
{ title: 'Quality & Quantity', rows: [ /* two rows, one section, each naming its field */ ] },
{ title: 'Rate & Remarks', rows: [ /* … */ ] },
]);
4. The wizard modal — one field per pop-up
{#if wizOpen}
<div class="fixed inset-0 z-40 flex {kbOpen ? 'items-end pb-2' : 'items-center'} justify-center p-4">
<button type="button" aria-label="Dismiss"
class="absolute inset-0 bg-black/15 backdrop-blur-[2px]"
onclick={() => (wizOpen = false)}></button>
<div class="relative flex max-h-[85dvh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-base-300 bg-base-100 shadow-xl"
role="dialog" aria-modal="true">
{#if !kbOpen}<div class="flex shrink-0 items-center justify-between px-4 pt-3">
<p class="text-[11px] font-semibold uppercase tracking-wide text-base-content/50">Step {wizStep + 1} of {WIZ_STEPS.length}</p>
<button type="button" class="btn btn-ghost btn-xs btn-circle" onclick={() => (wizOpen = false)}>✕</button>
</div>
<div class="shrink-0 px-4 pb-2">
<h2 class="text-base font-semibold leading-snug">{WIZ_STEPS[wizStep].title}</h2>
</div>
<div class="flex shrink-0 gap-1 px-4 pb-1" aria-hidden="true">
{#each WIZ_STEPS as s, i (s.id)}
<div class="h-1 flex-1 rounded-full {i <= wizStep ? 'bg-primary' : 'bg-base-300'}"></div>
{/each}
</div>{/if}
<div class="min-h-0 flex-1 overflow-y-auto overscroll-y-contain px-4 py-3">
…one step's content (§5)…
</div>
<div class="flex shrink-0 items-center gap-2 border-t border-base-200 bg-base-100 px-4 py-2.5">
<button type="button" class="btn btn-outline btn-sm flex-1" disabled={wizStep === 0}
onclick={() => { tap(); wizBack(); }}>← Back</button>
<button type="button" class="btn btn-primary btn-sm flex-[2]"
onclick={() => { tap(); wizNext(); }}>{wizStep === WIZ_STEPS.length - 1 ? 'Done' : 'Next →'}</button>
</div>
</div>
</div>
{/if}
Behaviours (all required, each with its shared piece):
- One field per pop-up — the wizard never shows two fields at once.
- Progress is stated twice: the text
Step {n} of {N}and a row ofh-1 flex-1 rounded-fullbars (bg-primaryup to the current step,bg-base-300after). - Centred card,
max-w-md,max-h-[85dvh], the body scrolls (min-h-0 flex-1 overflow-y-auto overscroll-y-contain), the footer does not: Back / Next-Done stays reachable. - Back is
btn-outline … flex-1and disabled on step 0; Next-Done isbtn-primary … flex-[2]and readsDoneon the last step. Both calltap(). - No
autofocusanywhere — the keyboard only opens when the user taps a field. - The step and the pop-up print the short label and the field only (C334) —
no
sub/staticNoteexplanation line under the heading, and no built-in instruction line inside a control (the money wheel's "Scroll ₹… a slot…" went with it). Guidance belongs in this document, on the record page, or in the guard's job. wizOpenstarts open only when a step still needs filling (C300) — the wizard opens on the first step whose field is still empty (firstNeededStepinsrc/lib/focusWizard.ts), and a form whose steps are all already carried/prefilled opens straight on its summary with no wizard. Steps answered by a carry are not walked through onNexteither; every step stays reachable from its summary row, and a carried value is confirmed or changed from that summary row (the wizard is the flow, never a compulsion).openWiz(n)/wizNext()/wizBack();wizNext()on the last (or on no further unanswered) step setswizOpen = false.- Searchable picks advance on pick; qty/rate coin taps fill and stay (§234) so the user sees the value land and can adjust.
- The current pick stays visible as a removable chip ABOVE the search box
(§234b,
Combobox.svelte) — the box itself stays empty for a new search, so suggestions never vanish and tapping the field always reopens search. - Keyboard collapse (§234c–e, C144): track the tallest viewport seen and
compare — never
window.innerHeight, which shrinks together with the keyboard (interactive-widget=resizes-content). When the current height drops >150px below the max (touch devices only), hide the step header + titles + progress ({#if !kbOpen}) and dock the card just above the keyboard (items-end pb-2, no dead gap) so the freed space above shows the page. Shared piece: thekbOpensnippet below. - The shell nav hides the whole time the wizard or any field pop-up is open
(
wizard-openon<html>+ oneapp.cssrule). - Lock the page behind it (shared piece: this one effect, copied verbatim):
// kbOpen — touch devices only; tallest-viewport baseline (never innerHeight)
$effect(() => {
if (!wizOpen && !popA && !popB) return;
const touchCapable = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
const vv = window.visualViewport;
let maxHeight = 0;
const check = () => {
const height = vv?.height ?? window.innerHeight;
maxHeight = Math.max(maxHeight, height);
kbOpen = touchCapable && maxHeight - height > 150;
};
check();
vv?.addEventListener('resize', check);
window.addEventListener('resize', check);
return () => { vv?.removeEventListener('resize', check); window.removeEventListener('resize', check); };
});
$effect(() => {
if (wizOpen || popA || popB) {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
document.documentElement.classList.add('wizard-open');
return () => { document.body.style.overflow = prev; document.documentElement.classList.remove('wizard-open'); };
}
});
4b. Field popup spec (COPY THIS for every new popup — owner §251)
Every small popup (yarn rate, adjust, add-quality, payment dhara/slab…) MUST be this exact structure. The quote popups shipped without rules 2–3 and the keyboard + header crowded them out on phones — fixed 2026-09-22 by copying leads. Do not invent a new popup shape; copy this one:
{#if myPop}
<div class="fixed inset-0 z-[60] flex {kbOpen ? 'items-end pb-2' : 'items-center'} justify-center p-4"
role="dialog" aria-modal="true" aria-label="…">
<button class="absolute inset-0 cursor-default bg-black/30" aria-label="close"
onclick={() => (myPop = null)}></button>
<div class="relative max-h-[85dvh] w-full max-w-xs overflow-y-auto overscroll-y-contain rounded-2xl border border-base-300 bg-base-100 p-5 text-center shadow-xl">
{#if !kbOpen}
<h2 class="text-base font-bold">…</h2>
<p class="text-xs text-base-content/50">…</p>
{/if}
…fields (every input: inputmode + scrollIntoView onfocus, §5)…
<button type="button" class="btn btn-primary btn-sm mt-3 w-full"
onclick={() => (myPop = null)}>Done</button>
</div>
</div>
{/if}
Rules (all required, no exceptions; each names its shared piece):
- z-scale: wizard
z-40· field popupsz-[60]· success/tickz-[70]. A field popup always sits above the wizard; success above everything. - Dock above the keyboard: the container MUST switch
{kbOpen ? 'items-end pb-2' : 'items-center'}using the sharedkbOpenstate (tallest-viewport baseline, touch-only, §234d, §4). A popup with plainitems-centerWILL be covered by the keyboard — this was the quote bug. - Hide the shell header: the
wizard-openeffect MUST stay active while ANY popup is open, not just the wizard steps — its guard iswizOpen || popA || popB, neverwizOpenalone. A popup opened from the summary lands on a page WITH the header otherwise — this was the second half of the quote bug. - Hide popup chrome when the keyboard is up (
{#if !kbOpen}around title/sub) — titles steal rows the fields need. - Card scrolls, never the page:
max-h-[85dvh] overflow-y-auto overscroll-y-contain(wizard:max-h-[85dvh] … overflow-hidden+ inneroverflow-y-auto overscroll-y-contain). Body getsoverflow: hiddenwhile open (rule 3's effect does both). - Every input carries
inputmode+onfocus={(e) => e.currentTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })}. Noautofocusanywhere — the keyboard opens only on user tap. - Widths: field popups
max-w-xs, wizardmax-w-md, formsmax-w-2xlshell with amax-w-mdinner column. - A field with no wizard step opens THIS box (C180, owner §251/§163).
Owning no step must never also mean owning no way to edit: a summary row
for a field no wizard step owns opens that field's own box — the §4b shape
above, holding that ONE field, writing the same state the hidden form
already posts. This is a rule, not two hand-picked rows: every such field
is resolved by one function (
summaryRowPopup) to its own box or to a deliberately locked plain line (STEP_LESS_ENTERABLE/STEP_LESS_LOCKEDinorderLogic.ts), so a field that lands in neither set fails the C180 test instead of quietly coming back as a dead row. The box's controls carry noname— the form's own input keeps the POST name and stays mounted underneath, so every name is still posted exactly once. Three things stay plain read-only lines on purpose: a locked value (the system date is stamped by the server alone, C135), a field an earlier correction took out of the wizard (C138's job-work terms), and a field that is not a value at all (status, the file picker, the order kind). Never create a wizard step for such a field to make it reachable — a step is for an important field only (C163), and the box is the answer.
5. Field step types (pick the right one per field)
Two rules decide this, and both are pinned in §11:
- §11 rule 5 — a wizard step is only for an important field: a field that is compulsory to enter and with no default value set (other cost 5.5% is the signed non-example). Everything else is a summary row that opens its own single-field popup — that is where a non-step field is edited — and nothing becomes un-editable.
- §11 rule 6 — every field
static/full-flow.htmllists for this form has to be here, with its listed mode. A field missing from the form is added in the same change, not deferred to the next screen.
So each field in the full flow is a wizard STEP or a summary ROW/ROW-POPUP, the important-fields decision is stated per field, and the field→step split is listed separately in the PLANNING entry. "The wizard" means the one-field-at-a-time steps only.
Dictate-first (§235, §242) — a transcript
textareawith an in-browser mic button (Web Speech API; Gboard mic works the same) plus a camera button (capture="environment"): mic speech lands in the box, a photo of the enquiry chit/message is client-downscaled (≤1600px JPEG) and posted with the text. Enter / "Fill from speech" / photo all callPOST /api/lead-understand— DeepSeek (deepseek-flash) reads transcript and/or photo against the master lists and fills party/quality/qty/rate/notes (photo text returns astranscript). No heuristic fallback: failure shows an error and fills nothing; every later step is still checked by hand before save. Dictation only ever prefills; each filled field is markeddraftand lands in the check-and-save "to confirm" list — the user checks or edits each one. Shared piece:OrderDictation.svelte/TippanWizard's dictate panel.Searchable pick (party/quality/broker/city…) — use
#lib/components/Combobox.svelte, do not hand-roll a list: the chip, the results position, the confirm-before-submit and the hidden POST input are all inside it. Props{ options, name, required, placeholder, selectedId = $bindable, label = $bindable, onselect }. Its DOM order is fixed: removable pick chip → results → search input → hidden input (results in normal flow above the input, neverabsolute, so the keyboard never hides them). Pick =tap()+ writeselectedId/label. Search needs ≥2 characters; typing after a pick clears it; an external label set (quote/lead/draft prefill, form reset) re-syncs the visible input. A wizard step that wants the suggestion coins instead builds them from the same list — results box above the input,Regulars/Qualitiescoins below, and the coins fill and stay:
{#if wizSearch.trim().length >= 2}
<div class="mb-1 max-h-44 overflow-y-auto rounded-box border border-base-300 bg-base-100">…rows…</div>
{/if}
<input class="input input-bordered w-full" placeholder="Search…" bind:value={wizSearch}
onfocus={(e) => e.currentTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })} />
{#if wizSearch.trim().length < 2}
<p class="mb-1.5 mt-3 text-[11px] font-semibold uppercase tracking-wide text-base-content/50">Regulars</p>
<div class="flex flex-wrap gap-1.5">…coins…</div>
{/if}
- Quantity stepper (±) —
items-stretchrow so the input and buttons share one height; the caption sits OUTSIDE the row:
<div class="flex items-stretch justify-center gap-2">
<button type="button" tabindex="-1" class="btn btn-outline w-12 select-none text-lg"
onpointerdown={(e) => { e.preventDefault(); e.stopPropagation(); startHold(-STEP); }}
onpointerup={stopHold} onpointerleave={stopHold} onpointercancel={stopHold}
oncontextmenu={(e) => e.preventDefault()}>−</button>
<input class="input input-bordered w-32 text-center text-lg font-semibold tabular-nums"
inputmode="numeric" bind:value={qty} oninput={() => (qty = qty.replace(/\D/g, ''))}
onfocus={(e) => e.currentTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })} />
<button type="button" tabindex="-1" class="btn btn-outline w-12 select-none text-lg" …>+</button>
</div>
<div class="mt-1 text-center text-[11px] uppercase tracking-wide text-base-content/40">metres</div>
Hold-to-repeat (starts 250ms, accelerates ×0.7 to a 50ms floor) — the shared
recipe, shipped in src/routes/leads/new/+page.svelte (startHold/stopHold)
and src/routes/bookings/new/+page.svelte (same pair, closure form). The
quote pages carry their own instance of the same rule in
src/routes/quote/new/quoteNewState.svelte.ts (holdStart/holdStop, 220ms
×0.75 to a 45ms floor) — the rule is shared, the two timings are both
shipped; a new form copies one of them verbatim rather than inventing a
third.
let holdTimer: ReturnType<typeof setTimeout> | undefined;
let holdDelay = 250;
function applyQty(delta: number) { qty = String(Math.max(0, Math.min(MAX, Math.round((Number(qty)||0)+delta)))); }
function qtyTick(d: number) { applyQty(d); holdDelay = Math.max(50, holdDelay*0.7); holdTimer = setTimeout(() => qtyTick(d), holdDelay); }
function startHold(d: number) {
tap();
(document.activeElement as HTMLElement | null)?.blur?.(); // never re-open the keyboard
applyQty(d); holdDelay = 250;
if (holdTimer) clearTimeout(holdTimer);
holdTimer = setTimeout(() => qtyTick(d), holdDelay);
}
function stopHold() { if (holdTimer) { clearTimeout(holdTimer); holdTimer = undefined; } }
Suggestion coins below (horizontal scroll row, btn btn-outline btn-sm rounded-full shrink-0).
- Amount wheel (rate, money) — use
#lib/components/NumberWheel.svelte(never fork it; §6 has the contract) — one wheel per part (e.g. ₹ 0–999 and paise 0–99) + "Last used" suggestion coins above:
<button … class="btn btn-outline btn-sm rounded-full {rate !== '' && Number(rate) === r ? 'btn-active' : ''}" onclick={() => { tap(); rate = String(r); }}>₹{r}</button>
<div class="flex items-center justify-center gap-1">
<span class="text-lg font-semibold text-base-content/60">₹</span>
<NumberWheel min={0} max={999} value={parts.rupees} typeable onchange={(v) => setPart('rupees', v)} />
<span class="text-lg text-base-content/30">.</span>
<NumberWheel min={0} max={99} value={parts.paise} typeable onchange={(v) => setPart('paise', v)} />
<span class="text-lg font-semibold text-base-content/60">/m</span>
</div>
Free text —
textarea.textarea.textarea-bordered.w-fullwithonfocus→scrollIntoView.Date —
<input type="date" class="input input-bordered w-full">, withmaxset to today where a past date is meaningless.Small enumerations (mode, counted-in, city, papers) — one row of
btn btn-outline btn-sm rounded-fullcoins withbtn-activeon the chosen one. Tapping a coin fills and stays; the user continues with Next.Photo / evidence file —
mode: 'file'on the summary row, which opens the shell's own single-field popup (Add <label>/Replace <label>, a camera-capture input). It posts the saved filename of the shared/upload-file?bin=screenshotsdrop, never the file itself, and the hidden form carries it under its own POST name. Three rules, from C186 on the G0 inward gatepass:- Two photos = two fields, never one field reused.
image= "what was received" (the physical document) andconfirmPhoto= "what confirmed it" (the sizer / weaver WhatsApp photo) are distinct rows with distinct labels and distinct 【PH】 / 【PH/OCR】 badges, so the record reads as both. One handler serves them and routes on the row id —FocusEntrycallsonfile(id, file), so a one-argument handler silently posts the id string instead of the file. - Evidence that arrives later is never a wizard step. A confirmation
photo is not compulsory at entry, so per §11 rule 5 it is a summary row
with its own popup,
requiredis never set on it, and it can never sit in therequiredMissingcount that gates Save. - No column of its own → the creation event payload. Read it off raw
FormDatain the action (exactly asbeamTypedoes) and log it with the creation event: no zod change, no schema change, no migration, and the existing column keeps its own name.
- Two photos = two fields, never one field reused.
Close-out tap (【T】, arrives after the movement) —
mode: 'pick'over two or three codes,kind: 'T', neverrequired, never a wizard step, on the row whose label says when it belongs ("… (after delivery)"). From C187 on the G0b direct-delivery intimation, where the flow's close-out is "sizer = WhatsApp confirm+photo 【PH】 + post-delivery GP counter-copy signed 【T】":- The close-out lives on the form the movement was intimated on, beside
the flow's other close-out evidence, and the leg that another row owns
(G0b's
premises = inward) stays where it is. Two screens must never both claim one leg of the flow. - Reuse an evidence field when it means the same thing. The WhatsApp
confirmation photo is already
confirmPhoto(step type 7); a second field for the same evidence would be a duplicate of a duplicate. - A small tap still needs its own POST name, appended at the end of the
hidden form (
counterCopyaftertransport), read off rawFormDataand logged on the creation event. Carrying it on a name that already means something else stores the tap under the wrong meaning.
- The close-out lives on the form the movement was intimated on, beside
the flow's other close-out evidence, and the leg that another row owns
(G0b's
- "Not back yet" is a legitimate code. The tap answers "has the signed counter-copy come back?", and it is answered after the delivery, so an explicit pending code is what keeps the row honest without gating Save.
- Zero-entry gate tap (the guard's half of the flow) — a flow row that
says "zero entry" gets a form that asks only its own list, and every
【A】 step is a
staticrow, never a field. From C188 on B0b, the beam-outward gate tap (/gatepasses/new?kind=BEAM_OUT):- One form must not carry two flow rows.
?kind=BEAM_OUTwas doing B0 (the planner's intimation: #beams, beam type, expected date, vehicle) and B0b (the guard's tap) at once, so the gate had to re-type the planner's numbers before it could save. When a kind is both, the tap side is what the form is for and the planner's fields move to the row that owns the intimation — the beam card here (C175). This row stops the form asking; it does not build the other form. - The tap list names its source. The label says where the items come from ("Flange nos (beams from the intimation)") and the caption says the user is reconfirming what was pre-assigned — a multi-pick off the whole floor list reads as free picking, not as confirming a plan.
- Say where the missing fields went. A
staticrow under "Which movement" ("Planned on the intimation (B0)") lists the fields this form no longer asks, so nobody hunts for them twice. - An automatic step is a
staticrow, not a missing field. B0b's "send confirmation 【A】" is the save itself, so the row says "Automatic — saving this tap records that the beams were sent". Nothing auto-commits: opening the page still writes nothing. - The other kinds keep their fields. Per-kind flags
(
showExpected,showVehicle) are per kind, never global; a hidden row still posts its POST name, and the server keeps validating and storing every one of them. - A shared goods block is not a default — read it against the row. The
same rule settles a kind that is NOT zero entry. From C189 on B5b, the
yarn-outward gatepass (
?kind=YARN_OUT) inheritedYARN_IN's whole goods block (countYarn+ticket+bags+cones+gross+net, andconesPerBagriding oncones) while B5b lists four things: the yarn barcodes 【T/SC】, the party/weaver 【S】, the vehicle 【N】 and the guard's taps. Those six are an inward receipt's fields. So:- Switch the block per kind, and give the sibling that owns the fields
its own flag.
goods: {}on YARN_OUT; YARN_IN and WINDING keep theirs byte-identical because the flow lists the weights on both. - Say where the six went, in one
staticrow. "Count, ticket and weights (not this gatepass)" names the inward leg (yarn in, G0) and the real home of an outbound weighing — a movement (B5), which is its own form. Reused blocks are the usual source of a ten-field form for a four-field row. - A prefill row that fills nothing visible leaves with the block. With
the goods gone,
goodsSectionShownis false and the booking picker goes too — it fills count/ticket/mill lot/bags/cones-per-bag, i.e. only the removed fields (and?bookingId=forces the inward kind anyway). - The 【A】 half stays the status ladder. B5b's "receiver confirms like a
GP" is an act on the record, already carried by the shared
Statusstatic row — never a second field storing a competing meaning. - The server is not the thing being corrected. Every one of the six keeps its POST name, its zod field, its column and its validation, so a POST that still carries them is stored exactly as before. The form stopped asking; nothing stopped storing.
- Switch the block per kind, and give the sibling that owns the fields
its own flag.
- A row the flow lists for a kind must EXIST on that kind. The same rule
settles a kind that is not zero entry at all. From C190 on C3 step 1
(the sizing return,
/gatepasses/new?kind=BEAM_IN): the flow lists "beams 【SC】 · mtrs/beam 【OCR→N】 · ends 【OCR】 · quality 【OCR→…】 plus the guard's beam taps", and the form had the three values but no tap row at all —beamIdswas a declared wizard step, a POST name and agatepass_linkwrite with nothing to type it into on this kind. A per-kind flag (showBeamPick) had quietly excluded it, so the wizard silently skipped a step the flow lists and the Save gate could not see the taps.- A mode the flow lists is the widget, not a badge. 【OCR → confirm】 is one camera affordance on the field plus a caption that says the value was read from the slip and is still to be checked; a field badged 【OCR】 with no affordance is free typing with a label on it.
- A scan belongs to the row it sits on.
FocusEntrycallsonscan(id, file), so the handler routes on the id — the sizing slip's parser for this kind's three rows, the plate reader for the vehicle. One handler that ignores the id writes the sizing quality into the vehicle field the first time anyone scans it. - One photo may fill several rows; each row still confirms its own
value. A stepper (
qty) widget has no camera button, so it is filled by the scan on its neighbours and its caption says so — the parser (#lib/sizingOcr.js) is shared with the other surface that reads the same slip, so the same three values carry the same codes in both places. - Say where the LATER step lands. A
staticrow ("Full hishob (not this gate tap)") names C3 step 2, so a reader who expects the hishob here is told where it is, and the row adds no POST name, no column and no yardage booking.
- A mirror leg is its own row, and a receipt's figures stay on the
receipt. From C193 on C5b (the winding-yarn outward gatepass,
/gatepasses/new?kind=WINDING, the doc C3 step 2 promises once the hishob closes): C5 is the receipt of what the winder wound — empties, ring wt, received net wt, the net/gross difference, the set closing — and this kind is the yarn leaving sizing for the winder. The two overlap on six fields (set no, winder, bags/cones, gross, net, year), which this kind asks, and on none of the receipt's own numbers, which it must not.- The receipt's fields are asked once, on the receipt screen. Empties,
ring wt and received net are the winder's report (
/winding), weighed against what sizing sent; asking them again at the gate would give one quantity two homes and the diff a second meaning. - Say where they went, in one
staticrow. "Empties, ring wt, received net (not this gatepass)" names C5 and the screen, so nobody hunts for the reconciliation on a form that does not carry it — the same move as C188's B0 row, C189's B5b row and C191's C2b row. - Two flow rows may share a noun and still be two documents. The map names the outward gatepass (C5b) and the receipt (C5) separately, each from the form that carries it, with nothing renumbered.
- The receipt's fields are asked once, on the receipt screen. Empties,
ring wt and received net are the winder's report (
- One form must not carry two flow rows.
6. NumberWheel.svelte contract (src/lib/components/NumberWheel.svelte)
Props: { min=0, max=9, value=0, typeable=false, ariaLabel?, onchange }.
ariaLabel names the typeable centre input for screen readers when the wheel
has no other visible label (e.g. the job-work quantity wheel).
- The slot geometry is the contract — one value per
H = 40pxslot: containerh-[120px] w-16, ah-[40px]spacer above and below the values, and one<div class="flex h-[40px] items-center justify-center …">per value, so exactly 3 slots are visible (faint above, active centre, faint below) attext-base-content/40. - Centre band:
pointer-events-none absolute inset-x-0 top-[40px] h-[40px] rounded-md border-y border-base-300 bg-base-200/50; gradient fades (bg-gradient-to-b from-base-100 to-transparent/-to-t, alsopointer-events-none) mask the slot edges. - Scroll container:
no-scrollbar h-full touch-pan-y overflow-y-auto overscroll-y-contain(scrollbar-width:none,-webkit-overflow-scrolling:touch) — so the wheel scrolls inside the wizard instead of taking the page. - Offset for value
vis(v - min) * H, top and bottom; an external value change scrolls the reel to that offset. - Live active number on
onscroll(updatetextonly when the rounded value changes — keep it cheap). - Snap on
onscrollend:el.scrollTo({ top: (v-min)*H, behavior:'smooth' })thenonchange(v)if changed. - Slot-machine tick (
tick()from#lib/feedback.js) once per value passed — softer than the first build (§225); never a new sound. - Typeable centre, touch-safe: the input is
pointer-events-noneunlesstyping; a tap on the wheel callsstartTyping()(setstyping=true, focuses the input on the next frame);onblursetstyping=false. → the wheel stays fully draggable mid-scroll (no input steals the gesture), and typing happens in the active band.inputmode="numeric", value sanitised on input.
Do not fork this component per form — configure via props.
7. Save → check-and-save → saved pop-up → the table
The whole leg is §14's lifecycle; the timings live here.
- Save (bottom bar) opens the check-and-save pop-up — the read-only confirmation of every carried value, with the unconfirmed ones listed as tappable items. Its Save button is disabled while anything is unconfirmed ("Save order · {n} to confirm"), and Back to edit returns to the form (the screen it came from). One named exception: the yarn booking has no tick gate (§18, C185).
- The
createaction returns{ ok: true, id, docNo }(no redirect). The form'suse:enhancereads it and opens the saved pop-up:
import { goto } from '$app/navigation';
import { success } from '#lib/feedback.js';
let saved = $state<{ id: string; docNo: string; /* + display fields */ } | null>(null);
function enhanceGate() {
return async ({ result, update }) => {
await update();
if (result?.type === 'success' && result?.data?.ok) {
const d = result.data as { id: string; docNo: string };
saved = { id: d.id, docNo: d.docNo, /*…*/ };
success(); // sound + haptic
setTimeout(() => goto(`<list>?created=${d.id}`), 5000);
}
};
}
- Saved pop-up —
fixed inset-0 z-[70] flex items-center justify-center p-4, dim backdrop, cardw-full max-w-sm rounded-2xl border border-base-300 bg-base-100 p-6 text-center shadow-xl. It shows, in this order:- the animated tick (GPay-style stroke-draw),
- the new record's number (
docNo, the thing the user reads aloud), - the important details as a
divide-ydetail table. The pop-up itself is the sharedSavedPopup.svelte— props{ open, title, number, numberLabel, details, note }; it carries the tick, the record's number and thedivide-ydetail table, so a new form imports it instead of hand-copying it. Three older forms still hold the identical markup from before it existed (quote/NewSavedPopup.svelte,booking/BookingSavedPopup.svelte,leads/new/+page.svelte); those are the only ones left and folding them in is Q-C151-5 (§18). Never write another hand-copy — the tick below is the reference markup, and it lives inSavedPopup.svelte:
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-success/15 text-success">
<svg class="check-svg h-9 w-9" viewBox="0 0 52 52" aria-hidden="true">
<circle class="check-circle" cx="26" cy="26" r="25" fill="none" stroke="currentColor" stroke-width="3" />
<path class="check-mark" fill="none" stroke="currentColor" stroke-width="3" d="M14.1 27.2l7.1 7.2 16.7-16.8" />
</svg>
</div>
.check-circle { stroke-dasharray: 166; stroke-dashoffset: 166; stroke-linecap: round;
animation: check-circle-draw .6s cubic-bezier(.65,0,.45,1) forwards; }
.check-mark { stroke-dasharray: 48; stroke-dashoffset: 48; stroke-linecap: round; stroke-linejoin: round;
animation: check-mark-draw .3s cubic-bezier(.65,0,.45,1) .55s forwards; }
@keyframes check-circle-draw { to { stroke-dashoffset: 0; } }
@keyframes check-mark-draw { to { stroke-dashoffset: 0; } }
- Held about 5 seconds (
5000), thengoto('<list>?created=<id>'). Amended 2026-09-30 (PLANNING §326): this supersedes the earlier 1500 ms hold — the user needs time to read the number before the screen moves.
- The table shows the new row highlighted briefly. The list page reads the
createdparam in an$effect, setshighlightId, and the table'srowClassapplies the sharedrow-highlightfor ~2.5s:
const created = page.url.searchParams.get('created');
if (!created) return;
if (created === highlightId) return; // already shown
highlightId = created;
rowClass={(r) => (r.id === highlightId ? 'row-highlight' : '')}
Shared pieces: the $effect above (orders + quote both ship it) and
.row-highlight in src/app.css.
8. Feedback — src/lib/feedback.ts (sound + haptic)
Browser-guarded Web Audio + navigator.vibrate, no-ops off-device/SSR.
tap()—vibrate(8)+tone(540, 0.05)— every data-entry tap (coins, picks, Combobox picks, Next/Back, summary rows).tick()—tone(400, 0.05, 0, 0.09)— the slot-machine reel click (wheels).success()—vibrate([40,50,40])+tone(660,.12)+tone(880,.2,0.09)— on a completed save. Import and call — never inlinenew AudioContext()in a page, and never ship a second feedback module.
9. The phone/keyboard popup spec — "TYPE ABOVE THE KEYBOARD" (C144, copy this)
- No
autofocus, anywhere — the keyboard opens only on a user tap. Buttons never grab focus (tabindex="-1",preventDefault,stopPropagation,oncontextmenuprevented). Hold-steppersblur()first. - Text inputs
onfocus→scrollIntoView({ block:'center', behavior:'smooth' }). - The wizard body is
overflow-y-auto overscroll-y-contain; the wheel is too. document.body { overflow: hidden }while the wizard or any field popup is open (§4).app.htmlviewport must bewidth=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content(already set) — required so the keyboard resizes the layout viewport.- Results in normal flow ABOVE the input, never
absolute/below (the keyboard hides them), and every results box carries the markerdata-c144="results-above-input"so the rule is checkable in the markup.Combobox.svelte(and thefocus/FocusEntry.svelteshell) is the shipped version of this rule. - Keyboard state is measured from
window.visualViewportagainst a tallest-seen baseline and only on touch devices (§4). - With the keyboard open, the wizard (the card) changes three ways — this is
C144's rule 6 and it is what the phone evidence list checks:
the card goes tall and pinned above the keyboard, the backdrop goes
opaque (
bg-black/70instead ofbg-black/15) so the fields read against it, and the Back/Next footer stays — it sits outside the scrolling body so it cannot scroll out of reach. The step header, the title and the progress dots hide while the keyboard is up ({#if !kbOpen}), the same rule as §4b. - Light mode only: daisyUI v5 with
themes: light --defaultinapp.css.
10. Data-entry affordance (must be present)
- Required rows: red
*on the label, amber "Tap to add" when empty. - Optional rows: muted "Optional" when empty.
- Subtitle counts "{n} required to fill" in amber; flips to the "ready" line when done.
- Save disabled (
btn-disabled+disabled) until required fields are set, thenbtn-primary. - Prefilled values are visibly marked until the user touches them (a source
badge, a
draftmarker) and are re-listed in the check-and-save pop-up — nothing auto-commits.
11. Contract rules (non-negotiable) — the hidden-form data contract
- UI-only carry-over: POST
names,action,method,use:enhanceand all server validation stay byte-identical. The real form is hidden (class="hidden"), not removed, and is the source of truth the wizard and the summary are views over — never a second copy of the data. - A picker's visible input is not the POST field: the hidden
<input type="hidden" name=…>carries the id (this is whatCombobox.sveltedoes, and it also carriesdata-nameso a picker can be driven from outside). requestSubmit()on the hidden form is how a save starts;use:enhanceis what returns{ ok, id, docNo }.- No value exports from
+page.server.ts. - Important fields only: a wizard step is only for an important field. A
field is important when entering the data is compulsory AND no
default value is set for it. So a defaulted / automatic / owner-instructed
/ optional field is not a step:
- a system date or automatic value (a master default such as today, or last-used) is not a step;
- a field the owner has instructed as standing default is a default, so not a step — the signed example is other cost at 5.5%;
- a plainly optional field is not a step. A field that already has a step and stops meeting the test comes out of the wizard. A prefill the user must confirm still counts as important (nothing is set yet), and so does a suggestion-chip value — the signed example is the last used rate chip. A non-step field shows in the sectioned summary with its value, and a non-step field is edited in its own single-field popup: its summary row opens its own single-field popup. own single-field popup**. That popup is **not a substitute for the step, it is where a non-step field is edited — so nothing becomes un-editable: the read-only sectioned summary and the small single-field popups (§3, §4b) stay exactly as they are. "The wizard" means the one-field-at-a-time step sequence and nothing else. In doubt, ask the owner and take the most reasonable reading as the rollout's assumption rather than guessing silently.
- The full flow is the bible: every field
static/full-flow.htmllists for a form must be present in the form you built, with its listed mode. In plain terms: every field listed there must be present in the form you built, with its listed mode. That doc —static/full-flow.html, served at/full-flow.html, the EXHAUSTIVE flow × form × data-entry map, rows A1 onward, last column "ALL fields on the form — mode", read alongsidedocs/YARN-FLOW.md— decides the field list, not the screen you happen to be looking at. So each listed field is present as a wizard step when it is an important field (rule 5), and otherwise as a summary row that opens its own single-field popup. No field is dropped for being "not needed on this screen". If a field is missing from the form it is added in the same change with the column, migration and server read/write it needs, never deferred, and never by rewriting something already saved (append-only: corrections go through/api/amend). The rollout writes the list down — it names, per field, the full-flow name, its mode and its surface, as a wizard step or summary row/popup — so a missing field is visible instead of assumed absent, and §12 rule 6 is ticked off with that list attached. - Every step is connected: the wizard is a sequence of hand-overs, not a
stack of unrelated popups, so a field is entered once and carries forward.
So the flow is the default, never a compulsion — a field can be entered
directly from its summary row and attached to the step later, so nothing
forces the user down the whole flow. No re-keying: a later step never re-keys what an
earlier step already holds — it reads what the earlier step carried. The
interdependency map this follows is
docs/ARCHITECTURE.md§8.
12. Rollout checklist (per form)
- §11 rule 6 ticked off, and with it the §11 rule 5 important-fields
decision — one pass over the field list:
- §11 rule 6 — walk
static/full-flow.htmlfor this form and, in the PLANNING entry, list its full-flow name, its mode and its surface — a wizard STEP or summary ROW/popup — for every field. A field you cannot name there is a field the form is missing. - §11 rule 5 — state the important-fields decision per field (compulsory to enter AND no default value set), and list the steps and the non-steps separately: every field is a wizard STEP or summary ROW/popup, and the two lists are listed separately in the PLANNING entry so a reader can see the split without re-deriving it.
- §11 rule 6 — walk
docsfirst: PLANNING log entry naming the form, the field→step split and the prefilled-exclusions list (§15). For each step, the hand-over is written down: Previous step + its back target · Next step + the forward button · What it carries forward · How a direct entry gets attached later (§11 rule 7).- Build the
<route>/new/+page.sveltein the §2 order: title → dynamic hint → summary sections → Clear/Save → hidden form → wizard → field pop-ups → check-and-save → saved pop-up. - Reuse
Combobox.svelte,NumberWheel.svelteand#lib/feedback.js— do not rebuild them; do not fork a popup shape (§4b). - Wire
use:enhanceto open the saved pop-up:success(), hold ~5 s, thengoto('<list>?created=<id>'); add the?created=highlight$effecton the list if it isn't there. - Keep the server
createreturning{ ok, id, docNo }(drop the redirect). - List the previous records per §16 (4–5 columns, no horizontal scroll, row pop-up carries all the form's fields).
- Phone-width evidence list — check every line on the dev URL at
390 × 844 (or 360 × 640) with a touch device emulation or a real phone:
- the form opens with the wizard already open on its first unanswered step (and on the summary when every step is already answered — C300), over the real form;
- typing in any field is visible above the keyboard (scrolled to centre), and results appear above the input — checked with the keyboard is actually open (real focus, not a simulated viewport); No desktop-only shortcut may stand in for this on any form;
- a field popup opened from the summary docks above the keyboard and the shell header is hidden;
- the wheel drags mid-scroll without the typeable input stealing the gesture, and ticks;
- coin taps fill and stay (search picks advance; qty/rate coins don't);
- the pick shows as a removable chip and the ✕ clears it;
- Save is disabled until required fields are set, then primary;
- Save → check-and-save → its Save → saved pop-up: tick + the record's number + details, held about 5 s, then the table with the new row highlighted;
- every back button returns to the screen it came from;
- a half-filled form is offered as a draft at the top of the table with Complete / Delete;
- sound + haptic on tap, tick and save;
- light mode, no horizontal scroll at phone width.
- Phone-width keyboard check (§9 — the C144 gate, on every form) — do it with the keyboard actually open (a touch device emulation or a real phone; No desktop-only shortcut — a mouse click proves none of it): typing is visible above the keyboard and scrolled to centre, the results being filtered are above the input, a field popup docks above the keyboard with the shell header hidden, and with the keyboard up the card is tall and pinned, the backdrop is opaque and the Back/Next footer is still there.
bash scripts/heavy.sh bun --smol test --timeout 30000 tests/<your>.test.ts, thenbash scripts/quiet.sh bun run check;bun run screensis a last resort — run it only when no narrower check can prove the change (C66); deploy viascripts/deploy.shon a deploy host only.- How to tell if a form is covered (C172) — the form is covered when its
static/full-flow.htmlrow is in the §13b coverage table with its route and its state filled in:✅ on the signed UIonly after someone opened the page and wrote the record down (C130),⏳ queued — Cnwhile its row is still open,⛔ no form to convertwith the reason. A flow row with no table line is not covered, and a queued row is never shown as live.
13. Live status
| Form | Status |
|---|---|
/leads/new |
✅ live — the origin of the pattern |
/quote/new, /quote/edit |
✅ live — the reference floor (not restyled by C151) |
/orders/new (+ its three type-fixed forms) |
✅ live — wizard + summary, §17 ledger |
/bookings/new, /tippans/new |
✅ live — summary + one-field pop-up + wizard |
/gatepasses/new |
✅ live — the shell is on the focus-entry pattern per C168 (402ba69): §2 the title over the summary, §3 the sectioned summary, §4 the per-kind one-field wizard, every POST name byte-identical behind the hidden ?/create form. One route, eight kinds, audited field by field against static/full-flow.html by C186–C193 and then re-audited against the flow CELLS by C201 — YARN_IN C186 → C201, DIRECT C187 → C201, BEAM_OUT C188, YARN_OUT C189, BEAM_IN C190, BEAM_REPAIR C191, BEAM_RETURN C192, WINDING C193 (C201 re-pointed six source/markup pins that had frozen on the pre-C201 shape: the showVehicle/showMillLot flags and YARN_IN's/DIRECT's served row lists) — while C194 (G2 transporter pickup) and C195 (G4 gate inward) are forms of their own, not kinds. Named rather than glossed: what is still open on this form is the §12 items 7–8 phone-width evidence (390 × 844, the keyboard actually open), which no gatepass row has recorded; §2–§4 themselves ship on all eight kinds |
/rates |
✅ live — built from this document alone (§12 rollout, PLANNING §327): read §2/§3/§4/§5/§7/§14/§16 and tests/c151-rates-focus-entry.test.ts, no other form's code. The three legacy tick carriers listed in §18 are the only hand-copies left |
13b. Full-flow coverage, A1 → C6 (C172)
static/full-flow.html rows A1 through C6 are every entry form the flow
has. This table is the whole picture: for each flow row, the form, its route and
its state. The last form in the rollout is C6, the beam card (corrected
2026-09-30 — it was C1). The row ids in the state column are the queue rows that
carry each conversion.
How to read the state column — a queued row is never shown as live.
✅ on the signed UI means the form is on the signed UI and someone opened the
page and wrote the record down (C130: proof by opening the page).
⏳ queued — Cn means the row is not live: it is queued, not done, and it
turns into ✅ only on that verification record. ⛔ no form to convert means
the flow row has nothing to type into, with the reason read out of the row itself.
| Flow row | Form | Route | State |
|---|---|---|---|
| A1 Lead (trading) | lead entry | /leads/new |
✅ on the signed UI — the origin of the pattern |
| A2 Quote (trading) | quote | /quote/new, /quote/edit |
✅ on the signed UI — the reference floor |
| A3 Sample REQUEST → dispatch (courier) | sample | /samples |
⏳ queued — C166 |
| A4 + subsequent events (Confirm — Trading SO / job-work contract), A4b production order (stock & sell) | the three type-fixed order forms | /orders/new (+ its three type-fixed forms) |
✅ on the signed UI — §17 ledger |
| A4c production order | production | /production/* (four shells over one shared component) |
⏳ queued — C167 |
| A5 Handoff / A6 Status | — | — | ⛔ no form to convert — SYSTEM-ONLY, no entry; status is derived from dispatch/fabrication events |
| B0 Beam-out intimation (planner) | gatepass | not built yet — the planner's own intimation form; the gate tap under it is C188, on the signed UI | ⏳ not built — C188 stopped the tap form asking the planner's numbers, and nothing converts B0 yet |
| B5b Yarn outward gatepass | gatepass — planner selects, guard taps | /gatepasses/new?kind=YARN_OUT |
✅ on the signed UI — C189 (§5 step type 9): the form asks B5b's four things — the barcode taps, the party/weaver, the vehicle, the guard's taps — and the inward receipt's count / ticket / bags / cones / weights are not asked here (a static row says where they land: yarn in, or a movement when stock is weighed going out) |
| B0b Beam outward | gatepass — guard taps, zero entry | /gatepasses/new?kind=BEAM_OUT |
✅ on the signed UI — C188 (§5 step type 9): zero entry, so the form asks B0b's list only — the beams the intimation pre-assigned, the actual date, the transporter — and B0's #beams / beam type / expected date / vehicle are not asked here (a static row says where they land) |
| B1 Yarn booking (trading) | yarn booking | /bookings/new |
⏳ queued — C162 |
| B2 Yarn inward | inward | /inward |
⏳ queued — C169 |
| B3 Stock ledger | — | — | ⛔ no form to convert — a derived ledger, no entry |
| B4 Allocation + reservation | — | — | ⛔ no form to convert — it lives on the lot record page, not its own entry form |
| B5 Movements (stock that moves without a buy/sell doc) | movements | /movements (7 kinds) |
⏳ queued — C170 |
| B6 Weft inward (job-work, parts) | — | — | ⛔ no dedicated form — the gatepass inward path covers the receipts; the movements form (C170) carries the movements |
| B7 Stock take / cone balance | stock take | /lots/stocktake |
⏳ queued — C171 |
| B8 Yarn hishob | — | — | ⛔ no form to convert — system-derived |
| C1 Warping + sizing tippan | tippan | /tippans/new |
⏳ queued — C110 |
| C2 Beam/warp outward (unified) | — | — | ⛔ no form to convert — guard-tap only, with a vehicle number and no other fields |
| C3 Sizing receipt (two steps) | sizing | /sizing |
⏳ queued — C173 |
| C3 step 1 — the beams-in gatepass | gatepass — guard taps + the three captured values | /gatepasses/new?kind=BEAM_IN |
✅ on the signed UI — C190 (§5 step type 9): the guard's beam taps on the physical ids (flangeNo first) plus mtrs/beam · ends · quality off one sizing-slip scan that only prefills, each row confirmed by the user; the full hishob is C3 step 2 and a static row says so |
| C4 Out-house beam ledger | — | — | ⛔ no form to convert — a register of automatic per-beam state, entered through the beam card (C6) |
| C5 Winding | winding | /winding |
⏳ queued — C174 |
| C5b Winding-yarn outward gatepass (sizing → winder) | gatepass — clerk issues, guard taps | /gatepasses/new?kind=WINDING |
✅ on the signed UI — C193 (§5 step type 9): the yarn leaving sizing for the winder, the doc C3 step 2 promises — the winder, the sizing/warping set no, bags/cones, gross + net at the gate and the transfer year; the receipt's own figures (empties, ring wt, received net, the net/gross difference, the set closing) are asked once, on /winding (C5), and a static row says so |
C6 Beam card (C6b beam reassign / sizer→sizer transfer rides on /beams/[id]) |
beam card | /beams ?/create |
⏳ queued — C175 |
| G0 Gatepass (own/party yarn) | gatepass — the inward yarn receipt | /gatepasses/new?kind=YARN_IN |
✅ on the signed UI — C201 (§5 rule 6, read against the cell): the form asks G0's own list — GP no 【A】 · date 【P】 · source trader/party 【S】 · target our-godown/premises/named-sizer 【T】 · count 【S】 · ticket/mill 【S】 · bags 【N】 · gross/net 【N】 · cones/bag 【P】 · endorse-to 【T】 · the status ladder 【A】 · GP image 【PH】 · confirmation evidence 【PH/OCR】 — and each displaced field carries a static row naming its real row (no vehicle and no transporter: G4 /inward + G2 /transporter; no typed total cones: B6 and C5b count them, and bags × cones-per-bag is the figure G0 asks). The value slots carry the owning flow row and the sentences ride staticNote (§3/§18), so §18's six named value-slot sentences are unchanged |
| G0b Direct-delivery intimation | DD intimation (precursor) | /gatepasses/new?kind=DIRECT |
✅ on the signed UI — C201: the intimation asks its own cell only — count/bags expected 【N】 (typed, unlike G0's 【S】 pick) · destination premises/named-sizer 【T】 · expected date 【N】 · status 【A】 · the close-out, sizer WhatsApp confirm+photo 【PH】 + post-delivery GP counter-copy signed 【T】 (C187) — with a static row naming G0/B1 for the mill lot and the cones per bag that an intimation does not carry. Nothing commits on open |
| G2 Transporter pickup (the due-for-collection list) | transporter pickup | /transporter ?/pick |
✅ on the signed UI — C194: the list is built automatically from every open outward intimation (no filing step, no second status), and each row's pickup is §3 + §4 over a HIDDEN POST form — which beams/boxes to lift read-only, then picked/partial 【T】 · date 【P】 · note · photo 【PH】 one per pop-up, committed by the single "Picked ✓" tap. No wizard: no field here is important enough to be a step, and the pop-up sits items-end so the phone keyboard never covers it |
| G4 Gate inward (the arrival at the gate) | gate arrival — over the inward queue | /inward ?/markPicked |
✅ on the signed UI — C195: G4's entry is the gate arrival on /inward, not the gatepass form (that is G0's intimation). The inward code 【SC】 heads the board as a LOOKUP — it finds the delivery and brings it up, no new POST name or column; the vehicle no is the plate field (mode: 'plate', §17b): camera primary 【SC】, typed fallback 【N】, filling the same vehicle name behind the widget; the barcodes print themselves 【A】, so there is nothing to fill; partial/all from pickup (G2) is the row's own status badge. The plate field is also the gatepass form's vehicle row on the kinds whose flow cell lists a vehicle (B5b, C2b, C5b, B5 — /gatepasses/new?kind=YARN_OUT and the beam/winding legs); C201 took it off G0 and G0b, whose cells list neither a vehicle nor a transporter, and a static row on both now names G4 and G2 as their rows. The arrival tap keeps posting id · vehicle · arrivedBags |
Every route in the "queued" rows was opened on real data by bun run screens
before it was called a form, so no row above names a screen that does not exist.
The table is status only: it changes no app code, schema or form.
14. The whole lifecycle, leg by leg
The owner's reverify flow (2026-09-30). Every form behaves the same way:
- Nav pane → table. The navigation pane lists forms by name; tapping a form's name opens its previous-records table page (§16) — never the entry form directly.
- New record. A New record button on that page opens the new form
with the wizard already open on its first unanswered step (
wizOpenstartstrueonly while a step still needs filling — C300), and the real, original form stays mounted behind the wizard as the source of truth — the wizard steps and the summary are views over it, never a second copy of the data, so the hidden POST names carry exactly what the summary shows. - Save → check-and-save. Tapping Save opens the check-and-save pop-up (§7.1): every carried value, one line each, the unconfirmed ones tappable. Its Save is disabled until they are confirmed; Back to edit returns to the form — the screen the user came from, not a dead end.
- Saved. The action returns
{ ok, id, docNo }; the saved pop-up shows the important details + the new record's number + the animated tick, held about 5 s (§7.3), thengoto('<list>?created=<id>'). - The table again, with the new row highlighted temporarily
(
row-highlight, §7.4). - The flow reverses with the back button: form page → table, check-and-save pop-up → form, saved pop-up → table (via the URL), field popup → the screen that opened it. Every back button returns to the screen it came from.
- Drafts. A half-filled form is offered as a draft at the TOP of the table page with Complete (back into the form, values intact) or Delete. A draft is never committed on its own — capture-first, the user confirms.
15. Three-way consistency (wizard ⇄ background form ⇄ check-and-save)
- The wizard, the background (hidden) form and the check-and-save pop-up show the same fields: every field of the record is on the summary and in the hidden form, and is asked in the wizard unless it is on the written prefilled-exclusions list. A field that exists in one and not the others is a defect, not a variant.
- The one stated exception: prefilled fields are not asked in the wizard. A field deliberately prefilled by default — payment dhara, brokerage, or a value prefilled from that quality + party's last order — is shown read-only on the summary, edited in the background form, and never asked as a wizard step. It still appears in the check-and-save list.
- Each form's exclusions are written down in its PLANNING log entry (no unwritten folklore). Current written lists: orders §250/§253 (payment dhara + slab, brokerage, pass-on, history qty/rate, quote-carried construction) · quote §254 (lead-prefilled steps) · bookings §319.
- Section merge and field order are owner decisions and are mirrored exactly.
16. The previous-records table format (every form that lists records)
- 4–5 columns that actually matter. For orders: quality short name, party short name, quantity, rate (+ status). For quotes: quote no, party, quality, rate. Never more — a wider table is a design failure, not a feature.
- No horizontal scroll at phone width (
overflow-x-autoon a table that needs it is a bug). Truncate withtruncate/tooltip; shared piece:SmartTable.svelteandquoteListUtils.tscolumn helpers. - The action buttons and every remaining field live inside the row pop-up. The row itself stays a summary; tapping it opens the pop-up.
- The pop-up carries all the fields of the original form, not a short selection — the pop-up and the entry form must be able to show the same record completely.
- The row's edit / delete / chat / share actions are as specified in their own rows (C140 context-cost, C152, C153) — this row does not change them.
- Rows may carry a status pill and the new-row flash (
row-highlight, §7.4).
17. Quote → orders correction ledger (the UI corrections made on the quote pages)
Every UI correction the quote pages earned over the rounds, where it lives, and
its status on the quote pages and on the three order forms
(/orders/new — trading / job-work / production-order, all rendered by
OrderEntryForm.svelte). A correction either exists on the order forms
through the same shared component/rule, or it is named below as still
missing with its own queue row — no entry is vague. Re-check this table when
you touch either side; tests/c151-focus-entry-doc-copy-exact.test.ts pins the
statuses that must stay true.
| # | Correction (quote pages) | PLANNING | Shared piece it lives in | Quote pages | Three order forms |
|---|---|---|---|---|---|
| 1 | Wizard + sectioned read-only summary (leads pattern) | §211–§216 | §2/§3 markup | ✅ NewSummary.svelte |
✅ OrderEntryForm.svelte §3 markup |
| 2 | Data-entry affordance: *, Tap to add, Optional, required count, Save gate |
§219 | §3 + §10 | ✅ | ✅ |
| 3 | Tappable summary rows, centre card, reliable field tap | §212–§213 | §4 wizard markup | ✅ | ✅ |
| 4 | Field popup spec: keyboard dock + hide shell header | §251 (doc §4b) | kbOpen effect + wizard-open class + z-[60] |
✅ DharaPopup, TermsPopup, YarnPopup, AdjustPopup, QualityPopup |
✅ fixed by C151 — pop-ups now z-[60], dock on kbOpen, {#if !kbOpen} chrome, and the effect's guard is wizOpen + dharaPop + slabPop |
| 5 | Pick-as-chip + results above the search input | §234 | Combobox.svelte (chip → results → input) |
✅ quote's own step markup | ✅ fixed by C151 — the shared Combobox.svelte was rendering results below the input; orders/gatepasses get the rule from the component |
| 6 | Coins fill-and-stay (qty/rate), search picks advance | §234 | §5 step recipes | ✅ | ✅ fixed by C151 — the order qty coins filled and advanced; they now fill and stay like the rate coins (tap() + fill, no wizNext()) |
| 7 | Amount wheel contract: slot geometry, typeable centre, mid-scroll drag, slot tick | §220–§226 | NumberWheel.svelte + §6 |
n/a — the quote pages have no wheel step (rate enters through YarnPopup + the rate board) |
✅ same component, no copy |
| 8 | qty stepper: hold-to-repeat, keyboard blur first, no leading zeros | §220–§222 | §5 stepper recipe (startHold/stopHold) |
✅ quoteNewState.svelte.ts (holdStart/holdStop) |
✅ fixed by C151 — the order wizard and the background form ± are now onpointerdown/onpointerup/onpointerleave/onpointercancel hold with blur() first, tabindex="-1" and a prevented context menu; the bumpQty step size is unchanged |
| 9 | Saved pop-up: tick + record number + details, held ~5 s, then goto(<list>?created=…) + row-highlight |
§216, §326 (doc §7) | NewSavedPopup.svelte + §7 + row-highlight |
✅ (5000 ms, the doc's standard) | ❌ missing — the order list already highlights ?created=, but the form never shows a saved pop-up or navigates: queue row Q-C151-3 |
| 10 | Wizard skips steps carried from an earlier record (prefilled exclusions) | §253–§254 | §15 written exclusions list | ✅ | ✅ (wizardStepIndexForField) |
| 11 | Back from check-and-save / from a field popup returns to the screen it came from | §258 | §14.6 | ✅ | ✅ ("Back to edit" in the review sheet) |
| 12 | Body scroll locked while the wizard or a popup is open | §225 | §4 effects | ✅ | ✅ (guard widened by C151) |
| 13 | Dictate-first step that only prefills and marks fields for confirmation | §235, §242 | §5.0 + OrderDictation.svelte |
✅ | ✅ OrderDictation.svelte |
| 14 | List + entry separation, add opens a popup with all the fields | §162, §168, §240 | §16 + RecordDialog.svelte |
✅ QuoteListTable.svelte |
✅ /orders list + /orders/[id] dialog |
| 15 | Tap feedback on every entry action | §219 | #lib/feedback tap() |
✅ | ✅ pinned by C151 — every order entry action reaches tap(): the wizard picks (wizPickLead, wizPickQuote, wizPickParty, wizPickDesign, pickDhara, pickSlab), every coin (qty in both surfaces, rate, broker, qty-unit), the startHold stepper repeat and the tappable summary row (§3's onclick) |
Named follow-ups: Q-C151-3 the saved pop-up (number + details + tick, ~5 s,
then /orders?created=) on the order form — it needs the order create action
to return { ok, id, docNo } instead of redirecting, which is an action change
this row's MUST NOT forbids · Q-C151-5 fold the three legacy tick carriers
into SavedPopup.svelte (§18) · Q-C151-6 move the shared
focus/FocusEntry.svelte shell's saved-pop-up close from 1500 ms to §7's 5000
ms (§18) — that one is the shell's behaviour change, owned by C166/C168/C170/
C173. Q-C151-4 is closed: the leads and quote carriers now hold the
documented 5000 ms. Each is a correction to the named screen only; the quote
pages are the floor and are not restyled by any of them.
17b. G4 Gate inward — the plate field and the inward code (C195)
G4's entry is the inward gate screen /inward, not the gatepass form.
static/full-flow.html G4 reads "vehicle no — CAMERA OCR of the number plate
【SC】 (typed fallback 【N】); future: driver FACE/biometric as the door control
(guard eliminated — vision) 【TBD】; scan inward code 【SC】; barcodes print auto
【A】; partial/all already known from pickup (G2) 【A】" — four things at the
arrival, which is /inward's board (C169's focus-entry receipt opens from
the same rows). What lives where:
| G4 field | Home | Shape |
|---|---|---|
| vehicle no — plate OCR 【SC】 + typed fallback 【N】 | /inward's arrival tap and /gatepasses/new's vehicle row |
mode: 'plate' |
| inward code 【SC】 | /inward, head of the board |
PlateScan variant="code" — a lookup |
| barcodes print auto 【A】 | /inward, stated on the gate leg |
nothing to fill, no field, no POST name |
| partial/all from pickup (G2) 【A】 | the row's own status badge | the ladder G0 already carries |
- A camera-first read is a MODE, not a nicer input. G4's vehicle was
mode: 'text'+scan: true— a text field with a scan affordance, which is free typing with a camera button under it.plateis its own mode in the shell and its own widget (#lib/components/gate/PlateScan.svelte): the camera target is primary and the typed entry is the fallback behindType it instead, exactly as G4 words the 【SC】/【N】 pair.scan: truestays on the field so a scan still routes on the row it sits on (C190's rule), and the read value is shown plate-shaped with a "check it" line — OCR only ever FILLS, the user confirms, nothing is committed. - An inward code is a LOOKUP, not a value. It is the code printed on the
gatepass the transporter carries, so scanning it finds the delivery on the
board and brings that row up. It therefore adds no POST name, no column and
no migration — and the arrival tap keeps posting
id·vehicle·arrivedBagsto?/markPickedexactly as before, with the plate widget filling the samevehiclename. - 【A】 is a sentence, never a field. The barcodes print themselves off the
scanned delivery, so there is nothing to fill and nothing to post: no barcode
input exists on either surface, and
barcodeson the B2 receipt summary (C169) stays astatic【A】 row. - A required field that moves behind a widget keeps its gate. The vehicle was
requiredon the arrival tap's input; with the input folded into the plate widget the arrival button is disabled until the plate is read or typed, so an arrival is still never recorded without its vehicle. The server's own rule (empty vehicle →null) is untouched. - 【TBD】 stays 【TBD】. G4's "driver FACE/biometric as the door control (guard eliminated — vision)" is not built by this row: no face, biometric or door-control code exists on either surface.
18. Named exceptions (a rule that cannot be shared yet)
The yarn booking's check-and-save has NO tick gate — its Save saves (C185, the owner's "why the section of needs your tick — save blocked in check and save page on yarn booking? remove it it is not letting me save the page"). §7.1's rule ("Save is disabled while anything is unconfirmed") is the pattern and it still holds on the three order forms (
OrderEntryForm.svelte: theTo confirm (n)group +Save order · n to confirm) and on every other entry form that adopted it. It does not hold on/bookings/new, and the reason is specific, not a preference: a booking opened from a prefill (procure?yarnId&bags, the order forward-link?orderId=, the last booking of the same product) is born with every carried field unconfirmed, so the group counted the whole form; the sheet carried no ✓ of its own (the mark-only chip lives on the summary rows, outside the sheet), and a group row was built without itsstep, so tapping it opened the section's first step — a user inside the sheet had no way to clear the block and a Save that did nothing. What the form keeps: the prefilled badges on the summary (every carried value names its source and stays changeable), the sheet listing every field, a row jumping to that field's own popup, andgate()still routing a bare Enter through the sheet first — nothing auto-saves (the booking is written only when the user presses the sheet's Save,?/createand its validation untouched). Owner approvals (/api/approve,/approvals) are a different control and are unaffected. Pinned bytests/c185-booking-no-tick-gate.test.ts; removing the gate from the other forms is its own row and must be said out loud, because it removes a confirmation step before a committed document.Combobox.svelteshows the picked label in the search box as well as in the removable chip. The chip is the removable pick (§234b); the box keeps the label so a prefill (quote/lead/draft) is visible where the user is typing. One component, both surfaces — not a second picker.The order qty/rate stepper is a stepper, not a wheel with parts — an order rate is one number (
33.5), so a singleNumberWheelplus a typeable input is the correct composition; the two-part ₹/paise split stays a leads/quote concern.Wizard "Done" only closes the wizard; the save leg is always the bottom bar's Save → check-and-save (§7), so a form never invents its own second save path.
The quote pages render no wheel step at all (their rate enters through
YarnPopup+ the rate board), so §6 cannot be pinned against them. They are the floor and are not restyled here; the wheel contract is pinned against the shared component itself and against the forms that compose it.The tick has one shared carrier, the shared shell's own copy, and three legacy hand-copies.
src/lib/components/SavedPopup.svelteis what a new form imports (§7.3);focus/FocusEntry.svelte(the shell C166/C168/C170/C173 built for the gatepass, movement, sizing, winding and sample forms) still carries its own copy of the tick markup inline, andquote/NewSavedPopup.svelte,booking/BookingSavedPopup.svelteandleads/new/+page.sveltestill ship the identical markup from beforeSavedPopupexisted, byte for byte. Folding all four intoSavedPopup.svelteis Q-C151-5 — this row touches no quote-page file and no shell's behaviour. A further hand-copy is the defect the pin catches:tests/c151-rates-focus-entry.test.tsnames the exact carrier set.The saved pop-up's hold is 5000 ms as documented (§7), and every client-side carrier now ships exactly that:
quote/new/quoteNewState.svelte.ts,leads/new/+page.svelteandrates/+page.svelteeach hold 5000 ms (Q-C151-4 closed — the leads and quote carriers moved onto it when C155 and C156 rolled the lifecycle out; the quote pages are the floor and they are at the standard, not below it).One named exception, with its reason: the shared
src/lib/components/focus/FocusEntry.svelteshell still closes its saved pop-up after 1500 ms —setTimeout(() => onclose?.(), 1500)— and its five consumers (gatepasses, movements, sizing, winding, samples) are pinned at that number by their own rows' gates (C166/C168/C170/C173's row tests). Moving the shell to §7's 5000 ms is Q-C151-6: it is that shell's behaviour change, owned by those rows, and this documentation row does not make it. The number is written here so the gap is visible, not folklore.How the holds stay true:
tests/c151-focus-entry-doc-copy-exact.test.tsreads every carrier's real hold out of the code (quote state, leads, rates, the shell) and requires this document to name that number. So a carrier cannot move off its documented hold without failing the suite, and the doc cannot claim a hold the code does not ship.The six value-slot sentences that stay (§3's rule has named exceptions, C197). Five are the flow rows C188–C193 built and signed off, whose sentence is the delivery — each is proved over the served page by its own row test, which reads the sentence out of the row's markup — and one is a state chain with no verb:
beamPlanStatic— "Planned on the intimation (B0)" (C188, BEAM_OUT)yarnGoodsStatic— "Count, ticket and weights (not this gatepass)" (C189, YARN_OUT)repairStatic— "Repair and its return (not this gatepass)" (C191, BEAM_REPAIR)hishobStatic— "Full hishob (not this gate tap)" (C190, BEAM_IN)windReceiptStatic— "Empties, ring wt, received net (not this gatepass)" (C193, WINDING)statusStatic—Entered — picked (partial/all) → delivered → confirmed → reconciledon every kind (C186/C187/C195 pin it byte-for-byte; it is the document's own state vocabulary, not an instruction). They live insrc/routes/gatepasses/new/+page.svelte. Moving any of them into a pop-up means re-proving the row that shipped it, so that is a new row against that row's test — not a quiet edit here. Every other static row (30 of them, on nine surfaces) is a value or a neutral marker; the sentences those rows used to wear ridestaticNotein their own pop-up and the flow behind them is indocs/PLANNING.md§28.
Anything added to this list needs a PLANNING log line in the same turn, with the reason — no folklore.
19. Not from this document
docs/FOCUS-ENTRY.md governs the entry pattern. The list/table page is
§16 here; costing, approvals, capture and dispatch keep their own rules
(COSTING.md, PLANNING.md, UIPATTERN.md). Nothing in this row changes a
POST name, an action or a server validation.
20. Master-backed fields — the create row and the read suggestion (C319–C321)
Any field whose values come from a master (yarn, mill/ticket, party, godown / pickup location, transporter, count, type) must let the operator create the missing value from the field that needed it. Owner, 2026-10-29: "Suppose you type the yarn and there is no value matching in the master, drop down should show a + sign in front of the typed value to add it to the master."
- One row at the bottom of the pick list. When the typed (or read) text
matches no master value, the list's last row is
+ <the text>— a daisyUIbtn btn-ghost btn-sm w-full justify-startinside the popup, never abadgeand never mixed in with real values. Real matches always rank above it. - The chip. A value the reader produced and could not match appears as the
same
+ <value>chip in that field's suggestion row, so "the OCR found something we do not know" is visible without opening the picker. - The three party-tab masters (C320). The pickup godown / location, the
trader and the transporter are all party rows, told apart by
category: a location is aGodownorSizingrow, a trader a plainParty, a transporter aTransport. Their chip carries?tab=party&new=<name>&category=<Godown|Transport|Party>&field=<field>&returnTo=<form>, and the save comes back as?field=&partyId=&party=so the field it left is the field it fills. A value the master already carries (case- and space-insensitive) shows no chip. - Tapping opens the master, prefilled.
?tab=<master>&new=<value>on/mastersopens that master's add dialog with the value in its first field.src/lib/masterCreate.tsbuilds and validates the link (safeReturnTokeeps the return target a path on this app), the value ridesnew=with the yarn's mill in its ownmill=, andreturnTo=is the link that opened the master. The chain follows the master's own dependencies: a new yarn brings its mill as a chip on the yarn master's mill field, which opens the mill/ticket master and returns; the same for count and type once C311/C312 land. - It comes back selected. Saving the master returns to the form it was
opened from with the new entry already chosen —
?yarnId=<id>&ticket=<mill>on the gatepass, whose yarn row then shows that master row as its value (and, for a typed value, the typed text as the selection). The form's POST names, validation and append-only behaviour are untouched — this is one extra row in a list plus the prefill parameter. - Every tab a field names prefills (C321).
?tab=reaches the yarn, mill/ticket, party, count and type masters, and the save carries the generic set?field=<field id>&value=<the option id>&label=<the name>&master=<tab>beside the C319/C320 names;FocusEntryapplies it once, so a pick takes the option id and a typed row takes the name. The value is checked inloadagainst the master with the same case/space rule as the field — a value the master already carries opens no dialog and writes nothing. - No OCR required. The create row exists on every master-backed field whether the value came from a photo, a dictation or the keypad: the gap is between the value and the master, not between the reader and the form.
- Nothing is auto-created, still. The master save is the user's tap, and the gatepass saves as its own record (capture-first, PLANNING §15).
- The child opens in a modal over the form (C360). A create chip never
navigates:
FocusEntry'soncreate(href, fieldId)makes it a button that opensCreateModal.svelte— a full-height sheet at z-80 (above the wizard's z-40 and the popup's z-60) holding the child route in a same-origin iframe, with← Back to the form,✕and Escape.#lib/createModal.tsis the protocol: the child URL is rebuilt withoutreturnTo=(and thefield=that link carried moves onto the URL) plusmodal=1; the child posts{field, value, label, master}on save or a cancel when it is backed out of. The parent's form is never unmounted, so every value, the photo in flight and the wizard page stand; the field that opened the modal takes the entry (applyCreated, the same landing the URL return uses). Withoutoncreatethe chip is still the plain link — nothing else changes.