@lotics/ui 29.3.0 → 30.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +23 -1
- package/MIGRATION.md +23 -0
- package/docs/ai_patterns.md +34 -3
- package/docs/catalog.md +6 -2
- package/docs/composition.md +80 -5
- package/docs/data_entry.md +5 -1
- package/examples/tpl_item_list.tsx +39 -6
- package/examples/tpl_record.tsx +187 -6
- package/package.json +1 -1
- package/src/agent_run.tsx +8 -1
- package/src/agent_run_pane.tsx +57 -12
- package/src/choice_list.tsx +6 -3
- package/src/clarify.tsx +6 -2
- package/src/clarify_wizard.tsx +4 -2
- package/src/control_surface.ts +25 -0
- package/src/dialog.tsx +47 -8
- package/src/files_editor.tsx +1 -1
- package/src/floating_action_bar.tsx +33 -4
- package/src/locale.tsx +3 -0
- package/src/mime.ts +19 -1
- package/src/pressable_row.tsx +13 -9
- package/src/table_fit.ts +16 -4
package/AGENTS.md
CHANGED
|
@@ -16,7 +16,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
16
16
|
|---|---|
|
|
17
17
|
| [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
|
|
18
18
|
| [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (the `FilesEditor` COMPOUND — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
|
|
19
|
-
| [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md)., the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
|
|
19
|
+
| [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md)., the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
|
|
20
20
|
| [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other), banded cards, register vs inset rows, master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), color discipline, typography, whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
|
|
21
21
|
| [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget). |
|
|
22
22
|
|
|
@@ -89,6 +89,28 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
89
89
|
back — which is what makes scanning twenty records unaffordable. `TableRow`'s `detail` +
|
|
90
90
|
`expanded` reveal the detail beneath the row; keep the drawer for a heavy form.
|
|
91
91
|
→ [catalog.md](./docs/catalog.md).
|
|
92
|
+
- **ONE LEFT EDGE — a row that paints a wash BLEEDS it, and never insets its content.**
|
|
93
|
+
`marginHorizontal: -ROW_WASH_BLEED` absorbed by an equal `paddingHorizontal` is net zero, so
|
|
94
|
+
a row's content sits on its container's own edge — level with the headings, labels and prose
|
|
95
|
+
beside it — while the hover/selection wash still breathes by growing into the parent's
|
|
96
|
+
padding. `PressableRow` (all variants), `Table`, `ChoiceList`, `ListItem` and `FileRow`
|
|
97
|
+
already do this, so a screen composed from the kit aligns with **no per-site padding at all**.
|
|
98
|
+
The moment one component insets instead, every neighbour has to be hand-padded by the same
|
|
99
|
+
amount to catch up, and that compensation spreads to each new surface built beside it — which
|
|
100
|
+
is how a `SummaryLine`, a `Clarify` question and a bare table's footer each acquired a
|
|
101
|
+
padding they should never have needed. **An indent is legible only when something VISIBLE
|
|
102
|
+
occupies it** — a checkbox, an ordinal, a diff mark. An empty indent is a defect.
|
|
103
|
+
- **The one exception is a self-contained OVERLAY.** A menu or a selector's popover
|
|
104
|
+
(`MenuListItem`, `OptionList`) has nothing outside itself to align with, so its rows keep
|
|
105
|
+
plain padding. Bleeding there would push rows past the popover's own edge.
|
|
106
|
+
- **A slot holding another component's part takes THAT component's constant**, never a
|
|
107
|
+
hand-picked number that happens to look close: `AgentRun`'s dot column was 18 while the
|
|
108
|
+
`Marker` it holds is `NODE` (24), so a 24px dot centred in an 18px column put the whole run
|
|
109
|
+
rail 3px left of every other band in the dialog. Import the owner's constant.
|
|
110
|
+
- Inside a `Dialog`, every band reads `dialogGutter(small)` — header, scroll area, footer and
|
|
111
|
+
any pane a caller drops in. A hand-copied `small ? 16 : 24` is a fifth copy of a number
|
|
112
|
+
that has to agree with four others.
|
|
113
|
+
→ [composition.md](./docs/composition.md).
|
|
92
114
|
- **Documents get a LIST; images get a GRID.** The discriminator is what identifies the file to
|
|
93
115
|
the person looking for it. A PDF, a Word doc and a spreadsheet are identified by their NAME —
|
|
94
116
|
their thumbnails are a grey page with grey lines on it, identical to every other grey page —
|
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,29 @@ Breaking changes, newest first — normally per major, plus the rare minor that
|
|
|
4
4
|
anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
|
|
5
5
|
this file exists only to move an app from one release to the next.
|
|
6
6
|
|
|
7
|
+
## 30.0.0 — a washed row bleeds; content sits on the container's edge
|
|
8
|
+
|
|
9
|
+
No API changed. Every register, table and choice list **moves its content 20px
|
|
10
|
+
left** onto its container's own edge, because `PressableRow` (all three
|
|
11
|
+
variants), `ChoiceList` and `Table` now bleed their hover/selection wash outward
|
|
12
|
+
instead of insetting content to make room for it. `ROW_WASH_BLEED` (8) in
|
|
13
|
+
`control_surface` names the rule.
|
|
14
|
+
|
|
15
|
+
**What you may have to remove.** If a surface of yours was hand-padded to line
|
|
16
|
+
up with a register or a table, that compensation is now a double-correction and
|
|
17
|
+
will sit 20px out. The shapes to look for:
|
|
18
|
+
|
|
19
|
+
| Compensator | What to do |
|
|
20
|
+
|---|---|
|
|
21
|
+
| A `SummaryLine`, caption or toolbar wrapped in `paddingHorizontal: 20` to match the rows below it | Drop the padding |
|
|
22
|
+
| `CardFooter` used as a **bare table's** footer (no `Card` around it) | Use a `Divider` + a plain row; `CardFooter` carries a *card's* 20px interior gutter |
|
|
23
|
+
| A heading or question hand-padded 8 to match a `ChoiceList`/`Clarify` block | Drop the padding |
|
|
24
|
+
| Anything reading `ROW_GUTTER` for its own spacing | It is 0 now — it means "the table's content gutter", and the wash no longer lives in it |
|
|
25
|
+
|
|
26
|
+
Tables also fit slightly MORE: the fit math stops reserving 40px of horizontal
|
|
27
|
+
padding, so a column that used to drop at a given width may now survive. Nothing
|
|
28
|
+
that was visible becomes hidden — the change only ever un-drops.
|
|
29
|
+
|
|
7
30
|
## 29.0.0 — review surfaces compose from atoms
|
|
8
31
|
|
|
9
32
|
`ChangeReview` and its family are DELETED, replaced by three layout-free pieces. The
|
package/docs/ai_patterns.md
CHANGED
|
@@ -133,6 +133,30 @@ transition — never leave a live button waiting on a network round-trip:
|
|
|
133
133
|
`useWorkflow` calls are NOT deduped (rapid distinct calls are legitimate), so a workflow CTA
|
|
134
134
|
must disable itself (`loading`) while pending or a double-click writes twice.
|
|
135
135
|
|
|
136
|
+
## Stopping — the exit is part of the trigger
|
|
137
|
+
|
|
138
|
+
The press being irreversible is a property of the BUTTON, not of the run. Every streaming surface
|
|
139
|
+
owes the operator a way out, because the reason to leave is usually visible in the first few
|
|
140
|
+
steps: it read the wrong document, it is grouping the wrong way, the brief was wrong. Without an
|
|
141
|
+
exit they pay for a result they already know they will discard.
|
|
142
|
+
|
|
143
|
+
- **`cancel`, never `abort`.** `useAgentRun` exposes both and they are not synonyms. `abort` stops
|
|
144
|
+
LISTENING — the run keeps executing server-side and lands in the session history. `cancel` stops
|
|
145
|
+
the run itself. A user-facing Stop wires to `cancel`; `abort` is for unmount.
|
|
146
|
+
- **Closing a dialog must CANCEL.** This is the one that leaks silently. The hook aborts on
|
|
147
|
+
unmount by design — right for a page you navigate away from and will come back to, wrong for a
|
|
148
|
+
dialog just dismissed, where the result is abandoned and every remaining token is spent on
|
|
149
|
+
nobody. Unmount is not cancellation; the close handler calls `cancel` itself.
|
|
150
|
+
- **Stop lands where the run STARTED**, not on a dead transcript — the intake, with the files, the
|
|
151
|
+
brief and the answers still there, because the operator stopped in order to change one of them.
|
|
152
|
+
It is the same destination the parked wizard's Cancel uses; only the verb differs, and it
|
|
153
|
+
differs truthfully — you STOP something running, you CANCEL something waiting.
|
|
154
|
+
- **The control is PINNED, in the footer.** A feed that follows its newest part scrolls a control
|
|
155
|
+
placed inside it away from the reader — exactly while the run is doing the thing they want to
|
|
156
|
+
stop. `AgentRunActions` renders Stop for you; a surface composing its own footers states it
|
|
157
|
+
directly ([`tpl_item_list`](../examples/tpl_item_list.tsx) and
|
|
158
|
+
[`tpl_record`](../examples/tpl_record.tsx) respectively).
|
|
159
|
+
|
|
136
160
|
## Show the work — `AgentRun`
|
|
137
161
|
|
|
138
162
|
`AgentRun` (`@lotics/ui/agent_run`) is a live feed of the agent's work as a TIMELINE. The prop is
|
|
@@ -424,8 +448,8 @@ Reach for it whenever an agent run happens inside a dialog — which is nearly a
|
|
|
424
448
|
const run = useAgentRun("intake"); // @lotics/app-sdk
|
|
425
449
|
<AgentRunScope> {/* wraps the Dialog, like ClarifyWizardScope */}
|
|
426
450
|
<Dialog …>
|
|
427
|
-
<AgentRunPane run={run} onCancel={
|
|
428
|
-
<DialogFooter><AgentRunActions run={run} /></DialogFooter>
|
|
451
|
+
<AgentRunPane run={run} onCancel={back} /> {/* content */}
|
|
452
|
+
<DialogFooter><AgentRunActions run={run} onStop={back} /></DialogFooter> {/* actions */}
|
|
429
453
|
</Dialog>
|
|
430
454
|
</AgentRunScope>
|
|
431
455
|
```
|
|
@@ -438,8 +462,15 @@ Submit, leaving a live `awaiting_input` run readable and unanswerable until it e
|
|
|
438
462
|
also swaps `FollowScroll` for `DialogScrollArea` on the park, so the question opens at the top
|
|
439
463
|
rather than wherever the feed was scrolled to.
|
|
440
464
|
|
|
465
|
+
**Mount `AgentRunActions` wherever the run drives the surface — never gated on one phase.** It
|
|
466
|
+
holds the wizard's verbs while a question is up, **Stop** while the run streams, and nothing once
|
|
467
|
+
it settles; the RUN decides, which is the whole point of handing it the run. A host that names a
|
|
468
|
+
single phase out here duplicates that decision and gets it wrong the moment the bar learns a new
|
|
469
|
+
one — which is precisely how two streaming phases ended up with an empty footer and no way to
|
|
470
|
+
stop.
|
|
471
|
+
|
|
441
472
|
**`run` is a SHAPE, not an import** (`AgentRunLike`: `status`, `parts`, `pendingChoice`,
|
|
442
|
-
`answerChoice`, `error`; `AgentRunQuestion` names what `pendingChoice` carries, so a template or
|
|
473
|
+
`answerChoice`, `cancel`, `error`; `AgentRunQuestion` names what `pendingChoice` carries, so a template or
|
|
443
474
|
test declares one directly — an option is a label + description and the answer's value IS the
|
|
444
475
|
label, which is the `ask_user_choice` wire shape, so nothing maps between two question types). `useAgentRun()` satisfies it structurally — `@lotics/ui` never depends on
|
|
445
476
|
`@lotics/app-sdk` — which also means a template or test can hand it a plain object and exercise the
|
package/docs/catalog.md
CHANGED
|
@@ -1333,8 +1333,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1333
1333
|
honestly-declared type is returned untouched; an *uninformative* one (`""`,
|
|
1334
1334
|
`application/octet-stream` and friends — what an OS clipboard or drag hands
|
|
1335
1335
|
over) is resolved from the filename extension instead, falling back to the
|
|
1336
|
-
declaration when the extension is unknown too.
|
|
1337
|
-
|
|
1336
|
+
declaration when the extension is unknown too. Documents, images and A/V; on
|
|
1337
|
+
every extension it knows it returns exactly what the SERVER stores, pinned by
|
|
1338
|
+
`src/mime_parity.test.ts`. The server reads a far larger table, so an `accept`
|
|
1339
|
+
naming a MIME pattern (`audio/*`) for an extension outside this one drops a
|
|
1340
|
+
file the server would have kept — write that `accept` as an EXTENSION
|
|
1341
|
+
(`.dwg`), which never consults the table, or add the entry.
|
|
1338
1342
|
- **`download`** — `downloadFileFromUrl(url, filename, { credentials? })`: fetch+blob+anchor
|
|
1339
1343
|
download that works inside sandboxed iframes (where `window.open` is silently dropped);
|
|
1340
1344
|
`credentials` defaults to `same-origin` — pass `"include"` only for auth-gated same-site
|
package/docs/composition.md
CHANGED
|
@@ -122,6 +122,31 @@ without scanning. The heading row is the section's control line — it already c
|
|
|
122
122
|
and the view controls — so the add belongs on it, in the same spot whether the section holds
|
|
123
123
|
nought or forty.
|
|
124
124
|
|
|
125
|
+
**Where there IS no heading row, the add has nowhere else to be, so it rides the surface's own
|
|
126
|
+
bar** — an attachment popover, a dialog body, any self-contained widget. That is not an exception
|
|
127
|
+
to the law so much as the same reasoning: the rule exists so the verb sits in ONE place a reader
|
|
128
|
+
can return to, and on a surface with no heading the bar IS that place. `FilesEditorUpload` is for
|
|
129
|
+
exactly this case; inside a section the host puts its own `Button` on the heading instead, because
|
|
130
|
+
`FilesEditor` provides the context and the heading is outside it — and a file widget rendering a
|
|
131
|
+
`SectionHeading` would own something the section owns. Worked both ways in `tpl_record` (Files,
|
|
132
|
+
Photos) and `tpl_task_board` (the attachment popover).
|
|
133
|
+
|
|
134
|
+
**A header CHECKBOX is legible only when it heads a COLUMN.** A table's select-all works because
|
|
135
|
+
it sits at the same x as every row's box beneath it — the column is what turns a 24px tick into
|
|
136
|
+
"all of these". A grid has no such column, so the same control in a section heading heads
|
|
137
|
+
nothing: it is a transparent 24×24 at radius 6 standing between 40px grounded buttons at radius
|
|
138
|
+
10, off their baseline, differing on every axis at once, and shape is read before meaning. **A
|
|
139
|
+
grid's select-all is a BUTTON** ("Select all" / "Deselect all"), which is what
|
|
140
|
+
`FilesEditorSelectAll` ships. Nothing is lost by giving up the tri-state: `indeterminate` says
|
|
141
|
+
"some", and the `FloatingActionBar` beside it already says WHICH some as an exact count — the
|
|
142
|
+
glyph was approximating a number that was on screen.
|
|
143
|
+
|
|
144
|
+
While a mode is ON, the heading row holds only that mode's controls; the section's own ADD stands
|
|
145
|
+
down until it is off. Otherwise a `primary` add sits in a row that is entirely about the
|
|
146
|
+
selection, competing with the promoted act in the action bar — two accents on one screen. At rest
|
|
147
|
+
the add returns to the right edge where the law puts it, and drag/paste never stopped working, so
|
|
148
|
+
no path actually closes.
|
|
149
|
+
|
|
125
150
|
- **Weight is the section's**, not a hedge: a section's add is the act that section offers, so
|
|
126
151
|
`primary`. Don't drop to `secondary` because the list is full — an add that changes weight
|
|
127
152
|
with row count is the fault, not the cure.
|
|
@@ -306,9 +331,50 @@ Divider-separated.
|
|
|
306
331
|
|
|
307
332
|
| `PressableRow` variant | Shape | Use |
|
|
308
333
|
|---|---|---|
|
|
309
|
-
| `register` (default) | rounded (radius 10) full-width hover/open/`marked` wash
|
|
310
|
-
| `bleed` (legacy) |
|
|
311
|
-
| `inset` | rounded (radius 8)
|
|
334
|
+
| `register` (default) | rounded (radius 10) full-width hover/open/`marked` wash | THE record list |
|
|
335
|
+
| `bleed` (legacy) | square wash, `Divider`-separated | only an edge-to-edge data grid that genuinely wants hard rules |
|
|
336
|
+
| `inset` | rounded (radius 8) | grouped lists in a padded container (Accordion drill-downs) |
|
|
337
|
+
|
|
338
|
+
**A washed row BLEEDS its wash; it never insets its content.** All three variants
|
|
339
|
+
carry `marginHorizontal: -ROW_WASH_BLEED` absorbed by an equal `paddingHorizontal`
|
|
340
|
+
— net zero for layout — so a row's content sits on its container's own edge,
|
|
341
|
+
level with the headings, labels and prose around it, while the wash still gets
|
|
342
|
+
breathing room by growing into the parent's padding. The parent must afford
|
|
343
|
+
`ROW_WASH_BLEED` (8px); every kit container clears it (`Drawer` 20, `Dialog` 24,
|
|
344
|
+
the page canvas 28).
|
|
345
|
+
|
|
346
|
+
**A bar of verbs is TWO groups, not a row of peers.** `FloatingActionBar` separates its own
|
|
347
|
+
STATE (the count and Clear — what is picked, and how to un-pick it) from the ACTS
|
|
348
|
+
(`children`) with the section boundary getting the most air (20) and each group sitting on the
|
|
349
|
+
action gap (8). Spaced uniformly, four controls read as four equal things, and the emphasis
|
|
350
|
+
ladder between them — muted for a dismiss, `danger` for a destructive act, `primary` for the
|
|
351
|
+
promoted one — stops reading as a ladder and starts reading as arbitrary variety. The colours
|
|
352
|
+
are not the defect when this happens; the spacing is. Group first, and the ladder is then read
|
|
353
|
+
WITHIN the acts, where it means something.
|
|
354
|
+
|
|
355
|
+
The grouping pays a second time at PHONE width, which is why it is structure and not styling. A
|
|
356
|
+
count plus an escape plus three verbs is ~520px of content — wider than a 375 screen — so the
|
|
357
|
+
bar wraps; because `state` and `acts` are each a nowrap row, the ONLY break available is the
|
|
358
|
+
boundary between them. The line splits exactly where the meaning does (what is picked / what to
|
|
359
|
+
do with it) instead of orphaning one verb from the ladder it belongs to. A row of five peers has
|
|
360
|
+
no such seam and breaks wherever it runs out of room.
|
|
361
|
+
|
|
362
|
+
**Two exceptions, both real.** A self-contained OVERLAY — a menu, a selector's popover
|
|
363
|
+
(`MenuListItem`, `OptionList`) — has nothing outside itself to align with, so its rows keep
|
|
364
|
+
plain padding; bleeding there would push them past the popover's own edge. And a slot that
|
|
365
|
+
holds another component's PART takes that component's constant, never a hand-picked number:
|
|
366
|
+
`AgentRun`'s dot column was 18 while the `Marker` it holds is `NODE` (24), so a 24px dot
|
|
367
|
+
centred in an 18px column put the whole run rail 3px outside the surface's edge — every row
|
|
368
|
+
wrong by the same amount, so it read as internally consistent and nothing flagged it.
|
|
369
|
+
|
|
370
|
+
The register used to pad 20 WITHOUT the negative margin, and the cost was not
|
|
371
|
+
the 20px — it was that everything nearby had to be hand-padded by the same 20 to
|
|
372
|
+
line up with it. A `Table`'s header band, its static rows and its expanded detail
|
|
373
|
+
all inherited the number; a `SummaryLine` above the register got it at the call
|
|
374
|
+
site; a bare table's footer borrowed `CardFooter` and picked up a *card's*
|
|
375
|
+
interior gutter. **An indent is legible only when something VISIBLE occupies it**
|
|
376
|
+
— a checkbox, an ordinal, a diff mark. An empty one is a defect, and the
|
|
377
|
+
compensation it forces spreads to every new surface that sits beside it.
|
|
312
378
|
|
|
313
379
|
The `Divider` BETWEEN rows is the resting separation; the wash is the hover state. `selected`
|
|
314
380
|
shows a zinc-100 highlight; `marked` (a bulk-ticked row) a resting blue-50 tint.
|
|
@@ -419,7 +485,7 @@ and the "detail" of a list belongs in a `Drawer`/sheet, not a second column):
|
|
|
419
485
|
band, plus a bottom-pinned `<FloatingActionBar count label onClear>` carrying the bulk
|
|
420
486
|
action(s) while ≥ 1 row is ticked (it renders nothing at count 0); a row that can't take the
|
|
421
487
|
action has no usable checkbox. Bar weight gradient, left → right: the built-in Clear is muted
|
|
422
|
-
(the quietest act), destructive bulk = `danger
|
|
488
|
+
(the quietest act), destructive bulk = `danger` + icon, secondary bulk = `secondary`
|
|
423
489
|
+ icon, ONE primary CTA last — never repeat the count in a CTA title; the bar's label already
|
|
424
490
|
carries it.
|
|
425
491
|
|
|
@@ -504,7 +570,16 @@ All view controls are 40px tall (`CONTROL_HEIGHT`), `sm` labels, in ONE wrapping
|
|
|
504
570
|
action `primary`; it sits at a different altitude than the form's one terminal commit.)
|
|
505
571
|
- **Button labels carry no trailing ellipsis** ("Assign", not "Assign…"). **Button color is
|
|
506
572
|
VALENCE/RISK, never category**: the ladder `muted < secondary < primary` is the emphasis axis;
|
|
507
|
-
`danger` (and its quieter `danger-secondary`) marks destructive — that's the whole axis.
|
|
573
|
+
`danger` (and its quieter `danger-secondary`) marks destructive — that's the whole axis.
|
|
574
|
+
**Which of the two is decided by the COMPANY the button keeps, never by how destructive the
|
|
575
|
+
act is.** Among other BUTTONS — a bar, a form's action row, a toolbar — use `danger`: it
|
|
576
|
+
carries a ground (red-100) exactly where `secondary` carries zinc-100, so every act in the row
|
|
577
|
+
keeps ONE shape and only the ink moves; the ladder is then legible as a ladder. Drop a
|
|
578
|
+
groundless `danger-secondary` in there and the destructive verb is the one control with no box
|
|
579
|
+
— a shape difference, which a reader parses before colour and reads as an accident, and the
|
|
580
|
+
row stops looking designed (this is what "four buttons with different shapes" is, every time).
|
|
581
|
+
Among TEXT — a row's trailing verb, a `Callout`'s action, a dialog's tertiary — use
|
|
582
|
+
`danger-secondary`, because there a filled box is the thing that looks pasted on. A
|
|
508
583
|
fill-less `Button` (`danger-secondary`, `muted`, un-colored) shows no box, so its optical edge is
|
|
509
584
|
its INK, inset by its padding: **never align one to a column of TEXT** — it reads as indented
|
|
510
585
|
against every label starting on the true edge. The answer is to move the act, not to invent a
|
package/docs/data_entry.md
CHANGED
|
@@ -589,7 +589,11 @@ Finder/Explorer and pasted arrives with real bytes and a real filename but an un
|
|
|
589
589
|
type — `""`, or just as often `application/octet-stream`, the same non-answer wearing the shape
|
|
590
590
|
of one. Judging it on that reads silence as "not a PDF", the file is discarded, and since an
|
|
591
591
|
unacceptable paste is deliberately left alone, the user sees nothing happen at all. Matching on
|
|
592
|
-
the resolved type also means the surface agrees with what the server will store.
|
|
592
|
+
the resolved type also means the surface agrees with what the server will store. The kit's
|
|
593
|
+
extension table covers documents, images and A/V; the server reads a far larger one, so an
|
|
594
|
+
`accept` naming a MIME pattern (`audio/*`) for some exotic extension the kit does not list
|
|
595
|
+
drops a file the server would have stored — write that `accept` as an EXTENSION (`.dwg`), which
|
|
596
|
+
never consults the table, or add the entry. Separately,
|
|
593
597
|
some clipboards carry only a file *reference* (a path/URL, no bytes): there is nothing to
|
|
594
598
|
upload and no API that can go read it, so those pastes cannot work by any means — dragging the
|
|
595
599
|
file is the path that always does.
|
|
@@ -7,7 +7,6 @@ import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
|
|
|
7
7
|
import { Alert } from "@lotics/ui/alert";
|
|
8
8
|
import { Badge } from "@lotics/ui/badge";
|
|
9
9
|
import { Button } from "@lotics/ui/button";
|
|
10
|
-
import { CardFooter } from "@lotics/ui/card";
|
|
11
10
|
import { CheckboxInput } from "@lotics/ui/checkbox_input";
|
|
12
11
|
import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
|
|
13
12
|
import { Divider } from "@lotics/ui/divider";
|
|
@@ -571,6 +570,11 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
|
|
|
571
570
|
);
|
|
572
571
|
setPhase("running");
|
|
573
572
|
},
|
|
573
|
+
// A REAL app passes `useAgentRun().cancel` — which stops the run on the
|
|
574
|
+
// SERVER, not just locally. Here the run IS the reveal timer below, and that
|
|
575
|
+
// timer is gated on `phase`, so leaving the streaming phase is what stops
|
|
576
|
+
// it; there is nothing else for this mock to tear down.
|
|
577
|
+
cancel: () => {},
|
|
574
578
|
};
|
|
575
579
|
useEffect(() => {
|
|
576
580
|
if (phase !== "analyze" && phase !== "running") return;
|
|
@@ -624,7 +628,17 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
|
|
|
624
628
|
setDienThoai("");
|
|
625
629
|
setPhi(null);
|
|
626
630
|
};
|
|
627
|
-
|
|
631
|
+
/**
|
|
632
|
+
* Closing CANCELS — it does not merely stop watching.
|
|
633
|
+
*
|
|
634
|
+
* `useAgentRun` aborts on unmount, and its `abort` is deliberately
|
|
635
|
+
* local-only: the run keeps executing server-side so it survives navigating
|
|
636
|
+
* away and lands in the session history. That is right for a page you will
|
|
637
|
+
* come back to, and wrong for a dialog you just dismissed — the operator has
|
|
638
|
+
* abandoned the result and every remaining token is spent on nobody. So the
|
|
639
|
+
* close path calls `cancel` explicitly. Unmount is not cancellation.
|
|
640
|
+
*/
|
|
641
|
+
const close = () => { run.cancel(); onOpenChange(false); reset(); };
|
|
628
642
|
|
|
629
643
|
/**
|
|
630
644
|
* THE COMMIT. It writes what is on screen — the edited drafts, minus the
|
|
@@ -955,9 +969,22 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
|
|
|
955
969
|
<Button title="Back" color="secondary" onPress={() => { setKhach(""); setDienThoai(""); setPhi(null); setPhase("intake"); }} />
|
|
956
970
|
<Button title="Create record" color="primary" disabled={khach.trim() === ""} onPress={createManual} />
|
|
957
971
|
</DialogFooter>
|
|
958
|
-
) : phase === "clarify" ? (
|
|
972
|
+
) : phase === "analyze" || phase === "clarify" || phase === "running" ? (
|
|
959
973
|
<DialogFooter>
|
|
960
|
-
|
|
974
|
+
{/* EVERY run-driven phase, not just `clarify`. This branch used to
|
|
975
|
+
name that one phase, because `AgentRunActions` only ever held the
|
|
976
|
+
wizard and there was nothing to show while the feed streamed —
|
|
977
|
+
so the two streaming phases had an empty footer and no way to
|
|
978
|
+
stop. The component's contract is that the HOST mounts it
|
|
979
|
+
wherever the run drives the surface and the RUN decides what it
|
|
980
|
+
holds; gating it by phase out here duplicates that decision and
|
|
981
|
+
gets it wrong the moment the run learns a new one.
|
|
982
|
+
|
|
983
|
+
`onStop` is the SAME handler the pane's `onCancel` gets: stopping
|
|
984
|
+
a streaming run and abandoning a parked question both put the
|
|
985
|
+
operator back at the intake they started from, with their files
|
|
986
|
+
and their answers still there to retry from. */}
|
|
987
|
+
<AgentRunActions run={run} onStop={() => setPhase("intake")} />
|
|
961
988
|
</DialogFooter>
|
|
962
989
|
) : phase === "preview" ? (
|
|
963
990
|
<DialogFooter>
|
|
@@ -1252,7 +1279,13 @@ export function TplItemList() {
|
|
|
1252
1279
|
<EmptyState message="No matching records" hint="Try another keyword or switch status" />
|
|
1253
1280
|
</>
|
|
1254
1281
|
) : null}
|
|
1255
|
-
|
|
1282
|
+
{/* NOT `CardFooter` — that is a Card PART, and its 20px is the card's
|
|
1283
|
+
interior gutter. There is no card here, so it put the totals 20px
|
|
1284
|
+
right of the register's own rows and of the toolbar above them.
|
|
1285
|
+
A bare table's footer is a divider and a row; borrowing furniture
|
|
1286
|
+
from a container that is not there is what put it out of true. */}
|
|
1287
|
+
<Divider />
|
|
1288
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 12 }}>
|
|
1256
1289
|
<Text size="sm" color="muted" tabular style={{ flex: 1 }}>
|
|
1257
1290
|
{`${filtered.length} records, ${formatMoney(tongPhi)} in fees`}
|
|
1258
1291
|
</Text>
|
|
@@ -1264,7 +1297,7 @@ export function TplItemList() {
|
|
|
1264
1297
|
total={filtered.length}
|
|
1265
1298
|
onPageChange={setPage}
|
|
1266
1299
|
/>
|
|
1267
|
-
</
|
|
1300
|
+
</View>
|
|
1268
1301
|
</View>
|
|
1269
1302
|
</View>
|
|
1270
1303
|
</ScrollView>
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
|
|
1
|
+
import { Fragment, useEffect, useRef, useState, type ReactNode, useMemo } from "react";
|
|
2
2
|
import { ScrollView, View } from "react-native";
|
|
3
3
|
import { Text } from "@lotics/ui/text";
|
|
4
4
|
import { colors } from "@lotics/ui/colors";
|
|
@@ -45,6 +45,7 @@ import { EmptyState } from "@lotics/ui/empty_state";
|
|
|
45
45
|
import { FormField } from "@lotics/ui/form_field";
|
|
46
46
|
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
47
47
|
import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
48
|
+
import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
|
|
48
49
|
import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
|
|
49
50
|
import { DangerZone } from "@lotics/ui/danger_zone";
|
|
50
51
|
import { FileRow } from "@lotics/ui/file_row";
|
|
@@ -423,6 +424,18 @@ const DOCS: Doc[] = [
|
|
|
423
424
|
// an IMAGE file — the register renders it as a real square thumbnail (the
|
|
424
425
|
// SVG data URI stands in for the stored photo URL a live app serves)
|
|
425
426
|
{ id: "f5", name: "delivery-photo.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1424, added: "30 Jun", addedAt: 630, url: MOCK_PHOTO_URL },
|
|
427
|
+
// The hand-over set. EIGHT photos, not one: a grid with a single tile does not
|
|
428
|
+
// exercise a grid — it never wraps, its tiles are never told apart by content,
|
|
429
|
+
// and a Select mode over one item reads as absurd. A photo set on a real
|
|
430
|
+
// consignment is a dozen frames of the same yard from different angles, which
|
|
431
|
+
// is the case the surface has to survive.
|
|
432
|
+
{ id: "p1", name: "hạ-container-01.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 820, added: "30 Jun", addedAt: 630, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2JmZGJmZScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyNhOGEyOWUnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjZGMyNjI2Jy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMyNTYzZWInLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNkYzI2MjYnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
|
|
433
|
+
{ id: "p2", name: "hạ-container-02.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 957, added: "30 Jun", addedAt: 630, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2ZlZDdhYScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM3ODcxNmMnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjMDg5MWIyJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMxNTVlNzUnLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMwODkxYjInIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
|
|
434
|
+
{ id: "p3", name: "seal-truoc-khi-keo.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1094, added: "30 Jun", addedAt: 630, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2JiZjdkMCcvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM1NzUzNGUnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjYjQ1MzA5Jy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyM3YzJkMTInLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNiNDUzMDknIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
|
|
435
|
+
{ id: "p4", name: "cont-rong-mat-truoc.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1231, added: "01 Jul", addedAt: 701, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2U5ZDVmZicvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyNhMWExYWEnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjYmUxMjNjJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMxZTI5M2InLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNiZTEyM2MnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
|
|
436
|
+
{ id: "p5", name: "cont-rong-ben-trong.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1368, added: "01 Jul", addedAt: 701, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2ZlY2FjYScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM0NDQwM2MnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjMGY3NjZlJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMzMzQxNTUnLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMwZjc2NmUnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
|
|
437
|
+
{ id: "p6", name: "bien-so-xe.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1505, added: "01 Jul", addedAt: 701, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2ZlZjA4YScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM1MjUyNTInLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjN2UyMmNlJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMxZTNhOGEnLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyM3ZTIyY2UnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
|
|
438
|
+
{ id: "p7", name: "bang-ke-giao-nhan.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1642, added: "02 Jul", addedAt: 702, url: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2E1ZjNmYycvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyM3MzczNzMnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjYzI0MTBjJy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMwNjRlM2InLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNjMjQxMGMnIG9wYWNpdHk9JzAuNycvPjwvc3ZnPg==" },
|
|
426
439
|
];
|
|
427
440
|
|
|
428
441
|
const EXTRACT_STEPS: ScriptStep[] = [
|
|
@@ -1269,7 +1282,32 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1269
1282
|
// ── the document desk (see the module banner above; the record's files ARE
|
|
1270
1283
|
// the desk's register)
|
|
1271
1284
|
const [files, setFiles] = useState<Doc[]>(DOCS);
|
|
1285
|
+
// The photo subset of the SAME collection — one list, two surfaces. Derived
|
|
1286
|
+
// rather than a second state, or the two sections would drift the moment
|
|
1287
|
+
// anything is added to one of them.
|
|
1288
|
+
const photoDocs = useMemo(() => files.filter((f) => f.mimeType.startsWith("image/")), [files]);
|
|
1289
|
+
/**
|
|
1290
|
+
* SELECT MODE — for the grid only, and only because a grid has nowhere free
|
|
1291
|
+
* to put a checkbox.
|
|
1292
|
+
*
|
|
1293
|
+
* The Files LIST leaves its checkboxes on permanently: a table already has a
|
|
1294
|
+
* leading column, so they cost nothing and selection is always one tap away.
|
|
1295
|
+
* A photo tile has no such column — a permanent checkbox sits ON the content,
|
|
1296
|
+
* and worse it splits a 96px tile into two hit targets (tap the tile to open,
|
|
1297
|
+
* tap the box to select) exactly where fingers are least precise. So the grid
|
|
1298
|
+
* gets a mode instead: the default view is photos, and one visible control
|
|
1299
|
+
* switches it to picking. Hover-to-reveal was the other candidate and it is
|
|
1300
|
+
* not a design — it simply does not exist on touch, and long-press is a
|
|
1301
|
+
* gesture nothing on screen advertises.
|
|
1302
|
+
*
|
|
1303
|
+
* The selection itself is SHARED with the Files table rather than a second
|
|
1304
|
+
* one, because these are the same records seen two ways. Two selections would
|
|
1305
|
+
* mean two `FloatingActionBar`s racing for the same corner.
|
|
1306
|
+
*/
|
|
1307
|
+
const [pickingPhotos, setPickingPhotos] = useState(false);
|
|
1272
1308
|
const sel = useSelection();
|
|
1309
|
+
const photoIds = useMemo(() => photoDocs.map((p) => p.id), [photoDocs]);
|
|
1310
|
+
const allPhotosPicked = sel.allSelected(photoIds);
|
|
1273
1311
|
|
|
1274
1312
|
// Use AI — one entry off the selection, forking into the two document tasks.
|
|
1275
1313
|
const [aiOpen, setAiOpen] = useState(false);
|
|
@@ -1321,6 +1359,25 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1321
1359
|
};
|
|
1322
1360
|
// The upload path's save: pending picked files land on the record only here.
|
|
1323
1361
|
const commitUpload = () => setFiles((fs) => [...fs, ...picked.filter((p2) => !fs.some((f) => f.id === p2.id))]);
|
|
1362
|
+
/**
|
|
1363
|
+
* STOP the run and go back to the intake — the files, the brief and the task
|
|
1364
|
+
* choice are all still there, because the operator stopped to change one of
|
|
1365
|
+
* them. A real app cancels the run itself first:
|
|
1366
|
+
* run.cancel(); // `useAgentRun().cancel` — stops it SERVER-side
|
|
1367
|
+
* Here the run is the reveal timer below, which is gated on `phase`, so
|
|
1368
|
+
* leaving "running" is what stops it.
|
|
1369
|
+
*/
|
|
1370
|
+
const stopRun = () => { setPhase("fork"); setRevealed(0); setTask(null); };
|
|
1371
|
+
/**
|
|
1372
|
+
* Closing CANCELS the run — it does not merely stop watching it.
|
|
1373
|
+
*
|
|
1374
|
+
* `useAgentRun` aborts on unmount, and its `abort` is local-only BY DESIGN:
|
|
1375
|
+
* the run keeps executing server-side so it survives navigating away and
|
|
1376
|
+
* lands in the session history. Right for a page you will return to, wrong
|
|
1377
|
+
* for a dialog just dismissed — the result has been abandoned and every
|
|
1378
|
+
* remaining token is spent on nobody. So a real app calls `run.cancel()`
|
|
1379
|
+
* here. Unmount is not cancellation.
|
|
1380
|
+
*/
|
|
1324
1381
|
const closeAi = () => {
|
|
1325
1382
|
setAiOpen(false); setTask(null); setPhase("fork"); setRevealed(0); setUploadFlow(false); setBrief(""); setTaskChoice(null);
|
|
1326
1383
|
setCarrierRef(CARRIER_REF_PROPOSED); setVessel(VESSEL_PROPOSED); setConsignee(null); setConsigneePick(null); setCustomConsignee(""); setGrossWeight("1,540"); lines.reset(); setNewItem("Corner protectors, foam"); setNewQty("400"); setEditedQty("1,450"); fields.reset();
|
|
@@ -1810,6 +1867,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1810
1867
|
// there.
|
|
1811
1868
|
{ key: "comments", label: "Comments", icon: "message-square" },
|
|
1812
1869
|
{ key: "files", label: "Files", icon: "folder-closed" },
|
|
1870
|
+
{ key: "photos", label: "Photos", icon: "image" },
|
|
1813
1871
|
{ key: "transport", label: "Transport", icon: "map-pin" },
|
|
1814
1872
|
{ key: "fees", label: "Fees", icon: "receipt" },
|
|
1815
1873
|
{ key: "billing", label: "Billing", icon: "credit-card" },
|
|
@@ -1817,9 +1875,15 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1817
1875
|
{ key: "receipt", label: "Delivery receipt", icon: "log-in" },
|
|
1818
1876
|
{ key: "danger", label: "Danger zone", icon: "circle-alert" },
|
|
1819
1877
|
] as const;
|
|
1820
|
-
//
|
|
1821
|
-
//
|
|
1822
|
-
|
|
1878
|
+
// The explicit `<SecKey>` is what keeps this list and `SECTIONS` in step, and
|
|
1879
|
+
// it is not decoration. Inferred, this tuple defines its own key type and is
|
|
1880
|
+
// checked against nothing — so a section registered and scrolled to, but never
|
|
1881
|
+
// added to `SECTIONS`, compiles clean and simply has no rail row. That is the
|
|
1882
|
+
// silent direction, and it is the one that happened: Photos shipped reachable
|
|
1883
|
+
// by scroll and invisible in the outline. Bound to `SecKey`, a key here that
|
|
1884
|
+
// `SECTIONS` does not carry stops being assignable, so the omission is a
|
|
1885
|
+
// compile error instead of a missing row nobody notices.
|
|
1886
|
+
const nav = useSectionNav<SecKey>(["progress", "general", "comments", "files", "photos", "transport", "fees", "billing", "docset", "receipt", "danger"] as const);
|
|
1823
1887
|
// ONE record, ONE page. A section is a place you SCROLL to, never a
|
|
1824
1888
|
// destination you swap to: routing a record was tried here and lost, because
|
|
1825
1889
|
// every fix it needed rebuilt the whole-record view in miniature — a dot to
|
|
@@ -2613,6 +2677,111 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2613
2677
|
</Section>
|
|
2614
2678
|
</View>
|
|
2615
2679
|
|
|
2680
|
+
{/* PHOTOS — the same collection, the other SURFACE.
|
|
2681
|
+
|
|
2682
|
+
Files above is a row LIST because a shipping document is identified by
|
|
2683
|
+
its NAME: a PDF thumbnail is a grey page identical to every other grey
|
|
2684
|
+
page, so a grid of them is a wall of one tile. A photo is identified
|
|
2685
|
+
by its CONTENT — `IMG_4471.jpg` tells nobody anything — so the tile IS
|
|
2686
|
+
the identity and a grid puts twelve on screen at once. Same record,
|
|
2687
|
+
same verbs, different surface, decided by what the reader recognises.
|
|
2688
|
+
(AGENTS.md: documents get a LIST, images get a GRID.)
|
|
2689
|
+
|
|
2690
|
+
The ADD rides the heading row, right edge, exactly as it does on Files
|
|
2691
|
+
and Fees — a section's add sits in the same spot whether the section
|
|
2692
|
+
holds nought or forty. Note it is a plain `Button` + `pickFiles`, not
|
|
2693
|
+
`FilesEditorUpload`: that verb reads the `FilesEditor` context, and the
|
|
2694
|
+
heading is OUTSIDE the editor by construction, so a compound's own
|
|
2695
|
+
upload button can never reach the one place the law puts it. */}
|
|
2696
|
+
<View onLayout={nav.register("photos")}>
|
|
2697
|
+
<Section>
|
|
2698
|
+
<SectionHeading>
|
|
2699
|
+
<SectionHeadingTitle description="Condition and hand-over photos. Drag, paste, or click to add.">Photos</SectionHeadingTitle>
|
|
2700
|
+
{/* Secondary LEFT of primary — the same order every action row in the
|
|
2701
|
+
system uses, so the section's add stays on the right edge where
|
|
2702
|
+
the add-placement law puts it and the mode toggle never displaces
|
|
2703
|
+
it. In mode, the toggle becomes the way OUT; the bulk verbs never
|
|
2704
|
+
come here, because a verb that needs a selection has no meaning
|
|
2705
|
+
until there is one. */}
|
|
2706
|
+
{/* GROUPED, so the heading's 10px slot gap separates the TITLE from
|
|
2707
|
+
the pair rather than the two buttons from each other. A pair of
|
|
2708
|
+
buttons is an action group and takes the action gap (8) every
|
|
2709
|
+
other action row in the kit uses — a Dialog footer, a comment
|
|
2710
|
+
composer, the wizard. Wrapping is composition, not the per-site
|
|
2711
|
+
padding this kit spent the day deleting: it states that these two
|
|
2712
|
+
belong together, rather than compensating for someone else's
|
|
2713
|
+
geometry. */}
|
|
2714
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
2715
|
+
{/* SELECT ALL, in mode only — the same control the Files table
|
|
2716
|
+
carries in its header, at the grid's equivalent surface. A grid
|
|
2717
|
+
has no header row to head its tiles, so the section heading is
|
|
2718
|
+
where it goes; without it, picking 40 photos is 40 taps while
|
|
2719
|
+
the list beside it does the job in one. Reads all/some/none
|
|
2720
|
+
from the SAME `sel` the table uses, so the two sections cannot
|
|
2721
|
+
disagree about what is picked. */}
|
|
2722
|
+
{/* SELECT ALL is a BUTTON here, not the checkbox the Files table
|
|
2723
|
+
header carries, and the difference is not a preference. A bare
|
|
2724
|
+
header checkbox is legible only when it HEADS A COLUMN — the
|
|
2725
|
+
table's sits at the same x as every row's box beneath it, which
|
|
2726
|
+
is what makes a 24px tick in a header row read as "all of
|
|
2727
|
+
these". A grid has no such column, so the same control in a
|
|
2728
|
+
heading row heads nothing: a 24×24 transparent 6px-radius tick
|
|
2729
|
+
standing between two 40px grounded 10px-radius buttons, off
|
|
2730
|
+
their baseline by 8px, differing on every axis at once. Shape
|
|
2731
|
+
is read before meaning, so it read as a stray.
|
|
2732
|
+
Nothing is lost by dropping the tri-state: `indeterminate` said
|
|
2733
|
+
"some", and the action bar already says WHICH some, as an exact
|
|
2734
|
+
count. A glyph was approximating a number that was on screen. */}
|
|
2735
|
+
{pickingPhotos ? (
|
|
2736
|
+
<Button
|
|
2737
|
+
title={allPhotosPicked ? "Deselect all" : "Select all"}
|
|
2738
|
+
color="secondary"
|
|
2739
|
+
onPress={() => sel.setAll(photoIds, !allPhotosPicked)}
|
|
2740
|
+
/>
|
|
2741
|
+
) : null}
|
|
2742
|
+
<Button
|
|
2743
|
+
title={pickingPhotos ? "Done" : "Select"}
|
|
2744
|
+
color="secondary"
|
|
2745
|
+
onPress={() => {
|
|
2746
|
+
if (pickingPhotos) sel.setAll(photoIds, false);
|
|
2747
|
+
setPickingPhotos(!pickingPhotos);
|
|
2748
|
+
}}
|
|
2749
|
+
/>
|
|
2750
|
+
{/* The ADD stands down while picking. Not decluttering for its own
|
|
2751
|
+
sake: in select mode this row is ABOUT the selection, and the
|
|
2752
|
+
section's own act is a third kind of thing in a two-control
|
|
2753
|
+
row — which is how the primary ended up competing with the one
|
|
2754
|
+
in the action bar (`Use AI`), two accents on one screen. At
|
|
2755
|
+
rest the add is back on the right edge where the placement law
|
|
2756
|
+
puts it, and while picking, drag/paste still adds, so the path
|
|
2757
|
+
is never actually closed. */}
|
|
2758
|
+
{pickingPhotos ? null : (
|
|
2759
|
+
<Button
|
|
2760
|
+
title="Add photos"
|
|
2761
|
+
color="primary"
|
|
2762
|
+
onPress={() => {
|
|
2763
|
+
void pickFiles({ accept: "image/*", multiple: true }).then(intakeFiles);
|
|
2764
|
+
}}
|
|
2765
|
+
/>
|
|
2766
|
+
)}
|
|
2767
|
+
</View>
|
|
2768
|
+
</SectionHeading>
|
|
2769
|
+
<FileThumbnailGrid
|
|
2770
|
+
files={photoDocs.map(toDisplay)}
|
|
2771
|
+
minItemWidth={104}
|
|
2772
|
+
// Checkboxes exist only in mode — `selectedIds` undefined is the
|
|
2773
|
+
// clean preview. ONE meaning per tap: out of mode a tile opens, in
|
|
2774
|
+
// mode it picks.
|
|
2775
|
+
selectedIds={pickingPhotos ? sel.selected : undefined}
|
|
2776
|
+
onFilePress={(f) =>
|
|
2777
|
+
pickingPhotos
|
|
2778
|
+
? sel.toggle(f.id)
|
|
2779
|
+
: openPreview(photoDocs, photoDocs.findIndex((p: Doc) => p.id === f.id))
|
|
2780
|
+
}
|
|
2781
|
+
/>
|
|
2782
|
+
</Section>
|
|
2783
|
+
</View>
|
|
2784
|
+
|
|
2616
2785
|
{/* TRANSPORT — the DENSE section, and the honest one. Two groups of ~10
|
|
2617
2786
|
fields, three record references, mixed editor types, a derived
|
|
2618
2787
|
warning, one "Show more".
|
|
@@ -3509,7 +3678,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3509
3678
|
|
|
3510
3679
|
{/* ── the document desk's overlays */}
|
|
3511
3680
|
<FloatingActionBar count={sel.count} label={sel.count === 1 ? "file selected" : "files selected"} onClear={sel.clear}>
|
|
3512
|
-
<Button title="Remove" color="danger
|
|
3681
|
+
<Button title="Remove" color="danger" icon="trash" onPress={removeSelected} />
|
|
3513
3682
|
<Button title="Download" color="secondary" icon="download" onPress={() => { /* a real app zips or opens each selected file (openExternal) */ }} />
|
|
3514
3683
|
<Button title="Use AI" color="primary" onPress={openAi} />
|
|
3515
3684
|
</FloatingActionBar>
|
|
@@ -3814,7 +3983,19 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3814
3983
|
) : null}
|
|
3815
3984
|
</DialogScrollArea>
|
|
3816
3985
|
)}
|
|
3817
|
-
{phase === "
|
|
3986
|
+
{phase === "running" ? (
|
|
3987
|
+
/* The one phase that used to have NO footer, which meant a run could not
|
|
3988
|
+
be stopped — the operator who sees from the first step that it is
|
|
3989
|
+
reading the wrong document had to sit through the whole thing and pay
|
|
3990
|
+
for it. Stop belongs HERE rather than under the transcript because the
|
|
3991
|
+
feed follows its newest part: a control inside it scrolls away exactly
|
|
3992
|
+
while the run is doing the thing they want to stop. `AgentRunActions`
|
|
3993
|
+
renders the same button for a pane-based surface (tpl_item_list); this
|
|
3994
|
+
dialog composes its own footers per phase, so it states it directly. */
|
|
3995
|
+
<DialogFooter>
|
|
3996
|
+
<Button title="Stop" color="secondary" onPress={stopRun} />
|
|
3997
|
+
</DialogFooter>
|
|
3998
|
+
) : phase === "fork" ? (
|
|
3818
3999
|
<DialogFooter>
|
|
3819
4000
|
{uploadFlow ? <Button title="Save files only" color="muted" onPress={() => { commitUpload(); closeAi(); }} /> : <Button title="Cancel" color="muted" onPress={closeAi} />}
|
|
3820
4001
|
<Button
|
package/package.json
CHANGED
package/src/agent_run.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { Markdown } from "./markdown";
|
|
|
9
9
|
import { JsonPanel, stringifyData } from "./json_panel";
|
|
10
10
|
import { PressableHighlight } from "./pressable_highlight";
|
|
11
11
|
import { Marker, type StepStatus } from "./stepper";
|
|
12
|
+
import { NODE } from "./stepper_layout";
|
|
12
13
|
import { AnimationFadeIn } from "./animation_fade_in";
|
|
13
14
|
import { CONTROL_HEIGHT, CONTROL_RADIUS } from "./control_surface";
|
|
14
15
|
import { useLoticsLocale, type LoticsLocale } from "./locale";
|
|
@@ -498,7 +499,13 @@ const styles = StyleSheet.create({
|
|
|
498
499
|
marginHorizontal: -8,
|
|
499
500
|
paddingHorizontal: 8,
|
|
500
501
|
},
|
|
501
|
-
|
|
502
|
+
// NODE, not a local 18. The dot IS a stepper `Marker` (exported from
|
|
503
|
+
// `stepper` precisely so this feed reuses the same one), and a Marker is
|
|
504
|
+
// NODE across. An 18px column centring a 24px disc overflows 3px each side,
|
|
505
|
+
// so the whole dot rail sat 3px LEFT of the surface's own edge — every other
|
|
506
|
+
// band in a dialog lined up and the run did not. Read the owner's constant
|
|
507
|
+
// rather than re-deriving a width that has to match it.
|
|
508
|
+
dotCol: { width: NODE, alignItems: "center" },
|
|
502
509
|
rowBody: { flex: 1 },
|
|
503
510
|
// Reasoning body aligns under the "Thinking" label (past the dot column + gap).
|
|
504
511
|
reasoning: { paddingLeft: 28, paddingBottom: 4 },
|
package/src/agent_run_pane.tsx
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { useCallback, useMemo, type ReactNode } from "react";
|
|
2
2
|
import { View } from "react-native";
|
|
3
3
|
import { AgentRun, type AgentRunProps } from "./agent_run";
|
|
4
|
+
import { Button } from "./button";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
4
6
|
import type { AgentUIPart } from "./agent_transform";
|
|
5
7
|
import { ClarifyWizard, ClarifyWizardActions, ClarifyWizardScope, type ClarifyWizardAnswer } from "./clarify_wizard";
|
|
6
|
-
import { DialogScrollArea } from "./dialog";
|
|
8
|
+
import { DialogScrollArea, useDialogGutter } from "./dialog";
|
|
7
9
|
import { FollowScroll } from "./follow_scroll";
|
|
8
|
-
import { useContainerSize } from "@lotics/ui/size_boundary";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* What this pair needs from a run — structurally the shape `useAgentRun()`
|
|
@@ -36,6 +37,17 @@ export interface AgentRunLike {
|
|
|
36
37
|
/** Non-null exactly while parked. */
|
|
37
38
|
pendingChoice: { questions: AgentRunQuestion[] } | null;
|
|
38
39
|
answerChoice: (answers: { value: string; custom: boolean }[]) => Promise<unknown>;
|
|
40
|
+
/**
|
|
41
|
+
* STOP the run — server-side, not just locally. `useAgentRun().cancel` is
|
|
42
|
+
* exactly this; do NOT pass its `abort`, which detaches the listener and
|
|
43
|
+
* leaves the run executing (and billing) to completion.
|
|
44
|
+
*
|
|
45
|
+
* Without this the hook's capability was unreachable through the pane, so
|
|
46
|
+
* every surface built on it could only wait a run out. A long extraction is
|
|
47
|
+
* the case that matters: the operator sees from the first steps that it read
|
|
48
|
+
* the wrong document, and the only exit was to sit through it.
|
|
49
|
+
*/
|
|
50
|
+
cancel: () => void;
|
|
39
51
|
error: string | null;
|
|
40
52
|
}
|
|
41
53
|
|
|
@@ -64,7 +76,12 @@ export interface AgentRunPaneProps {
|
|
|
64
76
|
*/
|
|
65
77
|
export function AgentRunPane(props: AgentRunPaneProps) {
|
|
66
78
|
const { run, labelForCall, renderToolOutput, onCancel } = props;
|
|
67
|
-
|
|
79
|
+
// The DIALOG's gutter, not this pane's own container measurement. Asking
|
|
80
|
+
// `useContainerSize()` here answered "small" for a 760px dialog while the
|
|
81
|
+
// header, scroll area and footer asked the SCREEN and answered "not small" —
|
|
82
|
+
// two honest answers to one question, and the run's rows landed 8px left of
|
|
83
|
+
// the dialog's own title. One decision, published by the Dialog, read here.
|
|
84
|
+
const gutter = useDialogGutter();
|
|
68
85
|
const pending = run.pendingChoice;
|
|
69
86
|
|
|
70
87
|
// Mapped once per question set, not per render: the wizard keys its per-step
|
|
@@ -100,7 +117,10 @@ export function AgentRunPane(props: AgentRunPaneProps) {
|
|
|
100
117
|
// deliberate — remounting opens the question at the top rather than wherever
|
|
101
118
|
// the feed happened to be scrolled to.
|
|
102
119
|
return (
|
|
103
|
-
|
|
120
|
+
// The dialog's OWN gutter, read rather than re-derived: this pane sits as a
|
|
121
|
+
// band among the dialog's header and footer, and a hand-copied `small ? 16 :
|
|
122
|
+
// 24` here is a fifth copy of a number that has to agree with four others.
|
|
123
|
+
<FollowScroll contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: gutter }}>
|
|
104
124
|
{/* No empty-state slot on purpose. `AgentRun` renders its own breathing
|
|
105
125
|
"Starting…" row while streaming with zero parts, localized through the
|
|
106
126
|
`agentRun` locale slice — and ai_patterns states the law outright:
|
|
@@ -118,17 +138,42 @@ export function AgentRunPane(props: AgentRunPaneProps) {
|
|
|
118
138
|
}
|
|
119
139
|
|
|
120
140
|
/**
|
|
121
|
-
* The run's action bar, for a `DialogFooter`.
|
|
122
|
-
* Cancel/Back/Next/Submit
|
|
123
|
-
*
|
|
141
|
+
* The run's action bar, for a `DialogFooter`. The run decides what it holds, so
|
|
142
|
+
* the host mounts it unconditionally: the wizard's Cancel/Back/Next/Submit
|
|
143
|
+
* while a question is up, **Stop** while the run is streaming, nothing once it
|
|
144
|
+
* has settled.
|
|
124
145
|
*
|
|
125
|
-
* Pinned outside the scroller is the point
|
|
126
|
-
*
|
|
146
|
+
* Pinned outside the scroller is the point, and it is what decides that Stop
|
|
147
|
+
* lives here rather than under the transcript: the feed FOLLOWS its newest part,
|
|
148
|
+
* so a control inside it scrolls away from the reader exactly while the run is
|
|
149
|
+
* doing the thing they want to stop. No length of question can push Submit out
|
|
150
|
+
* of reach either.
|
|
151
|
+
*
|
|
152
|
+
* `onStop` is the host's way back — the screen to return to once the run is
|
|
153
|
+
* stopped, normally the intake it was started from. It is the same handler the
|
|
154
|
+
* pane's `onCancel` takes, because abandoning a parked question and stopping a
|
|
155
|
+
* streaming run land the operator in the same place; only the verb differs, and
|
|
156
|
+
* it differs truthfully (you STOP something running, you CANCEL something
|
|
157
|
+
* waiting).
|
|
127
158
|
*/
|
|
128
|
-
export function AgentRunActions({ run }: { run: AgentRunLike }) {
|
|
159
|
+
export function AgentRunActions({ run, onStop }: { run: AgentRunLike; onStop?: () => void }) {
|
|
160
|
+
const loc = useLoticsLocale().agentRun;
|
|
129
161
|
// Reads the SAME run the pane reads, rather than state the pane publishes: two
|
|
130
162
|
// components deriving from one source cannot disagree, and nothing has to set
|
|
131
163
|
// a parent's state during a child's render to keep them in step.
|
|
132
|
-
if (run.pendingChoice
|
|
133
|
-
|
|
164
|
+
if (run.pendingChoice != null) return <ClarifyWizardActions />;
|
|
165
|
+
// Parked is NOT streaming — a run waiting on an answer burns nothing, so it is
|
|
166
|
+
// the wizard's Cancel that leaves it, not a Stop. Checked in this order for
|
|
167
|
+
// that reason.
|
|
168
|
+
if (run.status !== "streaming") return null;
|
|
169
|
+
return (
|
|
170
|
+
<Button
|
|
171
|
+
title={loc.stop}
|
|
172
|
+
color="secondary"
|
|
173
|
+
onPress={() => {
|
|
174
|
+
run.cancel();
|
|
175
|
+
onStop?.();
|
|
176
|
+
}}
|
|
177
|
+
/>
|
|
178
|
+
);
|
|
134
179
|
}
|
package/src/choice_list.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { Icon } from "./icon";
|
|
|
6
6
|
import { Divider } from "./divider";
|
|
7
7
|
import { composeHandler, useFocusRing } from "./use_focus_ring";
|
|
8
8
|
import { useHover } from "./use_hover";
|
|
9
|
-
import { CONTROL_RADIUS, FOCUS_RING } from "./control_surface";
|
|
9
|
+
import { CONTROL_RADIUS, FOCUS_RING, ROW_WASH_BLEED } from "./control_surface";
|
|
10
10
|
import { useAutoGrowHeight } from "./use_auto_grow_height";
|
|
11
11
|
import { fontFamilyMedium, getInputTextStyle } from "./text_utils";
|
|
12
12
|
import { useLoticsLocale } from "./locale";
|
|
@@ -52,7 +52,10 @@ function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selec
|
|
|
52
52
|
onPress={onSelect}
|
|
53
53
|
{...focusProps}
|
|
54
54
|
style={({ hovered, pressed }) => [
|
|
55
|
-
|
|
55
|
+
// Bleeds like every other washed row, so the option TEXT sits on the
|
|
56
|
+
// container's edge — level with the question above it, which no longer
|
|
57
|
+
// has to hand-pad itself to match.
|
|
58
|
+
{ flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 14, paddingHorizontal: ROW_WASH_BLEED, marginHorizontal: -ROW_WASH_BLEED, borderRadius: CONTROL_RADIUS },
|
|
56
59
|
// Selected reads as a persistent tint (zinc-100); an unselected row only
|
|
57
60
|
// washes on hover (zinc-50). The check below reserves its slot always.
|
|
58
61
|
selected ? { backgroundColor: colors.zinc[100] } : hovered || pressed ? { backgroundColor: colors.zinc[50] } : null,
|
|
@@ -88,7 +91,7 @@ function CustomAnswerRow({ value, selected, onChangeText, onFocus, placeholder }
|
|
|
88
91
|
<View
|
|
89
92
|
{...(hoverProps as object)}
|
|
90
93
|
style={[
|
|
91
|
-
{ paddingVertical: 14, paddingHorizontal:
|
|
94
|
+
{ paddingVertical: 14, paddingHorizontal: ROW_WASH_BLEED, marginHorizontal: -ROW_WASH_BLEED, borderRadius: CONTROL_RADIUS },
|
|
92
95
|
// Same states as an option row: a filled custom answer reads as selected
|
|
93
96
|
// (zinc-100); otherwise it washes on hover (zinc-50).
|
|
94
97
|
selected ? { backgroundColor: colors.zinc[100] } : hovered ? { backgroundColor: colors.zinc[50] } : null,
|
package/src/clarify.tsx
CHANGED
|
@@ -36,9 +36,13 @@ export interface ClarifyProps {
|
|
|
36
36
|
export function Clarify(props: ClarifyProps) {
|
|
37
37
|
return (
|
|
38
38
|
<View style={{ gap: 12 }}>
|
|
39
|
-
{/*
|
|
39
|
+
{/* No padding: the `ChoiceList` rows below BLEED their wash now, so their
|
|
40
|
+
text already sits on this container's edge and the question needs
|
|
41
|
+
nothing to line up with it. The 8 that used to be here was compensation
|
|
42
|
+
for a row that pushed its own content in. Old note kept for the why:
|
|
43
|
+
it "aligned the eyebrow + question with the option/answer
|
|
40
44
|
text, which sits on the `ChoiceList` rows' 8px inset (CONTROL_RADIUS grid). */}
|
|
41
|
-
<View style={{ gap: 4
|
|
45
|
+
<View style={{ gap: 4 }}>
|
|
42
46
|
{props.eyebrow ? (
|
|
43
47
|
<Text size="xs" color="muted" weight="medium">
|
|
44
48
|
{props.eyebrow}
|
package/src/clarify_wizard.tsx
CHANGED
|
@@ -123,12 +123,14 @@ export function ClarifyWizard(props: ClarifyWizardProps) {
|
|
|
123
123
|
return (
|
|
124
124
|
<View style={{ gap: 16 }}>
|
|
125
125
|
{/* The step position is an eyebrow above the question (no progress bar — and
|
|
126
|
-
none at all for a single question); the footer
|
|
126
|
+
none at all for a single question); the footer shares their left edge
|
|
127
127
|
as the question and answers. Keyed by index so each step's custom-answer
|
|
128
128
|
draft is its own (no bleed across steps). */}
|
|
129
129
|
<Clarify key={index} eyebrow={total > 1 ? `${index + 1} / ${total}` : undefined} question={q.question} options={q.answers} answer={current} onAnswer={setAnswer} allowCustom={q.allowCustom} />
|
|
130
|
+
{/* No horizontal padding — the choice rows below BLEED their wash now, so
|
|
131
|
+
the footer already shares their left edge without compensating for it. */}
|
|
130
132
|
{scope == null ? (
|
|
131
|
-
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8
|
|
133
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
|
132
134
|
<Button title={labels.cancel} color="muted" onPress={onCancel} />
|
|
133
135
|
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
134
136
|
{index > 0 ? <Button title={labels.back} color="secondary" onPress={() => setIndex(index - 1)} /> : null}
|
package/src/control_surface.ts
CHANGED
|
@@ -23,6 +23,31 @@ export const MIN_CONTROL_WIDTH = 160;
|
|
|
23
23
|
* (avatars, dots, switches, sliders, progress, icon buttons) stay full. */
|
|
24
24
|
export const CONTROL_RADIUS = 10;
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* HOW FAR A ROW'S WASH EXTENDS PAST ITS CONTENT — and the invariant that goes
|
|
28
|
+
* with it: **a row that paints a hover/selection wash BLEEDS it outward; it
|
|
29
|
+
* never insets its content to make room for decoration.**
|
|
30
|
+
*
|
|
31
|
+
* `marginHorizontal: -ROW_WASH_BLEED` absorbed by an equal `paddingHorizontal`
|
|
32
|
+
* is net zero for layout, so the row's CONTENT stays on the container's edge —
|
|
33
|
+
* flush with the headings, labels and prose around it — while the wash gets its
|
|
34
|
+
* breathing room by growing into the parent's padding.
|
|
35
|
+
*
|
|
36
|
+
* The alternative (pad without the negative margin) is what the kit did in
|
|
37
|
+
* three places, and it fails in a way nobody reports as a bug: the row's content
|
|
38
|
+
* sits N px right of everything else on the page, so every neighbour that has to
|
|
39
|
+
* line up with it — a summary line, a question above a choice list, a table's
|
|
40
|
+
* own header — gets hand-padded by the same N at its call site. The indent then
|
|
41
|
+
* looks deliberate because it is everywhere, and the compensation spreads to
|
|
42
|
+
* each new surface. An indent is legible only when something VISIBLE occupies
|
|
43
|
+
* it (a checkbox, an ordinal, a mark); an empty one is a defect.
|
|
44
|
+
*
|
|
45
|
+
* The parent must afford this much horizontal padding. 8 is chosen because it is
|
|
46
|
+
* the smallest breathing room that reads, and because every container in the kit
|
|
47
|
+
* already clears it (`Drawer` 20, `Dialog` 24, the page canvas 28).
|
|
48
|
+
*/
|
|
49
|
+
export const ROW_WASH_BLEED = 8;
|
|
50
|
+
|
|
26
51
|
/** How far {@link FOCUS_RING} extends BEYOND the control's border box. Module-local
|
|
27
52
|
* on purpose: it is a FLOOR for anyone who clips (a container reserving less than
|
|
28
53
|
* this on an edge a focusable child sits against shears that child's ring), never a
|
package/src/dialog.tsx
CHANGED
|
@@ -50,6 +50,8 @@ export const DialogNavigationProvider = DialogNavigationContext.Provider;
|
|
|
50
50
|
interface DialogContextValue {
|
|
51
51
|
open: boolean;
|
|
52
52
|
onOpenChange: (open: boolean) => void;
|
|
53
|
+
/** The horizontal gutter every band inside THIS dialog shares — see {@link useDialogGutter}. */
|
|
54
|
+
gutter: number;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
const DialogContext = createContext<DialogContextValue | null>(null);
|
|
@@ -83,6 +85,39 @@ export interface DialogProps {
|
|
|
83
85
|
initialRoute?: string;
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
/**
|
|
89
|
+
* THE DIALOG'S HORIZONTAL GUTTER — every band inside a dialog reads this, so a
|
|
90
|
+
* header, a scroll area, a footer and any pane a caller drops in all share one
|
|
91
|
+
* left edge.
|
|
92
|
+
*
|
|
93
|
+
* It existed as the literal `small ? 16 : 24` at five sites (four here, one
|
|
94
|
+
* hand-copied into `AgentRunPane`), which is exactly how a band drifts: the copy
|
|
95
|
+
* has to be found and changed with the others, and the one that is missed is
|
|
96
|
+
* invisible until someone measures two bands against each other.
|
|
97
|
+
*/
|
|
98
|
+
function dialogGutterFor(small: boolean): number {
|
|
99
|
+
return small ? 16 : 24;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* THE gutter of the dialog you are inside — read, never re-derived.
|
|
104
|
+
*
|
|
105
|
+
* Centralising the VALUE was not enough, and that mistake shipped: `dialogGutter(small)` still
|
|
106
|
+
* left every band to work out `small` for itself, and `AgentRunPane` asked
|
|
107
|
+
* `useContainerSize()` (its own measured box, "small" at a 760px dialog) while the header,
|
|
108
|
+
* scroll area and footer asked `useScreenSize()` (the viewport, not small). Two honest answers
|
|
109
|
+
* to one question put the run's rows 8px left of the dialog's own title.
|
|
110
|
+
*
|
|
111
|
+
* So the Dialog decides ONCE and publishes it. A band inside a dialog gets that number
|
|
112
|
+
* whatever its own box measures; a surface rendered outside one falls back to the SCREEN — the
|
|
113
|
+
* same signal a dialog uses — so the two paths can never disagree about what "small" means.
|
|
114
|
+
*/
|
|
115
|
+
export function useDialogGutter(): number {
|
|
116
|
+
const ctx = useContext(DialogContext);
|
|
117
|
+
const screenSize = useScreenSize();
|
|
118
|
+
return ctx?.gutter ?? dialogGutterFor(screenSize.small);
|
|
119
|
+
}
|
|
120
|
+
|
|
86
121
|
export function Dialog(props: DialogProps) {
|
|
87
122
|
const {
|
|
88
123
|
testID,
|
|
@@ -127,12 +162,16 @@ export function Dialog(props: DialogProps) {
|
|
|
127
162
|
onOpenChange(false);
|
|
128
163
|
}, [onOpenChange]);
|
|
129
164
|
|
|
165
|
+
// Decided ONCE, here, and published — so a band never has to work out "small"
|
|
166
|
+
// for itself and land on a different answer than its siblings.
|
|
167
|
+
const gutter = dialogGutterFor(screenSize.small);
|
|
130
168
|
const dialogValue = useMemo(
|
|
131
169
|
(): DialogContextValue => ({
|
|
132
170
|
open,
|
|
133
171
|
onOpenChange,
|
|
172
|
+
gutter,
|
|
134
173
|
}),
|
|
135
|
-
[open, onOpenChange],
|
|
174
|
+
[open, onOpenChange, gutter],
|
|
136
175
|
);
|
|
137
176
|
|
|
138
177
|
const effectiveOffsetTop = screenSize.small ? 0 : offsetTop;
|
|
@@ -165,7 +204,7 @@ export function Dialog(props: DialogProps) {
|
|
|
165
204
|
>
|
|
166
205
|
<PortalHost>
|
|
167
206
|
<View testID={testID} style={[styles.dialogContainer, { borderRadius }]}>
|
|
168
|
-
<View style={[styles.closeButtonContainer, { paddingHorizontal:
|
|
207
|
+
<View style={[styles.closeButtonContainer, { paddingHorizontal: gutter }]}>
|
|
169
208
|
<IconButton icon="x" size="lg" accessibilityLabel={locale.overlay.close} onPress={handleClose} />
|
|
170
209
|
</View>
|
|
171
210
|
<SizeBoundary style={styles.container}>{children}</SizeBoundary>
|
|
@@ -199,10 +238,10 @@ export interface DialogHeaderProps {
|
|
|
199
238
|
export function DialogHeader(props: DialogHeaderProps) {
|
|
200
239
|
const { children, style } = props;
|
|
201
240
|
const { goBack, showBackButton } = useDialogNavigation();
|
|
202
|
-
const
|
|
241
|
+
const gutter = useDialogGutter();
|
|
203
242
|
|
|
204
243
|
return (
|
|
205
|
-
<View style={[styles.header, { paddingHorizontal:
|
|
244
|
+
<View style={[styles.header, { paddingHorizontal: gutter }, style]}>
|
|
206
245
|
{showBackButton && <BackButton onPress={goBack} />}
|
|
207
246
|
{children}
|
|
208
247
|
</View>
|
|
@@ -244,10 +283,10 @@ export interface DialogScrollAreaProps {
|
|
|
244
283
|
|
|
245
284
|
export function DialogScrollArea(props: DialogScrollAreaProps) {
|
|
246
285
|
const { children } = props;
|
|
247
|
-
const
|
|
286
|
+
const gutter = useDialogGutter();
|
|
248
287
|
|
|
249
288
|
return (
|
|
250
|
-
<ScrollView contentContainerStyle={[styles.scrollAreaContent, { paddingHorizontal:
|
|
289
|
+
<ScrollView contentContainerStyle={[styles.scrollAreaContent, { paddingHorizontal: gutter }]}>
|
|
251
290
|
{children}
|
|
252
291
|
</ScrollView>
|
|
253
292
|
);
|
|
@@ -264,12 +303,12 @@ export interface DialogFooterProps {
|
|
|
264
303
|
|
|
265
304
|
export function DialogFooter(props: DialogFooterProps) {
|
|
266
305
|
const { children, align = "end" } = props;
|
|
267
|
-
const
|
|
306
|
+
const gutter = useDialogGutter();
|
|
268
307
|
|
|
269
308
|
const justifyContent =
|
|
270
309
|
align === "end" ? "flex-end" : align === "space-between" ? "space-between" : "flex-start";
|
|
271
310
|
|
|
272
|
-
return <View style={[styles.footer, { justifyContent, paddingHorizontal:
|
|
311
|
+
return <View style={[styles.footer, { justifyContent, paddingHorizontal: gutter }]}>{children}</View>;
|
|
273
312
|
}
|
|
274
313
|
|
|
275
314
|
// ============================================================================
|
package/src/files_editor.tsx
CHANGED
|
@@ -351,7 +351,7 @@ export function FilesEditorRemove() {
|
|
|
351
351
|
return (
|
|
352
352
|
<Button
|
|
353
353
|
title={labels.delete}
|
|
354
|
-
color="danger
|
|
354
|
+
color="danger"
|
|
355
355
|
disabled={selected.length === 0}
|
|
356
356
|
onPress={() => confirmRemove(selected, () => { for (const f of selected) onRemove(f.id); exit(); })}
|
|
357
357
|
/>
|
|
@@ -33,9 +33,20 @@ export function FloatingActionBar(props: FloatingActionBarProps) {
|
|
|
33
33
|
return (
|
|
34
34
|
<View pointerEvents="box-none" style={styles.wrap}>
|
|
35
35
|
<Card style={styles.bar}>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
{/* TWO GROUPS, not one row of peers. The count and Clear are the bar's
|
|
37
|
+
own STATE — what is picked, and how to un-pick it; they act on the
|
|
38
|
+
selection, not on the records. `children` are the ACTS. A single gap
|
|
39
|
+
across all of them made four buttons read as four equal things, and
|
|
40
|
+
the emphasis ladder between them (muted / secondary / primary, which
|
|
41
|
+
encodes destructive vs benign vs promoted) then looked like
|
|
42
|
+
arbitrary variety rather than a ladder. Grouping states the kind
|
|
43
|
+
difference; the ladder is then read WITHIN the acts, where it means
|
|
44
|
+
something. */}
|
|
45
|
+
<View style={styles.state}>
|
|
46
|
+
<Text size="sm" weight="semibold" tabular>{`${count} ${label}`}</Text>
|
|
47
|
+
<Button title={clearLabel} color="muted" onPress={onClear} />
|
|
48
|
+
</View>
|
|
49
|
+
<View style={styles.acts}>{children}</View>
|
|
39
50
|
</Card>
|
|
40
51
|
</View>
|
|
41
52
|
);
|
|
@@ -48,13 +59,31 @@ const styles = StyleSheet.create({
|
|
|
48
59
|
left: 0,
|
|
49
60
|
right: 0,
|
|
50
61
|
alignItems: "center",
|
|
62
|
+
// Keeps the bar off the screen edges at any width — and, with the bar's
|
|
63
|
+
// `maxWidth`, is what bounds it to the viewport on a phone.
|
|
64
|
+
paddingHorizontal: 16,
|
|
51
65
|
zIndex: 20,
|
|
52
66
|
},
|
|
53
67
|
bar: {
|
|
54
68
|
flexDirection: "row",
|
|
55
69
|
alignItems: "center",
|
|
56
|
-
|
|
70
|
+
justifyContent: "center",
|
|
71
|
+
// A count plus an escape plus three verbs is ~520px of content — wider than
|
|
72
|
+
// a phone. Unbounded it simply hung off both edges, taking the promoted act
|
|
73
|
+
// with it, and no host could fix that without re-authoring the bar. So it
|
|
74
|
+
// WRAPS, and the grouping decides where: `state` and `acts` are each a
|
|
75
|
+
// nowrap row, so the only break available is the boundary between them —
|
|
76
|
+
// the line splits exactly where the meaning does (what is picked / what to
|
|
77
|
+
// do with it), never mid-ladder with Download orphaned from Use AI.
|
|
78
|
+
flexWrap: "wrap",
|
|
79
|
+
maxWidth: "100%",
|
|
80
|
+
// The biggest boundary in the bar — state | acts — gets the most air. The
|
|
81
|
+
// two groups below sit on the kit's action gap. Doubles as the row gap once
|
|
82
|
+
// wrapped, which is the same boundary seen vertically.
|
|
83
|
+
gap: 20,
|
|
57
84
|
paddingVertical: 10,
|
|
58
85
|
paddingHorizontal: 16,
|
|
59
86
|
},
|
|
87
|
+
state: { flexDirection: "row", alignItems: "center", gap: 8 },
|
|
88
|
+
acts: { flexDirection: "row", alignItems: "center", gap: 8 },
|
|
60
89
|
});
|
package/src/locale.tsx
CHANGED
|
@@ -193,6 +193,7 @@ export interface LoticsLocale {
|
|
|
193
193
|
output: string;
|
|
194
194
|
awaiting: string;
|
|
195
195
|
retry: string;
|
|
196
|
+
stop: string;
|
|
196
197
|
tools: Record<string, string>;
|
|
197
198
|
steps: (n: number) => string;
|
|
198
199
|
};
|
|
@@ -341,6 +342,7 @@ export const en: LoticsLocale = {
|
|
|
341
342
|
output: "Output",
|
|
342
343
|
awaiting: "Awaiting",
|
|
343
344
|
retry: "Retry",
|
|
345
|
+
stop: "Stop",
|
|
344
346
|
tools: {
|
|
345
347
|
query_records: "Searching records",
|
|
346
348
|
get_record: "Reading a record",
|
|
@@ -493,6 +495,7 @@ export const vi: LoticsLocale = {
|
|
|
493
495
|
output: "Kết quả",
|
|
494
496
|
awaiting: "Chờ duyệt",
|
|
495
497
|
retry: "Thử lại",
|
|
498
|
+
stop: "Dừng",
|
|
496
499
|
tools: {
|
|
497
500
|
query_records: "Đang tìm dữ liệu",
|
|
498
501
|
get_record: "Đang đọc một bản ghi",
|
package/src/mime.ts
CHANGED
|
@@ -73,7 +73,10 @@ const GENERIC_MIME_TYPES: ReadonlySet<string> = new Set([
|
|
|
73
73
|
]);
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
* Extension → MIME, for the document and
|
|
76
|
+
* Extension → MIME, for the document, image and A/V formats an intake surface
|
|
77
|
+
* names. Values are taken verbatim from the server`s table (`mime-types`, via
|
|
78
|
+
* `@lotics/shared/mime_resolution`) — `mime_parity.test.ts` pins them, and an
|
|
79
|
+
* intake that knows LESS than the server silently drops a storable file.
|
|
77
80
|
* A `Map`, not an object literal, so a file named `x.constructor` looks up a
|
|
78
81
|
* miss rather than `Object.prototype`'s member.
|
|
79
82
|
*/
|
|
@@ -101,6 +104,21 @@ const MIME_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
|
|
|
101
104
|
["xls", "application/vnd.ms-excel"],
|
|
102
105
|
["docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
|
|
103
106
|
["doc", "application/msword"],
|
|
107
|
+
// A/V: an `audio/*` or `video/*` intake resolves a pasted recording only if
|
|
108
|
+
// its extension is here — the extension-pattern form (`.m4a`) never needed it.
|
|
109
|
+
["m4a", "audio/mp4"],
|
|
110
|
+
["mp3", "audio/mpeg"],
|
|
111
|
+
["wav", "audio/wave"],
|
|
112
|
+
["ogg", "audio/ogg"],
|
|
113
|
+
["opus", "audio/ogg"],
|
|
114
|
+
["flac", "audio/x-flac"],
|
|
115
|
+
["aac", "audio/x-aac"],
|
|
116
|
+
["mp4", "video/mp4"],
|
|
117
|
+
["m4v", "video/x-m4v"],
|
|
118
|
+
["mov", "video/quicktime"],
|
|
119
|
+
["webm", "video/webm"],
|
|
120
|
+
["avi", "video/x-msvideo"],
|
|
121
|
+
["mkv", "video/x-matroska"],
|
|
104
122
|
]);
|
|
105
123
|
|
|
106
124
|
/** What a filename's extension says the file is, or `""` when it says nothing. */
|
package/src/pressable_row.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ReactNode, Ref, useCallback, useState } from "react";
|
|
2
|
+
import { ROW_WASH_BLEED } from "./control_surface";
|
|
2
3
|
import { Pressable, StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
3
4
|
import { colors } from "./colors";
|
|
4
5
|
import { pressSelectedText } from "./press_selection";
|
|
@@ -108,23 +109,26 @@ const styles = StyleSheet.create({
|
|
|
108
109
|
// the text it had no business covering.
|
|
109
110
|
...({ cursor: "pointer", transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
|
|
110
111
|
},
|
|
111
|
-
// THE register row — FULL-WIDTH rounded highlight
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
112
|
+
// THE register row — FULL-WIDTH rounded highlight that BLEEDS past the content
|
|
113
|
+
// (`ROW_WASH_BLEED`, net-zero margin+padding), so the row's content sits on the
|
|
114
|
+
// container's own edge and the wash still has breathing room. It used to pad 20
|
|
115
|
+
// WITHOUT the negative margin, which put every table's first column 20px right
|
|
116
|
+
// of the headings above it and made `SummaryLine` call sites hand-pad 20 to
|
|
117
|
+
// catch up. `inset` below has always done it correctly; this is the same rule.
|
|
116
118
|
register: {
|
|
117
119
|
borderRadius: 10,
|
|
118
|
-
paddingHorizontal:
|
|
120
|
+
paddingHorizontal: ROW_WASH_BLEED,
|
|
121
|
+
marginHorizontal: -ROW_WASH_BLEED,
|
|
119
122
|
},
|
|
120
123
|
// Square, full-bleed — lines up with the Dividers between register rows. Legacy.
|
|
121
124
|
bleed: {
|
|
122
|
-
paddingHorizontal:
|
|
125
|
+
paddingHorizontal: ROW_WASH_BLEED,
|
|
126
|
+
marginHorizontal: -ROW_WASH_BLEED,
|
|
123
127
|
},
|
|
124
128
|
// Rounded, pulled in from the edge — matches the Accordion's inset rows.
|
|
125
129
|
inset: {
|
|
126
130
|
borderRadius: 8,
|
|
127
|
-
paddingHorizontal:
|
|
128
|
-
marginHorizontal: -
|
|
131
|
+
paddingHorizontal: ROW_WASH_BLEED,
|
|
132
|
+
marginHorizontal: -ROW_WASH_BLEED,
|
|
129
133
|
},
|
|
130
134
|
});
|
package/src/table_fit.ts
CHANGED
|
@@ -30,10 +30,22 @@ export interface TableFitColumn {
|
|
|
30
30
|
/** Gap between the header band's / a row's children — `table.tsx` styles consume
|
|
31
31
|
* this, so the fit math and the rendered geometry can't drift. */
|
|
32
32
|
export const COLUMN_GAP = 14;
|
|
33
|
-
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
|
|
33
|
+
/**
|
|
34
|
+
* The register's CONTENT gutter — zero, because the washed row bleeds instead.
|
|
35
|
+
*
|
|
36
|
+
* It was 20, mirroring `PressableRow`'s register variant, and every unwashed
|
|
37
|
+
* part of a table (the header band, static rows, an expanded detail) padded by
|
|
38
|
+
* the same 20 purely to line up with the washed row's inset content. That put a
|
|
39
|
+
* table's first column 20px right of the section heading above it with nothing
|
|
40
|
+
* visible in the gap. The wash now grows outward (`ROW_WASH_BLEED`) rather than
|
|
41
|
+
* pushing content in, so every part of a table sits on its container's edge and
|
|
42
|
+
* the five `table.tsx` sites that read this constant follow automatically.
|
|
43
|
+
*
|
|
44
|
+
* Kept as a named constant rather than deleted: the header band, the rows and
|
|
45
|
+
* the fit math must agree, and a literal repeated at five sites is how they
|
|
46
|
+
* stopped agreeing before.
|
|
47
|
+
*/
|
|
48
|
+
export const ROW_GUTTER = 0;
|
|
37
49
|
const ROW_H_PADDING = ROW_GUTTER * 2;
|
|
38
50
|
/** Fit-math width a flexible column needs to stay usable — below this the flex
|
|
39
51
|
* column is crushed to ellipsis soup, so it counts as this wide when deciding
|