@lotics/ui 43.6.0 → 43.7.1
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 +1 -1
- package/docs/catalog.md +3 -1
- package/docs/composition.md +42 -12
- package/examples/tpl_attendance.tsx +5 -2
- package/examples/tpl_dashboard.tsx +4 -2
- package/examples/tpl_item_list.tsx +34 -101
- package/examples/tpl_record.tsx +55 -105
- package/package.json +1 -1
- package/src/text.css +42 -17
- package/src/text.tsx +34 -2
- package/src/type_ramp.ts +34 -0
package/AGENTS.md
CHANGED
|
@@ -17,7 +17,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
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) | **a picker over a select field keeps the field's colours** (`optionPicker` — the hand-rolled `map` to `{value,label}` drops `color`, so a value reads coloured in a register and grey in the editor); 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 (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — 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, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing), the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
|
|
19
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; **after the run** — a stored record that fills up from several writers (a person, a chat agent, an extraction, an automation) and remembers none of them: an unwritten value must not render like a written one, a machine's prose and a person's must not share a treatment, model markdown goes in `variant="embedded"`, and the read path must project every field the write paths set; 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
|
-
| [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 (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), 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), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size — plus the half that rule was missing: every
|
|
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 (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), 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), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size — plus the half that rule was missing: every SUBJECT on one row starts at the same rung, the subject leads by WEIGHT and its identity mark rather than by size, and whether the supporting line drops a rung is decided by what else separates the pair), **identity marks** (a mark that looks the same on every row carries nothing — `Avatar` derives its hue from the name and takes circle/square for person vs organization; the palette is ordered by contrast, not spectrum, and never paints a person red or amber), **where the accent goes** ("which of these" — a selected row, an active filter; never DATA and never the focus ring, while a page-scale position marker like an active TAB takes `primary`, the ink the kit already spends on "this is on"; and selection must not share a wash with hover), **theming is an app-level ESCAPE HATCH, not a product surface** (`applyLoticsTheme({ … })` once at boot, before `mount`, writing the `--lotics-*` roles on the document root; nothing in the platform writes a theme, and `apps.theme.color` is the launcher icon only — the five `var()` roles plus the three font rungs ARE the whole mechanism, and wrapping a component cannot replace them because RN-Web writes `fontFamily` per element and background/border do not inherit), color discipline, **typography** (the ramp is DATA in `type_ramp`, one table both platforms derive from; leading and tracking are baked per rung and a hand-set `letterSpacing` is always a second copy of the curve), whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
|
|
21
21
|
| [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built** — the other docs say what good looks like; this one says how to find out whether you achieved it, because "it looks fine" is the same claim as "it should work" made with the same evidence. Measure, never eyeball: render it, extract computed values, let the table show the defect. Two GATES that outrank every treatment question (**SUBTRACT** — what question does this element answer, and count FACTS not elements; **RIGHT CONTROL** — name the species before treating it), then 10 probes each naming what to COLLECT and the SIGNATURE in the numbers: type inventory (range, singletons, the label/value shrink war), ground+border+radius across siblings, what each RULE separates, gap RATIOS, the state diff (paint may change, geometry may not), alignment drift (incl. a centred child that moves while its container measures identical), the surface walk (open it, press it, read the settled string), the DATA probe (what is really in the table — incl. grepping rendered text for internal ids), the ABSENCE pass that catches "bland", and microcopy. Plus the extraction snippets, what measurement cannot see, and how to act on a finding (fix the CLASS, and never resolve one with "the docs say X"). |
|
|
22
22
|
| [docs/testing.md](./docs/testing.md) | Driving the kit in a browser — the three anatomies where the a11y tree says one thing and a driver must do another: a `PressDoor` row whose named button always intercepts pointer events (by design), portalled overlays that render at the top of the DOM, and custom pointer drag that `dragTo` cannot move. |
|
|
23
23
|
| [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) and the ACTIVITY shape — a communications feed where the row's label is the GIST and the body varies by medium, one anatomy rather than a row type per kind. |
|
package/docs/catalog.md
CHANGED
|
@@ -400,7 +400,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
400
400
|
|
|
401
401
|
- **`text`** — the `Text` primitive (never raw `div`/`span`/`fontSize`): `size` `xs`–`xxxl`
|
|
402
402
|
(the heading ramp: `xl` = `##` section title, `xxl` = `#` page/record title, `xxxl` = hero
|
|
403
|
-
numbers), `weight`, `color`, alignment, tabular numerals.
|
|
403
|
+
numbers), `weight`, `color`, alignment, tabular numerals. **`leading="tight"`** sets the line
|
|
404
|
+
box for a STACKED PAIR rather than for prose — a subject over its supporting line, a value over
|
|
405
|
+
its annotation; see composition.md, which also covers the `gap` that goes with it.
|
|
404
406
|
- **`markdown`** — `Markdown`: the single canonical markdown renderer for chat, apps, and
|
|
405
407
|
`AgentRun`; rich GFM markdown on web with copyable tables, plain-text on
|
|
406
408
|
native; takes a markdown `children` string. **`variant`** decides whose type
|
package/docs/composition.md
CHANGED
|
@@ -1165,20 +1165,50 @@ wider pair then reads as though its second line shrank rather than stepped. Pick
|
|
|
1165
1165
|
relative to the primary and the pair scales together. The probe: collect every primary/supporting
|
|
1166
1166
|
pair on the screen and divide. A spread in the RATIOS is the finding, not a spread in the sizes.
|
|
1167
1167
|
|
|
1168
|
-
**
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
shouted while everything else receded.
|
|
1168
|
+
**Every SUBJECT on one row starts at the same rung — a register's is `sm`, and a value in any
|
|
1169
|
+
other column is `sm` too.** "One rung below" is only half a rule: it constrains the STEP and says
|
|
1170
|
+
nothing about where the pair starts, so a subject placed a rung high stays compliant while dragging
|
|
1171
|
+
its whole column above the rest. Measured on a register that read as disproportionate: the identity
|
|
1172
|
+
column ran 16/14 while the column beside it ran 14/12 — both correctly "one rung apart", one whole
|
|
1173
|
+
rung out of step with each other, and the first column shouted while everything else receded.
|
|
1175
1174
|
|
|
1176
1175
|
**The subject leads by WEIGHT and by its identity mark, not by size.** `medium` against `regular`,
|
|
1177
|
-
plus the avatar or tile that answers "which row is this", is enough
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1176
|
+
plus the avatar or tile that answers "which row is this", is enough. Money is the case that most
|
|
1177
|
+
often argues for a size step and should not get one: tabular figures and right alignment already
|
|
1178
|
+
mark it, and a lone larger value on a row reads as a mistake rather than as emphasis. One larger
|
|
1179
|
+
thing among peers is not emphasis; it is the thing that looks broken.
|
|
1180
|
+
|
|
1181
|
+
**Whether the SUPPORTING line drops a rung is decided by what else separates the pair — not by
|
|
1182
|
+
where it sits and not by what it carries.** `color="muted"` is already one channel of demotion, so
|
|
1183
|
+
the size step is a second, and a second is only worth its cost where nothing else is doing the job:
|
|
1184
|
+
|
|
1185
|
+
- The subject carries `medium`, or the row opens with an identity mark — the supporting line stays
|
|
1186
|
+
at **`sm` muted**. Weight and the mark separate the pair; colour finishes it, and the line keeps
|
|
1187
|
+
body size. This matters most where that line is the DISAMBIGUATOR (which person, at which
|
|
1188
|
+
company): `xs` demotes content the reader needs, and 12px is where stacked diacritics crowd.
|
|
1189
|
+
- Both lines are regular weight and colour is the only thing between them — the supporting line
|
|
1190
|
+
drops to **`xs`**. Measured: a date over its touch count, promoted to one rung, stopped reading
|
|
1191
|
+
as a value and its annotation and started reading as two peers. The step is what makes them a
|
|
1192
|
+
pair.
|
|
1193
|
+
|
|
1194
|
+
Deciding from the CONTENT instead — prose gets `sm`, reference data gets `xs` — fails on a line
|
|
1195
|
+
that holds both. An account name beside a contract number is one line, and it takes ONE rung: the
|
|
1196
|
+
pair's, not each half's.
|
|
1197
|
+
|
|
1198
|
+
**A STACKED PAIR takes `leading="tight"` — it is not prose.** The ramp's body rungs carry prose
|
|
1199
|
+
leading (14/24, ratio 1.71), which is right for a paragraph and wrong for two lines that are one
|
|
1200
|
+
object: it leaves ~10px of empty box between them, so the pair reads as two separate lines. Beside
|
|
1201
|
+
a 40px mark the same fault is measurable rather than a matter of taste — the stack comes to 50 and
|
|
1202
|
+
overhangs the mark it belongs to. Tight is `size + 6`, which is what `xs` has always been, so this
|
|
1203
|
+
is the smallest rung's own rule extended up the ladder rather than a second opinion about leading.
|
|
1204
|
+
|
|
1205
|
+
Set it on BOTH lines where both are body rungs; where the supporting line is `xs` it is already
|
|
1206
|
+
tight, so only the subject carries the prop. Then the stack's own `gap` goes to **0** — each tight
|
|
1207
|
+
line box already contributes 3px above and below, giving 6px between the two, and a `gap` on top
|
|
1208
|
+
is a third source of space between lines that are meant to read as one. Measured after: the
|
|
1209
|
+
identity stack sits at exactly the mark's 40, and every pair on the screen carries the same 6px.
|
|
1210
|
+
|
|
1211
|
+
Running text keeps prose leading. The default is for paragraphs, and `.ui-markdown` renders at it.
|
|
1182
1212
|
|
|
1183
1213
|
**A CONTROL riding a text line must not resize that line.** A control is taller than the text it
|
|
1184
1214
|
sits beside — a 24px `CopyButton` on an 18px `xs` line — so a flex row containing both is sized by
|
|
@@ -166,8 +166,11 @@ function TuanRow({ nv }: { nv: NhanVien }) {
|
|
|
166
166
|
<Accordion>
|
|
167
167
|
<AccordionHeader accessibilityLabel={`Week detail for ${nv.ten}`}>
|
|
168
168
|
<View style={{ flex: 1, gap: 2 }}>
|
|
169
|
-
<Text size="sm" weight="medium" numberOfLines={1}>{nv.ten}</Text>
|
|
170
|
-
|
|
169
|
+
<Text size="sm" weight="medium" leading="tight" numberOfLines={1}>{nv.ten}</Text>
|
|
170
|
+
{/* Body size: `medium` above already separates the pair, so the rung
|
|
171
|
+
down would be a second demotion on the line that says WHICH
|
|
172
|
+
person this is. */}
|
|
173
|
+
<Text size="sm" color="muted" leading="tight" numberOfLines={1}>{nv.chucVu}</Text>
|
|
171
174
|
</View>
|
|
172
175
|
<View style={{ flexDirection: "row", gap: 10 }}>
|
|
173
176
|
{nv.tuan.map((ngay, i) => <DayCell key={NGAY_TUAN[i]} ngay={ngay} />)}
|
|
@@ -503,9 +503,11 @@ export function TplDashboard() {
|
|
|
503
503
|
<Avatar name={kh.ten} size="md" />
|
|
504
504
|
<View style={{ flex: 1, gap: 0, alignItems: "flex-start" }}>
|
|
505
505
|
<Peek accessibilityLabel={`Customer profile for ${kh.ten}`} content={<KhachPeek kh={kh} />}>
|
|
506
|
-
<Text size="sm" weight="medium" numberOfLines={1}>{kh.ten}</Text>
|
|
506
|
+
<Text size="sm" weight="medium" leading="tight" numberOfLines={1}>{kh.ten}</Text>
|
|
507
507
|
</Peek>
|
|
508
|
-
|
|
508
|
+
{/* Body size — the mark and the name's weight separate this
|
|
509
|
+
pair; see composition.md on what decides the rung. */}
|
|
510
|
+
<Text size="sm" color="muted" leading="tight" numberOfLines={1}>{kh.nganh}</Text>
|
|
509
511
|
</View>
|
|
510
512
|
<Sparkline data={kh.trend} width={84} height={24} color={kh.trend[5] >= kh.trend[0] ? solid("emerald") : solid("red")} />
|
|
511
513
|
<Text size="sm" weight="medium" tabular>{formatMoney(kh.doanhThu)}</Text>
|
|
@@ -223,90 +223,36 @@ function HoSoRow({ hs, ordinal, daThu, selected, marked, selectable, onToggle, o
|
|
|
223
223
|
trailing={<ActionMenu items={menuFor(hs, daThu)} accessibilityLabel={`Actions for ${hs.ma}`} />}
|
|
224
224
|
>
|
|
225
225
|
<TableCell>
|
|
226
|
-
{/*
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
the marks differ across rows without anyone configuring anything —
|
|
231
|
-
the failure mode to avoid is N identical marks, which is the largest
|
|
232
|
-
and brightest thing on the row carrying no information at all.
|
|
233
|
-
|
|
234
|
-
It sits in the SUBJECT'S CELL, not the `leading` slot: `leading`
|
|
235
|
-
already holds the selection checkbox, and those two are different
|
|
236
|
-
jobs — one is what the row IS, the other is something you are doing
|
|
237
|
-
to it. Stacking them in one slot reads as a single compound control.
|
|
238
|
-
|
|
239
|
-
`lg` because the customer is what this row is ABOUT. A person who is
|
|
240
|
-
one FIELD among columns (the owner, below) takes `md`; see
|
|
241
|
-
`avatar_size.ts`, which owns that rule.
|
|
242
|
-
|
|
243
|
-
ONE SHAPE for the whole register. `Avatar` takes `square` for an
|
|
244
|
-
organization, but that distinction earns its place only where a
|
|
245
|
-
surface mixes entity TYPES and the reader acts on which is which.
|
|
246
|
-
Here every row is a record, and whether the customer is a company is
|
|
247
|
-
not something the reader does anything with — so a column of mixed
|
|
248
|
-
circles and squares reads as an inconsistency rather than as
|
|
249
|
-
information, which is exactly how it gets reported. */}
|
|
226
|
+
{/* The mark goes in the SUBJECT'S cell, not the `leading` slot —
|
|
227
|
+
`leading` holds the selection checkbox, and "what this row is" and
|
|
228
|
+
"something you are doing to it" in one slot read as a single
|
|
229
|
+
compound control. Rung and shape per composition.md § Identity. */}
|
|
250
230
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, alignSelf: "stretch" }}>
|
|
251
231
|
<Avatar name={hs.khach} size="lg" />
|
|
252
|
-
<View style={{ gap:
|
|
232
|
+
<View style={{ gap: 0, flex: 1, minWidth: 0 }}>
|
|
253
233
|
{/* The name takes the row, the thread pins right. A comment count is
|
|
254
234
|
the one thing on this row that is about PEOPLE talking rather than
|
|
255
235
|
the record's own state, so it sits apart from the values below it
|
|
256
236
|
instead of joining them on the supporting line. */}
|
|
257
237
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
258
|
-
<Text size="sm" weight="medium" numberOfLines={1} style={{ flexShrink: 1 }}>{hs.khach}</Text>
|
|
238
|
+
<Text size="sm" weight="medium" leading="tight" numberOfLines={1} style={{ flexShrink: 1 }}>{hs.khach}</Text>
|
|
259
239
|
{/* Rendered only where a thread EXISTS — a zero on every quiet row is a
|
|
260
240
|
column of noise that trains the eye to skip where the signal appears. */}
|
|
261
241
|
{hs.binhLuan > 0 ? <CommentsButton count={hs.binhLuan} subject={hs.khach} onPress={onOpenComments} /> : null}
|
|
262
242
|
</View>
|
|
263
|
-
{/*
|
|
264
|
-
its
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
most interesting: the line has to be honest on every row to be
|
|
277
|
-
reservable at all. */}
|
|
278
|
-
{/* HEIGHT PINNED TO THE xs LINE BOX (18), because this line carries a
|
|
279
|
-
CONTROL. `CopyButton` at `sm` is 24 — sized to sit beside `sm`
|
|
280
|
-
text, per its own doc — so on an `xs` line it made the row 24 and
|
|
281
|
-
`alignItems: "center"` spent 3 of those px above the text. The
|
|
282
|
-
supporting line then started 5px under the name while the Fee
|
|
283
|
-
column's identical primary-over-supporting pair, which carries no
|
|
284
|
-
control, started 2px under its own. Same shape, two treatments,
|
|
285
|
-
and the reason (one of them has a button) is invisible to a
|
|
286
|
-
reader — which is the whole test.
|
|
287
|
-
|
|
288
|
-
Pinning the row to the text's line box lets the 24px hit area
|
|
289
|
-
overflow it without moving the text. Any verb riding a supporting
|
|
290
|
-
line needs this; a control must not resize the line it sits on. */}
|
|
291
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, height: 18 }}>
|
|
292
|
-
{/* The KEY, muted, on the supporting line — at most this, never a
|
|
293
|
-
column and never the row's name.
|
|
294
|
-
|
|
295
|
-
It does NOT shrink. A key is quoted whole or it is not a key: a
|
|
296
|
-
phone number clipped to "(555) 384-7…" is still recognisably a
|
|
297
|
-
number the reader can widen the column to finish, while
|
|
298
|
-
"RC-2026-0…" is unusable — it is the exact string a desk reads
|
|
299
|
-
out. So the fixed-length identifier holds its width and the phone
|
|
300
|
-
gives way, which is also the right order of loss: the phone has a
|
|
301
|
-
Copy control beside it that hands over the full value regardless. */}
|
|
302
|
-
<Text size="xs" color="muted" tabular numberOfLines={1} style={{ flexShrink: 0 }}>{hs.ma}</Text>
|
|
303
|
-
{/* A GAP, not a middot. Two facts of different kinds sat either side
|
|
304
|
-
of a ` · ` here, which is the separator this kit bans outright —
|
|
305
|
-
punctuation claiming a relation it refuses to name, and dropped
|
|
306
|
-
entirely by a screen reader. Spacing separates them without
|
|
307
|
-
asserting anything, and the two are already told apart by shape:
|
|
308
|
-
one is a key, one is a number with a Copy control on it. */}
|
|
309
|
-
<Text size="xs" color="muted" tabular numberOfLines={1} style={{ flexShrink: 1, marginLeft: 8 }}>{hs.dienThoai}</Text>
|
|
243
|
+
{/* UNCONDITIONAL — the load-bearing part of this shape. A row centres
|
|
244
|
+
its cells, so a supporting line that renders only when its field is
|
|
245
|
+
set makes this cell two heights, and the name above it then sits at
|
|
246
|
+
two different offsets down the column while every row still measures
|
|
247
|
+
the same. So the values here are ones every record carries. */}
|
|
248
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 2 }}>
|
|
249
|
+
{/* The key does NOT shrink. Quoted whole or it is not a key: a phone
|
|
250
|
+
clipped to "(555) 384-7…" is still a number you can widen the
|
|
251
|
+
column to finish, while "RC-2026-0…" is unusable. So the
|
|
252
|
+
fixed-length identifier holds its width and the phone gives way —
|
|
253
|
+
and the phone has a Copy control that hands over the full value. */}
|
|
254
|
+
<Text size="sm" color="muted" tabular leading="tight" numberOfLines={1} style={{ flexShrink: 0 }}>{hs.ma}</Text>
|
|
255
|
+
<Text size="sm" color="muted" tabular leading="tight" numberOfLines={1} style={{ flexShrink: 1, marginLeft: 8 }}>{hs.dienThoai}</Text>
|
|
310
256
|
<CopyButton value={hs.dienThoai} label="Copy phone number" />
|
|
311
257
|
</View>
|
|
312
258
|
</View>
|
|
@@ -378,12 +324,10 @@ function RecordDrawer({ hs, daThu: _daThu, onPaid: _onPaid, onClose, onPrev, onN
|
|
|
378
324
|
position={canGoBack ? undefined : position}
|
|
379
325
|
>
|
|
380
326
|
<Screen route="">
|
|
381
|
-
{/* THE RECORD SURFACE, not a copy of it
|
|
382
|
-
chrome: same sections, same order, same rules
|
|
383
|
-
discussion collapse (a Drawer has no gutters)
|
|
384
|
-
reading order as its last section.
|
|
385
|
-
the two identical; the hand-built workspace that used to live here is
|
|
386
|
-
how they drifted — a checklist on this side, a pipeline on that one. */}
|
|
327
|
+
{/* THE RECORD SURFACE, not a copy of it — `tpl_record` in `drawer`
|
|
328
|
+
chrome: same sections, same order, same rules. The rail and the docked
|
|
329
|
+
discussion collapse (a Drawer has no gutters) and the thread joins the
|
|
330
|
+
reading order as its last section. */}
|
|
387
331
|
<TplRecord chrome="drawer" code={hs.ma} openSection={openSection} />
|
|
388
332
|
</Screen>
|
|
389
333
|
<Screen route="/record/:ma">{(p) => <LinkedRecordScreen ma={p.ma} />}</Screen>
|
|
@@ -423,15 +367,9 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
|
|
|
423
367
|
<DetailRow label="Fee">
|
|
424
368
|
<InlineNumberInput value={fee} onSave={persist(setFee)} min={0} format={(v) => (v == null ? "" : formatMoney(v))} accessibilityLabel="Fee" />
|
|
425
369
|
</DetailRow>
|
|
426
|
-
{/* FILES AS A FIELD
|
|
427
|
-
|
|
428
|
-
`
|
|
429
|
-
decisions and usually missing `InlineSlot` (which is what keeps
|
|
430
|
-
this value column a column rather than two left edges).
|
|
431
|
-
|
|
432
|
-
The MANY case: `multiple` defaults true, the CTA becomes
|
|
433
|
-
"Add document" once something is attached, and the host APPENDS.
|
|
434
|
-
`tpl_record` shows the single-file case, where the verb is
|
|
370
|
+
{/* FILES AS A FIELD, the MANY case: `multiple` defaults true, the CTA
|
|
371
|
+
becomes "Add document" once something is attached, and the host
|
|
372
|
+
APPENDS. `tpl_record` shows the single-file case, where the verb is
|
|
435
373
|
replace — see the table in data_entry.md §InlineFiles. */}
|
|
436
374
|
<DetailRow label="Documents">
|
|
437
375
|
<InlineFiles
|
|
@@ -1049,19 +987,14 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
|
|
|
1049
987
|
</DialogFooter>
|
|
1050
988
|
) : phase === "analyze" || phase === "clarify" || phase === "running" ? (
|
|
1051
989
|
<DialogFooter>
|
|
1052
|
-
{/* EVERY run-driven phase, not just `clarify`.
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
`onStop` is the SAME handler the pane's `onCancel` gets: stopping
|
|
1062
|
-
a streaming run and abandoning a parked question both put the
|
|
1063
|
-
operator back at the intake they started from, with their files
|
|
1064
|
-
and their answers still there to retry from. */}
|
|
990
|
+
{/* EVERY run-driven phase, not just `clarify`. The contract is that the
|
|
991
|
+
HOST mounts this wherever the run drives the surface and the RUN
|
|
992
|
+
decides what it holds; gating it by phase out here duplicates that
|
|
993
|
+
decision and gets it wrong the moment the run learns a new phase.
|
|
994
|
+
|
|
995
|
+
`onStop` is the SAME handler the pane's `onCancel` gets: stopping a
|
|
996
|
+
stream and abandoning a parked question both put the operator back
|
|
997
|
+
at the intake, files and answers intact. */}
|
|
1065
998
|
<AgentRunActions run={run} onStop={() => setPhase("intake")} />
|
|
1066
999
|
</DialogFooter>
|
|
1067
1000
|
) : phase === "preview" ? (
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -2931,20 +2931,19 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2931
2931
|
divider above the section for every section before it. */}
|
|
2932
2932
|
{/* The header band sits ABOVE the stack, on the canvas beat — it is the
|
|
2933
2933
|
record's IDENTITY, not a section, so the 56px between-sections rhythm
|
|
2934
|
-
(and, in scroll mode,
|
|
2935
|
-
|
|
2936
|
-
|
|
2934
|
+
(and, in scroll mode, its hairline) does not apply. A condition belongs
|
|
2935
|
+
on the FIELD it concerns, with a dot on the rail naming the section to
|
|
2936
|
+
look in — never announced up here away from what it is about. */}
|
|
2937
2937
|
<View style={{ flex: 1, minWidth: 0, gap: 28 }}>
|
|
2938
2938
|
{/* The record's own ACTS live here, beside its identity — not inside a
|
|
2939
|
-
section. Dropping a file is something you do TO THE RECORD,
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
slot on RecordSummary at its second consumer. */}
|
|
2939
|
+
section. Dropping a file is something you do TO THE RECORD, so an
|
|
2940
|
+
affordance for it that lives only in the Files section is scoped to
|
|
2941
|
+
that section: from anywhere else on a long page nothing says the
|
|
2942
|
+
record takes files at all, and a whole-record drop target nobody can
|
|
2943
|
+
see is an accelerator, never an entry point. The Files register keeps
|
|
2944
|
+
its own Add — a register's Add belongs under its register — and this
|
|
2945
|
+
is the record-level way in. A template composition for now; it earns a
|
|
2946
|
+
kit slot on RecordSummary at its second consumer. */}
|
|
2948
2947
|
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
|
|
2949
2948
|
<View style={{ flex: 1, minWidth: 0 }}>
|
|
2950
2949
|
{/* The SUBJECT leads, the key goes under it. A reader talks about
|
|
@@ -3165,31 +3164,17 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3165
3164
|
/>
|
|
3166
3165
|
) : (
|
|
3167
3166
|
<>
|
|
3168
|
-
{/* AN ENTRY, NOT A LOG LINE —
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
the
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
edits. They were never independent: each one was the
|
|
3180
|
-
arrangement pushing back.
|
|
3181
|
-
|
|
3182
|
-
So the gist is not a label. It is a RESIDENT field — always
|
|
3183
|
-
editable, in place, no mode and no Edit button, the same as
|
|
3184
|
-
every other authored value on this record. The old argument
|
|
3185
|
-
against a resident editor here ("the gist is already the row's
|
|
3186
|
-
label, so it renders the sentence twice") disappears the moment
|
|
3187
|
-
it stops being one.
|
|
3188
|
-
|
|
3189
|
-
What is left is the shape every comment UI converged on: a
|
|
3190
|
-
metadata header, the prose under it, the row's own actions at
|
|
3191
|
-
the TOP RIGHT beside the text they act on, and the heavy
|
|
3192
|
-
artifacts behind their own disclosure. */}
|
|
3167
|
+
{/* AN ENTRY, NOT A LOG LINE — which is why this is not a `Timeline`.
|
|
3168
|
+
`Timeline` puts its label INSIDE the row's press target, right
|
|
3169
|
+
for a derived read-only event ("Stage changed to Won") and wrong
|
|
3170
|
+
for a sentence a person WROTE: text inside a button cannot be
|
|
3171
|
+
edited where it sits.
|
|
3172
|
+
|
|
3173
|
+
So the gist is a RESIDENT field — always editable in place, no
|
|
3174
|
+
mode, no Edit button, like every other authored value here. The
|
|
3175
|
+
shape every comment UI converges on: a metadata header, the prose
|
|
3176
|
+
under it, the row's actions at the TOP RIGHT beside the text they
|
|
3177
|
+
act on, and heavy artifacts behind their own disclosure. */}
|
|
3193
3178
|
<View style={{ gap: 20 }}>
|
|
3194
3179
|
{(showAllActivity ? activity : activity.slice(0, ACTIVITY_FOLD)).map((a) => (
|
|
3195
3180
|
<ActivityEntryRow
|
|
@@ -3712,52 +3697,25 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3712
3697
|
</Section>
|
|
3713
3698
|
</View>
|
|
3714
3699
|
|
|
3715
|
-
{/* PHOTOS —
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
very comment promised that the odd `.docx` among forty phone photos
|
|
3735
|
-
would stay in the grid. It could not: the filter forbade what the
|
|
3736
|
-
sentence described. `FileThumbnail` already renders a non-image as a
|
|
3737
|
-
doc tile (badge + filename), so nothing had to be built — only the
|
|
3738
|
-
filter that hid it had to go.
|
|
3739
|
-
|
|
3740
|
-
THE COST IS REAL AND DELIBERATE: every file now appears twice. The
|
|
3741
|
-
trade is that duplication is cheap to skim past, whereas an artifact
|
|
3742
|
-
filed under the wrong lens looks like an artifact that was never
|
|
3743
|
-
filed. A record whose attachments are overwhelmingly paperwork should
|
|
3744
|
-
drop the grid entirely rather than carry a wall of identical tiles —
|
|
3745
|
-
the choice is per-record-TYPE, made once, not per file.
|
|
3746
|
-
|
|
3747
|
-
Ordering follows the collection, so a record heavy in documents leads
|
|
3748
|
-
this grid with grey tiles. If that reads badly for your record type,
|
|
3749
|
-
sort images first rather than reinstating the filter.
|
|
3750
|
-
|
|
3751
|
-
The tiles are mixed by construction now, so the FIXTURE has to be
|
|
3752
|
-
mixed too — a grid of eight JPGs never once renders the doc tile it
|
|
3753
|
-
has always been able to render, which is how the gap survived.
|
|
3754
|
-
|
|
3755
|
-
The ADD rides the heading row, right edge, exactly as it does on Files
|
|
3756
|
-
and Fees — a section's add sits in the same spot whether the section
|
|
3757
|
-
holds nought or forty. Note it is a plain `Button` + `pickFiles`, not
|
|
3758
|
-
`FilesEditorUpload`: that verb reads the `FilesEditor` context, and the
|
|
3759
|
-
heading is OUTSIDE the editor by construction, so a compound's own
|
|
3760
|
-
upload button can never reach the one place the law puts it. */}
|
|
3700
|
+
{/* PHOTOS — two LENSES over ONE set, not two sets. Files answers "what is
|
|
3701
|
+
on this record" in a scannable column; this answers "what did it LOOK
|
|
3702
|
+
like" at a glance. (List for documents, grid for images: AGENTS.md.)
|
|
3703
|
+
|
|
3704
|
+
BOTH show the WHOLE collection, unfiltered. A reader who guesses wrong
|
|
3705
|
+
still finds the artifact instead of concluding it was never filed, and
|
|
3706
|
+
nobody has to know a file's FORMAT before knowing where to look. The
|
|
3707
|
+
cost is real: every file appears twice. Duplication is cheap to skim
|
|
3708
|
+
past; an artifact filed under the wrong lens reads as missing.
|
|
3709
|
+
|
|
3710
|
+
Ordering follows the collection, so a document-heavy record leads with
|
|
3711
|
+
grey tiles — sort images first if that reads badly, and drop the grid
|
|
3712
|
+
entirely for a record type that is overwhelmingly paperwork. That
|
|
3713
|
+
choice is per record TYPE, made once, never per file.
|
|
3714
|
+
|
|
3715
|
+
The add is a plain `Button` + `pickFiles`, NOT `FilesEditorUpload`:
|
|
3716
|
+
that verb reads the `FilesEditor` context and the heading row sits
|
|
3717
|
+
outside the editor by construction, so the compound's own upload
|
|
3718
|
+
button cannot reach the one place the law puts it. */}
|
|
3761
3719
|
<View onLayout={nav.register("photos")}>
|
|
3762
3720
|
<Section>
|
|
3763
3721
|
<SectionHeading>
|
|
@@ -4147,26 +4105,16 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
4147
4105
|
<InlineTextInput value={f.note} onSave={persist((v: string) => patchFee(f.id, { note: v }))} placeholder="Add a note…" accessibilityLabel="Fee note" />
|
|
4148
4106
|
</DetailRow>
|
|
4149
4107
|
</DetailTable>
|
|
4150
|
-
{/* The destructive verb, LEFT — the peek footer's
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
which is the safeguard the icon was only decorating.
|
|
4161
|
-
|
|
4162
|
-
NO Divider. Every hairline a `Table` draws is
|
|
4163
|
-
full-bleed (the row `Divider`s carry no padding, the
|
|
4164
|
-
header band's border spans its whole box), so one
|
|
4165
|
-
inset by ROW_GUTTER puts two hairline lengths in the
|
|
4166
|
-
same vertical run — and it would be saying twice what
|
|
4167
|
-
the expanded row's own wash already says. The row
|
|
4168
|
-
wrapper is required, not decoration: a `Button` alone
|
|
4169
|
-
in a column View stretches to full width. */}
|
|
4108
|
+
{/* The destructive verb, LEFT — the peek footer's convention.
|
|
4109
|
+
Solid `danger` per composition rule 9; no trash glyph,
|
|
4110
|
+
because "Remove fee" already names the object the icon
|
|
4111
|
+
was only decorating.
|
|
4112
|
+
|
|
4113
|
+
NO Divider. Every hairline a `Table` draws is full-bleed,
|
|
4114
|
+
so one inset by ROW_GUTTER puts two hairline lengths in
|
|
4115
|
+
the same vertical run — and it repeats what the expanded
|
|
4116
|
+
row's wash already says. The row wrapper IS required: a
|
|
4117
|
+
`Button` alone in a column View stretches full width. */}
|
|
4170
4118
|
<View style={{ flexDirection: "row" }}>
|
|
4171
4119
|
<Button title="Remove fee" color="danger" onPress={() => deleteFee(f)} />
|
|
4172
4120
|
</View>
|
|
@@ -4178,8 +4126,10 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
4178
4126
|
it on line 2, in one cell rather than two columns. */}
|
|
4179
4127
|
<TableCell>
|
|
4180
4128
|
<View style={{ gap: 2 }}>
|
|
4181
|
-
<Text size="sm" weight="medium" numberOfLines={1}>{f.name || "—"}</Text>
|
|
4182
|
-
|
|
4129
|
+
<Text size="sm" weight="medium" leading="tight" numberOfLines={1}>{f.name || "—"}</Text>
|
|
4130
|
+
{/* Body size: the subject carries `medium`, which is what
|
|
4131
|
+
makes this a pair rather than two peers. */}
|
|
4132
|
+
<Text size="sm" color="muted" leading="tight" numberOfLines={1}>
|
|
4183
4133
|
{f.party || (f.direction === "charge" ? "Charge" : "Cost")}
|
|
4184
4134
|
</Text>
|
|
4185
4135
|
</View>
|
package/package.json
CHANGED
package/src/text.css
CHANGED
|
@@ -15,25 +15,21 @@
|
|
|
15
15
|
* single line, so every label, value and control gained ~4px of line box.
|
|
16
16
|
* Density is what was traded for the rhythm.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
18
|
+
* `[data-text-leading="tight"]` buys that density back where the text is a
|
|
19
|
+
* STACKED PAIR rather than prose — the box becomes `size + 6`, which is what
|
|
20
|
+
* `xs` has always been. See `TYPE_LEADING_TIGHT_*`; a rung already at or below
|
|
21
|
+
* that keeps its own number, so only the rungs that left it for a prose ratio
|
|
22
|
+
* have a rule here.
|
|
19
23
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* untracked body reads loose beside it.
|
|
24
|
+
* TRACKING: Inter's own dynamic-metrics curve, evaluated per rung — the
|
|
25
|
+
* derivation and the function live in `type_ramp.ts`. Never nudged by eye: the
|
|
26
|
+
* typeface's designer has already answered this for this typeface, and a screen
|
|
27
|
+
* that reads too tight or too loose wants a size or a leading change instead.
|
|
28
|
+
* Zero at 12px, steepening as the type grows.
|
|
26
29
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `xs` is the one rung held back (-0.02em, not the full body value). 12px is
|
|
31
|
-
* where Vietnamese stacked diacritics (ự, ộ, ế, ệ) and Inter's `1`/`l`/`i` come
|
|
32
|
-
* closest to colliding, and tightening is the direction that makes both worse.
|
|
33
|
-
*
|
|
34
|
-
* `.ui-markdown` and the inputs (`INPUT_LETTER_SPACING`) mirror the body rung
|
|
35
|
-
* exactly. All three are ONE contract on both axes — leading above, tracking here
|
|
36
|
-
* — and a value moved on one surface alone is the seam re-opening.
|
|
30
|
+
* `.ui-markdown` and the inputs (`INPUT_LETTER_SPACING`) sit on the body rung
|
|
31
|
+
* exactly. All three are ONE contract on both axes — leading above, tracking
|
|
32
|
+
* here — and a value moved on one surface alone is the seam re-opening.
|
|
37
33
|
*
|
|
38
34
|
* Inter stylistic alternates: `cv11` (single-storey `a`), `ss01` (alternate
|
|
39
35
|
* `1`), `ss03` (alternate `g`) — disambiguates similar glyphs without
|
|
@@ -82,6 +78,20 @@
|
|
|
82
78
|
}
|
|
83
79
|
|
|
84
80
|
/* Refer to `use_screen_size` for breakpoints */
|
|
81
|
+
/*
|
|
82
|
+
* TIGHT — a stacked pair, not prose. Only the rungs whose prose leading exceeds
|
|
83
|
+
* `size + 6` need a rule; the rest already sit at or below it and inherit.
|
|
84
|
+
*/
|
|
85
|
+
[data-text-size="sm"][data-text-leading="tight"] {
|
|
86
|
+
line-height: 20px;
|
|
87
|
+
}
|
|
88
|
+
[data-text-size="md"][data-text-leading="tight"] {
|
|
89
|
+
line-height: 22px;
|
|
90
|
+
}
|
|
91
|
+
[data-text-size="lg"][data-text-leading="tight"] {
|
|
92
|
+
line-height: 24px;
|
|
93
|
+
}
|
|
94
|
+
|
|
85
95
|
@media screen and (min-width: 768px) {
|
|
86
96
|
[data-text-size="xs"] {
|
|
87
97
|
font-size: 12px;
|
|
@@ -111,4 +121,19 @@
|
|
|
111
121
|
font-size: 48px;
|
|
112
122
|
line-height: 52px;
|
|
113
123
|
}
|
|
124
|
+
[data-text-size="sm"][data-text-leading="tight"] {
|
|
125
|
+
line-height: 20px;
|
|
126
|
+
}
|
|
127
|
+
[data-text-size="md"][data-text-leading="tight"] {
|
|
128
|
+
line-height: 22px;
|
|
129
|
+
}
|
|
130
|
+
[data-text-size="lg"][data-text-leading="tight"] {
|
|
131
|
+
line-height: 26px;
|
|
132
|
+
}
|
|
133
|
+
[data-text-size="xl"][data-text-leading="tight"] {
|
|
134
|
+
line-height: 30px;
|
|
135
|
+
}
|
|
136
|
+
[data-text-size="xxl"][data-text-leading="tight"] {
|
|
137
|
+
line-height: 38px;
|
|
138
|
+
}
|
|
114
139
|
}
|
package/src/text.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import "./text.css";
|
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { Platform, Text as RNText, TextProps as RNTextProps, StyleSheet, type TextStyle } from "react-native";
|
|
4
4
|
import { fontFamilyDisplay } from "./display_font";
|
|
5
|
-
import { TYPE_RAMP_MOBILE, trackingPx, type TypeRungName } from "./type_ramp";
|
|
5
|
+
import { TYPE_LEADING_TIGHT_MOBILE, TYPE_RAMP_MOBILE, trackingPx, type TypeRungName } from "./type_ramp";
|
|
6
6
|
import {
|
|
7
7
|
fontFamilyMedium,
|
|
8
8
|
fontFamilyRegular,
|
|
@@ -26,6 +26,19 @@ export interface TextProps {
|
|
|
26
26
|
* how one screen ends up in a different face from the next.
|
|
27
27
|
*/
|
|
28
28
|
family?: "display";
|
|
29
|
+
/**
|
|
30
|
+
* `tight` sets the rung's line box for a STACKED PAIR instead of for prose —
|
|
31
|
+
* a subject over its supporting line, a value over its annotation. The two are
|
|
32
|
+
* one object, and prose leading puts enough empty box between them to read as
|
|
33
|
+
* two; beside a mark the pair also grows taller than the mark.
|
|
34
|
+
*
|
|
35
|
+
* Set it on BOTH lines of the pair, not just the second: the box belongs to
|
|
36
|
+
* each line, so tightening one leaves the stack lopsided.
|
|
37
|
+
*
|
|
38
|
+
* Not for running text. A paragraph keeps prose leading, which is what the
|
|
39
|
+
* default is for and what `.ui-markdown` renders at.
|
|
40
|
+
*/
|
|
41
|
+
leading?: "tight";
|
|
29
42
|
align?: TextAlign;
|
|
30
43
|
weight?: TextWeight;
|
|
31
44
|
numberOfLines?: number;
|
|
@@ -86,6 +99,7 @@ export function Text(props: TextProps) {
|
|
|
86
99
|
size = "sm",
|
|
87
100
|
weight = "regular",
|
|
88
101
|
family,
|
|
102
|
+
leading,
|
|
89
103
|
numberOfLines,
|
|
90
104
|
decoration,
|
|
91
105
|
tabular,
|
|
@@ -105,7 +119,12 @@ export function Text(props: TextProps) {
|
|
|
105
119
|
|
|
106
120
|
// On web, responsive font sizes are applied via CSS using data-text-size attribute.
|
|
107
121
|
// On native, sizes are applied via StyleSheet.
|
|
108
|
-
|
|
122
|
+
// `textLeading` is only emitted when tight — an absent attribute is the prose
|
|
123
|
+
// default, so no rule has to restate the ramp to say "unchanged".
|
|
124
|
+
const webProps =
|
|
125
|
+
Platform.OS === "web"
|
|
126
|
+
? { dataSet: leading != null ? { textSize: size, textLeading: leading } : { textSize: size } }
|
|
127
|
+
: undefined;
|
|
109
128
|
|
|
110
129
|
return (
|
|
111
130
|
<RNText
|
|
@@ -127,6 +146,9 @@ export function Text(props: TextProps) {
|
|
|
127
146
|
// to, which on web was dead weight the cascade overrode and on native
|
|
128
147
|
// was the reason the curve never reached the mobile app.
|
|
129
148
|
Platform.OS !== "web" && sizeStyles[size],
|
|
149
|
+
// AFTER the rung, which set the prose leading — this replaces that one
|
|
150
|
+
// number and leaves size and tracking alone.
|
|
151
|
+
Platform.OS !== "web" && leading === "tight" && tightStyles[size],
|
|
130
152
|
styles[weight],
|
|
131
153
|
// AFTER the weight, because the weight ladder sets a family too — each
|
|
132
154
|
// rung is a distinct Inter family, not a `font-weight` — so the display
|
|
@@ -186,6 +208,16 @@ const sizeStyles = StyleSheet.create(
|
|
|
186
208
|
) as Record<TypeRungName, TextStyle>,
|
|
187
209
|
);
|
|
188
210
|
|
|
211
|
+
/** The tight line box, same source, same reason there is no second table. */
|
|
212
|
+
const tightStyles = StyleSheet.create(
|
|
213
|
+
Object.fromEntries(
|
|
214
|
+
(Object.keys(TYPE_LEADING_TIGHT_MOBILE) as TypeRungName[]).map((rung) => [
|
|
215
|
+
rung,
|
|
216
|
+
{ lineHeight: TYPE_LEADING_TIGHT_MOBILE[rung] },
|
|
217
|
+
]),
|
|
218
|
+
) as Record<TypeRungName, TextStyle>,
|
|
219
|
+
);
|
|
220
|
+
|
|
189
221
|
const styles = StyleSheet.create({
|
|
190
222
|
// Text alignment styles
|
|
191
223
|
left: {
|
package/src/type_ramp.ts
CHANGED
|
@@ -66,6 +66,40 @@ export const TYPE_RAMP_DESKTOP = {
|
|
|
66
66
|
xxxl: { size: 48, leading: 52 },
|
|
67
67
|
} as const satisfies Record<keyof typeof TYPE_RAMP_MOBILE, TypeRung>;
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* TIGHT LEADING — the same rungs, set for a STACKED PAIR rather than for prose.
|
|
71
|
+
*
|
|
72
|
+
* `Text` defaults to the prose leading above, and a stacked pair is not prose: a
|
|
73
|
+
* subject over its supporting line is ONE object, and prose leading puts ~10px of
|
|
74
|
+
* empty box between the two so it reads as two. Beside a 40px mark the pair also
|
|
75
|
+
* measures 50 and overhangs it, which is the visible form of the same fault.
|
|
76
|
+
*
|
|
77
|
+
* DERIVED, not chosen: it is `size + 6`, capped at the rung's own prose leading
|
|
78
|
+
* so a rung already tighter than that keeps what it has. That constant is not
|
|
79
|
+
* invented here — `xs` is 12/18, and has always BEEN `size + 6`. The body rungs
|
|
80
|
+
* are the ones that leave it for a prose ratio, so this is the smallest rung's
|
|
81
|
+
* existing rule extended up the ladder rather than a second opinion about
|
|
82
|
+
* leading. The display rungs mostly already sit at or below it and so are
|
|
83
|
+
* unchanged; the cap is what keeps `xxxl` from being LOOSENED by a rule meant to
|
|
84
|
+
* tighten.
|
|
85
|
+
*/
|
|
86
|
+
const TIGHT_LEAD = 6;
|
|
87
|
+
|
|
88
|
+
function tighten(ramp: Record<TypeRungName, TypeRung>): Record<TypeRungName, number> {
|
|
89
|
+
return Object.fromEntries(
|
|
90
|
+
(Object.keys(ramp) as TypeRungName[]).map((rung) => [
|
|
91
|
+
rung,
|
|
92
|
+
Math.min(ramp[rung].leading, ramp[rung].size + TIGHT_LEAD),
|
|
93
|
+
]),
|
|
94
|
+
) as Record<TypeRungName, number>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Tight leading below 768px, and on native. */
|
|
98
|
+
export const TYPE_LEADING_TIGHT_MOBILE = tighten(TYPE_RAMP_MOBILE);
|
|
99
|
+
|
|
100
|
+
/** Tight leading at 768px and above. */
|
|
101
|
+
export const TYPE_LEADING_TIGHT_DESKTOP = tighten(TYPE_RAMP_DESKTOP);
|
|
102
|
+
|
|
69
103
|
/**
|
|
70
104
|
* Tracking per rung, in em — INTER'S OWN dynamic metrics, not a house curve.
|
|
71
105
|
*
|