@lotics/ui 43.4.0 → 43.6.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 +3 -2
- package/MIGRATION.md +93 -0
- package/docs/catalog.md +128 -26
- package/docs/composition.md +395 -9
- package/docs/data_entry.md +25 -0
- package/docs/reviewing.md +477 -0
- package/docs/templates.md +9 -6
- package/examples/tpl_attendance.tsx +0 -1
- package/examples/tpl_item_list.tsx +109 -54
- package/examples/tpl_record.tsx +89 -4
- package/package.json +7 -3
- package/src/avatar.tsx +29 -29
- package/src/avatar.web.tsx +32 -31
- package/src/avatar_props.ts +66 -0
- package/src/avatar_tone.ts +79 -0
- package/src/button.tsx +36 -4
- package/src/checkbox.tsx +4 -1
- package/src/choice_list.tsx +5 -3
- package/src/color_tokens.ts +15 -14
- package/src/composer.tsx +3 -3
- package/src/control_surface.ts +37 -9
- package/src/copy_button.tsx +8 -1
- package/src/counter.tsx +1 -1
- package/src/data_grid.tsx +6 -3
- package/src/date_calendar.tsx +4 -2
- package/src/date_range_filter_field.tsx +5 -1
- package/src/date_segments_field.tsx +2 -2
- package/src/file_drop_target.web.tsx +2 -2
- package/src/file_dropzone.tsx +12 -6
- package/src/file_rows.tsx +22 -2
- package/src/file_thumbnail.tsx +19 -3
- package/src/font_family.ts +1 -1
- package/src/font_family.web.ts +1 -1
- package/src/funnel.tsx +1 -1
- package/src/icon_button.tsx +5 -2
- package/src/index.css +22 -16
- package/src/inline_edit.tsx +9 -10
- package/src/inline_files.tsx +6 -0
- package/src/json_panel.tsx +1 -1
- package/src/kpi_card.tsx +1 -1
- package/src/locale.tsx +1 -1
- package/src/markdown.css +5 -1
- package/src/metric.tsx +24 -24
- package/src/number_input.tsx +2 -2
- package/src/option_picker.tsx +57 -0
- package/src/picker.tsx +2 -2
- package/src/pressable_row.tsx +15 -5
- package/src/progress_bar.tsx +1 -1
- package/src/radio_picker.tsx +2 -1
- package/src/search_input.tsx +12 -11
- package/src/sort_header.tsx +7 -3
- package/src/stacked_progress_bar.tsx +1 -1
- package/src/step_progress.tsx +5 -2
- package/src/switch.tsx +11 -6
- package/src/table.tsx +158 -16
- package/src/table_fit.ts +12 -0
- package/src/tabs.tsx +13 -1
- package/src/text.css +50 -18
- package/src/text.tsx +37 -40
- package/src/text_input_field.tsx +2 -2
- package/src/text_utils.ts +46 -6
- package/src/theme.ts +13 -0
- package/src/theme.web.ts +49 -0
- package/src/theme_vars.ts +113 -0
- package/src/type_ramp.ts +113 -0
- package/src/theme.tsx +0 -24
- package/src/theme.web.tsx +0 -79
- package/src/theme_context.ts +0 -107
package/src/table.tsx
CHANGED
|
@@ -10,13 +10,12 @@ import {
|
|
|
10
10
|
} from "react";
|
|
11
11
|
import { StyleSheet, View, type ViewStyle } from "react-native";
|
|
12
12
|
import { Text } from "./text";
|
|
13
|
-
import { colors } from "./colors";
|
|
13
|
+
import { colors, solid, type ColorName } from "./colors";
|
|
14
14
|
import { PressableRow } from "./pressable_row";
|
|
15
15
|
import { PressDoor } from "./press_door";
|
|
16
|
-
import { Divider } from "./divider";
|
|
17
16
|
import { DetailRow } from "./detail_row";
|
|
18
17
|
import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
|
|
19
|
-
import { COLUMN_GAP, ROW_GUTTER, computeTableFit, type TableFit, type TableFitColumn } from "./table_fit";
|
|
18
|
+
import { COLUMN_GAP, ROW_GUTTER, ROW_HEIGHT, computeTableFit, type TableFit, type TableFitColumn } from "./table_fit";
|
|
20
19
|
import { ROW_WASH_BLEED } from "./control_surface";
|
|
21
20
|
|
|
22
21
|
/**
|
|
@@ -26,7 +25,7 @@ import { ROW_WASH_BLEED } from "./control_surface";
|
|
|
26
25
|
* layer in `table_fit.ts` that decides which columns survive a narrow container.
|
|
27
26
|
*/
|
|
28
27
|
export interface TableColumn extends TableFitColumn {
|
|
29
|
-
/** Header label
|
|
28
|
+
/** Header label — sentence case, at the BODY size. Omit for a control column.
|
|
30
29
|
* In stacked mode it renders as the eyebrow over the cell's value. */
|
|
31
30
|
label?: string;
|
|
32
31
|
/** Flex grow when no `width` (default 1). */
|
|
@@ -37,6 +36,27 @@ export interface TableColumn extends TableFitColumn {
|
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
|
|
39
|
+
/**
|
|
40
|
+
* WHAT SEPARATES ONE ROW FROM THE NEXT: nothing. A register's rows are separated
|
|
41
|
+
* by their own height and a small gap, and the only mark a row makes is the
|
|
42
|
+
* rounded wash it paints when you point at it.
|
|
43
|
+
*
|
|
44
|
+
* There is no hairline and no prop asking for one. A rule between two rows
|
|
45
|
+
* divides LIKE FROM LIKE, which whitespace already says — the register's one
|
|
46
|
+
* line is the band capping the columns, which divides chrome from content and is
|
|
47
|
+
* the thing whitespace cannot say. Drawing both makes them compete, and a grid of
|
|
48
|
+
* equal lines is most of what "it looks like a spreadsheet" means.
|
|
49
|
+
*
|
|
50
|
+
* The wash does the work the rule did, and it already has the shape for it:
|
|
51
|
+
* `PressableRow` bleeds its hover/selected ground past the content and rounds it,
|
|
52
|
+
* so a register reads as a stack of cards without any card component existing.
|
|
53
|
+
*
|
|
54
|
+
* DENSITY, when a surface genuinely needs it, is `TableRow`'s `minHeight` — one
|
|
55
|
+
* number, not a mode. What no caller can ask for is the rules back. A table of
|
|
56
|
+
* VALUES rather than of objects is a different component: `Matrix` for a numeric
|
|
57
|
+
* grid (a statement, a pivot, a rollup), `DataGrid` for a compact one.
|
|
58
|
+
*/
|
|
59
|
+
|
|
40
60
|
interface TableCtx {
|
|
41
61
|
columns: TableColumn[];
|
|
42
62
|
leading: number;
|
|
@@ -211,6 +231,15 @@ export function Table(props: TableProps) {
|
|
|
211
231
|
</View>
|
|
212
232
|
) : null
|
|
213
233
|
) : (
|
|
234
|
+
/* THE HEADER RULE STAYS IN BOTH RHYTHMS — it is the one line that
|
|
235
|
+
survives `airy`, and it survives because of WHAT it separates rather
|
|
236
|
+
than how important it is. A rule between two rows divides like from
|
|
237
|
+
like, and space says that just as well. This rule divides CHROME
|
|
238
|
+
from CONTENT — a band of labels from the records they name — and
|
|
239
|
+
those are different kinds of thing, so the boundary earns a mark
|
|
240
|
+
that whitespace cannot supply. Dropping it was tried and the column
|
|
241
|
+
band floated: with nothing under them the labels read as a first
|
|
242
|
+
row rather than as the header of the rest. */
|
|
214
243
|
<View style={[styles.headerBand, headerFill ? styles.headerBandFilled : null]}>
|
|
215
244
|
{/* The TOTAL heads the column of positions — the one number that
|
|
216
245
|
answers "how many" without the reader scrolling to the last row. */}
|
|
@@ -221,7 +250,18 @@ export function Table(props: TableProps) {
|
|
|
221
250
|
col.sortable && onSort ? (
|
|
222
251
|
<SortHeader label={col.label} sortKey={col.key} sort={sort ?? null} onSort={onSort} align={col.align} labels={sortLabels} />
|
|
223
252
|
) : (
|
|
224
|
-
|
|
253
|
+
/* A column header is CHROME: `sm` (the body rung, so it is
|
|
254
|
+
legible), `medium` (a weight below the values it heads),
|
|
255
|
+
`muted` (a step back in ink), and SENTENCE CASE. It was
|
|
256
|
+
`xs` semibold uppercase — two sizes below the values, in
|
|
257
|
+
the weight the heading ramp reserves for `#`/`##`/`###`,
|
|
258
|
+
and all-caps, which the eyebrow rule bans outright as the
|
|
259
|
+
loudest templated tell there is. Making a label smaller
|
|
260
|
+
and fainter to make it recede trades legibility for quiet
|
|
261
|
+
and produces the complaint it was meant to fix. `SortHeader`
|
|
262
|
+
and `DataGrid` carry the same treatment; this is where the
|
|
263
|
+
reason lives. */
|
|
264
|
+
<Text size="sm" color="muted" weight="medium" numberOfLines={1}>
|
|
225
265
|
{col.label}
|
|
226
266
|
</Text>
|
|
227
267
|
)
|
|
@@ -232,18 +272,84 @@ export function Table(props: TableProps) {
|
|
|
232
272
|
</View>
|
|
233
273
|
)}
|
|
234
274
|
<View style={styles.body}>
|
|
235
|
-
{rows.map((row, i) =>
|
|
236
|
-
|
|
237
|
-
{i
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
))}
|
|
275
|
+
{rows.map((row, i) => {
|
|
276
|
+
return (
|
|
277
|
+
<View key={i}>{row}</View>
|
|
278
|
+
);
|
|
279
|
+
})}
|
|
241
280
|
</View>
|
|
242
281
|
</View>
|
|
243
282
|
</TableContext.Provider>
|
|
244
283
|
);
|
|
245
284
|
}
|
|
246
285
|
|
|
286
|
+
|
|
287
|
+
export interface TableGroupProps {
|
|
288
|
+
/** What this band of rows has in common — an ACTION state, not a category. */
|
|
289
|
+
label: string;
|
|
290
|
+
/** How many rows are in it. Shown beside the label; omit only if the band is
|
|
291
|
+
* never partial (it almost always is — a filter or a page can cut it). */
|
|
292
|
+
count?: number;
|
|
293
|
+
/** A palette family for the band's dot, when the group carries a VALENCE the
|
|
294
|
+
* label alone understates (a queue that is overdue). Omit for a neutral band —
|
|
295
|
+
* most bands are neutral, and a dot on every one is decoration. */
|
|
296
|
+
color?: ColorName;
|
|
297
|
+
/** The `TableRow`s in this band. */
|
|
298
|
+
children: ReactNode;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* A titled BAND of rows inside a `Table` — the difference between a register
|
|
303
|
+
* that presents records and one that triages them.
|
|
304
|
+
*
|
|
305
|
+
* A flat register sorted by a column asks the reader to do the deciding: every
|
|
306
|
+
* row arrives at equal weight, and "which of these needs me today" is answered by
|
|
307
|
+
* reading all of them. Grouping moves that decision into the screen. Reach for it
|
|
308
|
+
* when the rows fall into states that imply DIFFERENT ACTIONS — needs a reply,
|
|
309
|
+
* gone quiet, closed — and the reader's real question is which pile they are in.
|
|
310
|
+
*
|
|
311
|
+
* Do NOT reach for it to group by a category the reader can already see in a
|
|
312
|
+
* column: that is a sort, and it costs a band of chrome per value while telling
|
|
313
|
+
* them what the cell beside it says.
|
|
314
|
+
*
|
|
315
|
+
* The band separates itself with AIR rather than a rule, because it is the
|
|
316
|
+
* biggest boundary inside the register and the row hairlines are the smallest —
|
|
317
|
+
* drawing both as lines makes them compete. The heading also takes no rule
|
|
318
|
+
* beneath it, which would orphan the title from the rows it opens.
|
|
319
|
+
*
|
|
320
|
+
* A group's rows keep their own `ordinal` numbering if they carry one: restart
|
|
321
|
+
* inside each band, where the reader's question is "which of these" rather than
|
|
322
|
+
* "which of all".
|
|
323
|
+
*/
|
|
324
|
+
export function TableGroup(props: TableGroupProps) {
|
|
325
|
+
const { label, count, color, children } = props;
|
|
326
|
+
const rows = Children.toArray(children).filter(isValidElement);
|
|
327
|
+
// A band separates its OWN rows the same way the table does. This rendered
|
|
328
|
+
// hairlines unconditionally, so an airy register lost its rules between bands
|
|
329
|
+
// and kept them inside — the grouping made the register look half-converted,
|
|
330
|
+
// and the rule that survived was the one with least reason to.
|
|
331
|
+
return (
|
|
332
|
+
<View>
|
|
333
|
+
<View style={styles.groupHeading}>
|
|
334
|
+
{color ? <View style={[styles.groupDot, { backgroundColor: solid(color) }]} /> : null}
|
|
335
|
+
<Text size="sm" weight="semibold">
|
|
336
|
+
{label}
|
|
337
|
+
</Text>
|
|
338
|
+
{count != null ? (
|
|
339
|
+
<Text size="sm" color="zinc-500" tabular>
|
|
340
|
+
{count}
|
|
341
|
+
</Text>
|
|
342
|
+
) : null}
|
|
343
|
+
</View>
|
|
344
|
+
<View style={styles.body}>
|
|
345
|
+
{rows.map((row, i) => (
|
|
346
|
+
<View key={i}>{row}</View>
|
|
347
|
+
))}
|
|
348
|
+
</View>
|
|
349
|
+
</View>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
247
353
|
export type TableRowProps = {
|
|
248
354
|
selected?: boolean;
|
|
249
355
|
/** Part of a multi-select set — a resting blue tint (the open/hover wash overrides it). */
|
|
@@ -273,7 +379,19 @@ export type TableRowProps = {
|
|
|
273
379
|
* before `trailing`; stacked mode: the content's bottom line, right-aligned — an action
|
|
274
380
|
* reads at content scale, not as row chrome. Reserve Table `trailing` for it. */
|
|
275
381
|
action?: ReactNode;
|
|
276
|
-
/**
|
|
382
|
+
/**
|
|
383
|
+
* Min row height — a FLOOR, not a fixed height: a taller cell grows the row,
|
|
384
|
+
* so a two-line identity cell or a figure with a bar under it needs no
|
|
385
|
+
* override to fit. (Stacked rows size to their content and ignore this.)
|
|
386
|
+
*
|
|
387
|
+
* Defaults to `ROW_HEIGHT` (72), the register's rhythm. **Reach for this only
|
|
388
|
+
* when a surface genuinely needs more DENSITY than the beat** — and know that
|
|
389
|
+
* a number written against an older default silently becomes a shrink: an app
|
|
390
|
+
* carrying `minHeight={64}` from when the default was 52 now pulls that one
|
|
391
|
+
* register below every other on the screen, and it reads as cramped rather
|
|
392
|
+
* than as dense. If the reason you overrode it was "the content did not fit",
|
|
393
|
+
* delete the override.
|
|
394
|
+
*/
|
|
277
395
|
minHeight?: number;
|
|
278
396
|
/**
|
|
279
397
|
* The row's detail, revealed BENEATH it when `expanded` — the alternative to
|
|
@@ -330,9 +448,12 @@ export type TableRowProps = {
|
|
|
330
448
|
* slots, different geometry.
|
|
331
449
|
*/
|
|
332
450
|
export function TableRow(props: TableRowProps) {
|
|
333
|
-
const { onPress, selected, marked, accessibilityLabel, leading, ordinal, trailing, action, minHeight
|
|
451
|
+
const { onPress, selected, marked, accessibilityLabel, leading, ordinal, trailing, action, minHeight, detail, expanded, children } = props;
|
|
334
452
|
const ctx = useContext(TableContext);
|
|
335
453
|
if (!ctx) throw new Error("TableRow must be used within a Table");
|
|
454
|
+
// Read AFTER the context check so an airy register does not need every row to
|
|
455
|
+
// repeat its height — the rhythm is a property of the table, not of each row.
|
|
456
|
+
const rowMinHeight = minHeight ?? ROW_HEIGHT;
|
|
336
457
|
const showDetail = detail != null && expanded === true;
|
|
337
458
|
|
|
338
459
|
const cells = (Children.toArray(children).filter(isValidElement) as ReactElement<TableCellProps>[])
|
|
@@ -347,7 +468,7 @@ export function TableRow(props: TableRowProps) {
|
|
|
347
468
|
|
|
348
469
|
// ONE style for the column row, shared by the pressable and read-only paths
|
|
349
470
|
// below, which render near-identical Views.
|
|
350
|
-
const cellsStyle = [styles.cells, { minHeight }];
|
|
471
|
+
const cellsStyle = [styles.cells, { minHeight: rowMinHeight }];
|
|
351
472
|
|
|
352
473
|
const body = ctx.stacked ? (
|
|
353
474
|
<View style={styles.stackedBody}>
|
|
@@ -513,9 +634,30 @@ const styles = StyleSheet.create({
|
|
|
513
634
|
borderBottomWidth: 1,
|
|
514
635
|
borderColor: colors.border,
|
|
515
636
|
},
|
|
516
|
-
//
|
|
637
|
+
// A small gap, not a large one. The row's own height carries the airiness; the
|
|
638
|
+
// gap only has to stop two washes touching when the pointer moves between
|
|
639
|
+
// them, which at 4px it does while still reading as one list rather than as
|
|
640
|
+
// detached cards.
|
|
517
641
|
body: {
|
|
518
|
-
gap:
|
|
642
|
+
gap: 4,
|
|
643
|
+
},
|
|
644
|
+
// Air, not a rule. A band boundary is the LARGEST break inside a register and
|
|
645
|
+
// the row hairlines are the smallest; drawing both as lines makes the two
|
|
646
|
+
// compete and the grouping stops reading. The first band's heading sits under
|
|
647
|
+
// the column band's own hairline, so it takes less top space than the ones
|
|
648
|
+
// that follow — handled by the heading's own padding rather than by the caller.
|
|
649
|
+
groupHeading: {
|
|
650
|
+
flexDirection: "row",
|
|
651
|
+
alignItems: "center",
|
|
652
|
+
gap: 8,
|
|
653
|
+
paddingTop: 26,
|
|
654
|
+
paddingBottom: 10,
|
|
655
|
+
paddingHorizontal: ROW_GUTTER,
|
|
656
|
+
},
|
|
657
|
+
groupDot: {
|
|
658
|
+
width: 6,
|
|
659
|
+
height: 6,
|
|
660
|
+
borderRadius: 999,
|
|
519
661
|
},
|
|
520
662
|
row: {
|
|
521
663
|
gap: COLUMN_GAP,
|
package/src/table_fit.ts
CHANGED
|
@@ -30,6 +30,18 @@ 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
|
+
* A register row's height. Tall enough that a two-line identity cell (a name over
|
|
35
|
+
* its supporting values) sits in it without crowding, and tall enough that the
|
|
36
|
+
* hover wash reads as a surface rather than a stripe — which is what has to carry
|
|
37
|
+
* the separation now that no hairline does.
|
|
38
|
+
*
|
|
39
|
+
* A caller that genuinely needs density overrides it per row (`TableRow`'s
|
|
40
|
+
* `minHeight`). That is one number, not a mode: what no caller can ask for is the
|
|
41
|
+
* row rules back, because a table of VALUES rather than of objects is a different
|
|
42
|
+
* component (`Matrix`, `DataGrid`).
|
|
43
|
+
*/
|
|
44
|
+
export const ROW_HEIGHT = 72;
|
|
33
45
|
/**
|
|
34
46
|
* The register's CONTENT gutter — zero, because the washed row bleeds instead.
|
|
35
47
|
*
|
package/src/tabs.tsx
CHANGED
|
@@ -120,8 +120,20 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
|
|
|
120
120
|
<PressableHighlight
|
|
121
121
|
focusRing
|
|
122
122
|
ref={ref} style={{
|
|
123
|
+
// WHERE YOU ARE, painted in the INK THAT ACTS — `colors.primary`, which
|
|
124
|
+
// defaults to near-black. A tab rail is the only thing on a screen that
|
|
125
|
+
// answers "which section am I in", and a neutral grey underline would be
|
|
126
|
+
// indistinguishable from a divider, so it has to be a token.
|
|
127
|
+
//
|
|
128
|
+
// Not `accent`: on an unthemed screen the accent's blue would be the only
|
|
129
|
+
// blue anywhere — a single-member hue from a family used nowhere else,
|
|
130
|
+
// which is the definition of a library default nobody chose, and it sat
|
|
131
|
+
// three pixels from a near-black CTA and near-black checkboxes. `primary`
|
|
132
|
+
// is already the mark the kit spends on "this is on" (a checkbox's fill, a
|
|
133
|
+
// switch's track, a picked option's tick); an active tab is the same claim
|
|
134
|
+
// at page scale, so it reads as one product rather than two brands.
|
|
123
135
|
borderBottomWidth: 3,
|
|
124
|
-
borderBottomColor: selected ? colors.
|
|
136
|
+
borderBottomColor: selected ? colors.primary : "transparent",
|
|
125
137
|
backgroundColor: "transparent",
|
|
126
138
|
paddingBottom: 4,
|
|
127
139
|
}}
|
package/src/text.css
CHANGED
|
@@ -1,8 +1,39 @@
|
|
|
1
1
|
/*
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* TWO CURVES, ONE CONTRACT — leading and tracking, shared with `.ui-markdown`
|
|
3
|
+
* and the multiline inputs (`INPUT_LINE_HEIGHT_DESKTOP`). Change any one of the
|
|
4
|
+
* three alone and the seam re-opens.
|
|
5
|
+
*
|
|
6
|
+
* LEADING: the body rungs carry PROSE leading — 14px/24px, ratio 1.71, exactly
|
|
7
|
+
* what `.ui-markdown` uses. They were 1.43, four pixels tighter per line than the
|
|
8
|
+
* same 14px text rendered as markdown, so a typed note and a rendered one sat at
|
|
9
|
+
* different rhythms on one surface and the typed one read cramped. The ladder
|
|
10
|
+
* tightens as it rises, which is the convention and what markdown already did:
|
|
11
|
+
* large type needs proportionally less leading, so the display rungs keep their
|
|
12
|
+
* near-1.2 ratios and only the rungs carrying running text move.
|
|
13
|
+
*
|
|
14
|
+
* The cost is real and deliberate: line-height sets the BOX height of even a
|
|
15
|
+
* single line, so every label, value and control gained ~4px of line box.
|
|
16
|
+
* Density is what was traded for the rhythm.
|
|
17
|
+
*
|
|
18
|
+
* TRACKING: NEGATIVE throughout, steepening as the type grows.
|
|
19
|
+
*
|
|
20
|
+
* The body rung is ANCHORED on -0.028em — the -0.4px this product already set on
|
|
21
|
+
* `.ui-markdown` and on six inputs, expressed as an em so it scales. That value
|
|
22
|
+
* is the product's own, arrived at by reading it in Vietnamese every day, and it
|
|
23
|
+
* is what the rest of the ramp is derived FROM rather than something imported
|
|
24
|
+
* from another design system. Inter is drawn generously at default tracking, so
|
|
25
|
+
* untracked body reads loose beside it.
|
|
26
|
+
*
|
|
27
|
+
* Larger type takes proportionally more negative tracking, which is why the curve
|
|
28
|
+
* steepens: what looks right at 14px looks slack at 32px.
|
|
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.
|
|
6
37
|
*
|
|
7
38
|
* Inter stylistic alternates: `cv11` (single-storey `a`), `ss01` (alternate
|
|
8
39
|
* `1`), `ss03` (alternate `g`) — disambiguates similar glyphs without
|
|
@@ -14,58 +45,59 @@
|
|
|
14
45
|
|
|
15
46
|
[data-text-size="xs"] {
|
|
16
47
|
font-size: 12px;
|
|
17
|
-
line-height:
|
|
18
|
-
letter-spacing:
|
|
48
|
+
line-height: 18px;
|
|
49
|
+
letter-spacing: 0em;
|
|
19
50
|
}
|
|
20
51
|
[data-text-size="sm"] {
|
|
21
52
|
font-size: 14px;
|
|
22
|
-
line-height:
|
|
23
|
-
letter-spacing: 0.
|
|
53
|
+
line-height: 24px;
|
|
54
|
+
letter-spacing: -0.006em;
|
|
24
55
|
}
|
|
25
56
|
[data-text-size="md"] {
|
|
26
57
|
font-size: 16px;
|
|
27
|
-
line-height:
|
|
58
|
+
line-height: 26px;
|
|
59
|
+
letter-spacing: -0.011em;
|
|
28
60
|
}
|
|
29
61
|
[data-text-size="lg"] {
|
|
30
62
|
font-size: 18px;
|
|
31
|
-
line-height:
|
|
32
|
-
letter-spacing: -0.
|
|
63
|
+
line-height: 28px;
|
|
64
|
+
letter-spacing: -0.014em;
|
|
33
65
|
}
|
|
34
66
|
/* Display ramp — the heading ladder: xl = `##` (section), xxl = `#`
|
|
35
67
|
(page/record title), xxxl = hero numbers. */
|
|
36
68
|
[data-text-size="xl"] {
|
|
37
69
|
font-size: 22px;
|
|
38
70
|
line-height: 28px;
|
|
39
|
-
letter-spacing: -0.
|
|
71
|
+
letter-spacing: -0.018em;
|
|
40
72
|
}
|
|
41
73
|
[data-text-size="xxl"] {
|
|
42
74
|
font-size: 28px;
|
|
43
75
|
line-height: 34px;
|
|
44
|
-
letter-spacing: -0.
|
|
76
|
+
letter-spacing: -0.021em;
|
|
45
77
|
}
|
|
46
78
|
[data-text-size="xxxl"] {
|
|
47
79
|
font-size: 32px;
|
|
48
80
|
line-height: 38px;
|
|
49
|
-
letter-spacing: -0.
|
|
81
|
+
letter-spacing: -0.022em;
|
|
50
82
|
}
|
|
51
83
|
|
|
52
84
|
/* Refer to `use_screen_size` for breakpoints */
|
|
53
85
|
@media screen and (min-width: 768px) {
|
|
54
86
|
[data-text-size="xs"] {
|
|
55
87
|
font-size: 12px;
|
|
56
|
-
line-height:
|
|
88
|
+
line-height: 18px;
|
|
57
89
|
}
|
|
58
90
|
[data-text-size="sm"] {
|
|
59
91
|
font-size: 14px;
|
|
60
|
-
line-height:
|
|
92
|
+
line-height: 24px;
|
|
61
93
|
}
|
|
62
94
|
[data-text-size="md"] {
|
|
63
95
|
font-size: 16px;
|
|
64
|
-
line-height:
|
|
96
|
+
line-height: 26px;
|
|
65
97
|
}
|
|
66
98
|
[data-text-size="lg"] {
|
|
67
99
|
font-size: 20px;
|
|
68
|
-
line-height:
|
|
100
|
+
line-height: 30px;
|
|
69
101
|
}
|
|
70
102
|
[data-text-size="xl"] {
|
|
71
103
|
font-size: 24px;
|
package/src/text.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import "./text.css";
|
|
2
2
|
import React from "react";
|
|
3
|
-
import { Platform, Text as RNText, TextProps as RNTextProps, StyleSheet } from "react-native";
|
|
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
6
|
import {
|
|
6
7
|
fontFamilyMedium,
|
|
7
8
|
fontFamilyRegular,
|
|
@@ -61,7 +62,11 @@ export interface TextProps {
|
|
|
61
62
|
|
|
62
63
|
export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
/**
|
|
66
|
+
* The rungs of the ramp — the set is the ramp's own keys rather than a literal
|
|
67
|
+
* union repeated here, so a rung cannot exist in one and not the other.
|
|
68
|
+
*/
|
|
69
|
+
export type TextSize = TypeRungName;
|
|
65
70
|
type TextAlign = "left" | "right" | "center";
|
|
66
71
|
type TextDecorationLine = "underline" | "lineThrough" | "underline lineThrough";
|
|
67
72
|
type TextWeight = "regular" | "medium" | "semibold";
|
|
@@ -116,8 +121,12 @@ export function Text(props: TextProps) {
|
|
|
116
121
|
{...webProps}
|
|
117
122
|
style={[
|
|
118
123
|
{ color: getTextColor(color) },
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
// Web takes size, leading AND tracking from `text.css` via the
|
|
125
|
+
// `data-text-size` attribute set above; native takes all three here. Nothing
|
|
126
|
+
// sets tracking for both — a flat value applied on both platforms used
|
|
127
|
+
// to, which on web was dead weight the cascade overrode and on native
|
|
128
|
+
// was the reason the curve never reached the mobile app.
|
|
129
|
+
Platform.OS !== "web" && sizeStyles[size],
|
|
121
130
|
styles[weight],
|
|
122
131
|
// AFTER the weight, because the weight ladder sets a family too — each
|
|
123
132
|
// rung is a distinct Inter family, not a `font-weight` — so the display
|
|
@@ -153,43 +162,31 @@ const selectStyles = StyleSheet.create({
|
|
|
153
162
|
none: { userSelect: "none" },
|
|
154
163
|
});
|
|
155
164
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
// (page/record title), xxxl = hero numbers.
|
|
180
|
-
xl: {
|
|
181
|
-
fontSize: 22,
|
|
182
|
-
lineHeight: 28,
|
|
183
|
-
},
|
|
184
|
-
xxl: {
|
|
185
|
-
fontSize: 28,
|
|
186
|
-
lineHeight: 34,
|
|
187
|
-
},
|
|
188
|
-
xxxl: {
|
|
189
|
-
fontSize: 32,
|
|
190
|
-
lineHeight: 38,
|
|
191
|
-
},
|
|
165
|
+
/**
|
|
166
|
+
* The native ramp, DERIVED from `type_ramp.ts` rather than written out again.
|
|
167
|
+
* It was a second hand-maintained table and had drifted from the web one on the
|
|
168
|
+
* leading of three rungs and the tracking of all seven — the flat `-0.4` it
|
|
169
|
+
* applied at every size was the whole of native's tracking, so the curve simply
|
|
170
|
+
* did not exist there. Deriving it means there is no longer a second table to
|
|
171
|
+
* drift.
|
|
172
|
+
*
|
|
173
|
+
* Native takes the MOBILE rung because a phone is a phone; the web ramp grows
|
|
174
|
+
* its display sizes above 768px and native has no equivalent breakpoint.
|
|
175
|
+
*/
|
|
176
|
+
const sizeStyles = StyleSheet.create(
|
|
177
|
+
Object.fromEntries(
|
|
178
|
+
(Object.keys(TYPE_RAMP_MOBILE) as TypeRungName[]).map((rung) => [
|
|
179
|
+
rung,
|
|
180
|
+
{
|
|
181
|
+
fontSize: TYPE_RAMP_MOBILE[rung].size,
|
|
182
|
+
lineHeight: TYPE_RAMP_MOBILE[rung].leading,
|
|
183
|
+
letterSpacing: trackingPx(rung, TYPE_RAMP_MOBILE[rung].size),
|
|
184
|
+
},
|
|
185
|
+
]),
|
|
186
|
+
) as Record<TypeRungName, TextStyle>,
|
|
187
|
+
);
|
|
192
188
|
|
|
189
|
+
const styles = StyleSheet.create({
|
|
193
190
|
// Text alignment styles
|
|
194
191
|
left: {
|
|
195
192
|
textAlign: "left",
|
package/src/text_input_field.tsx
CHANGED
|
@@ -13,7 +13,7 @@ import { Ref, useCallback, useState } from "react";
|
|
|
13
13
|
import { Icon, IconName } from "./icon";
|
|
14
14
|
import { IconButton } from "./icon_button";
|
|
15
15
|
import { ShortcutBadge } from "./shortcut_badge";
|
|
16
|
-
import { fontFamilyRegular, getInputTextStyle, getMultilineInputHeight } from "./text_utils";
|
|
16
|
+
import { fontFamilyRegular, getInputTextStyle, getMultilineInputHeight, INPUT_LETTER_SPACING } from "./text_utils";
|
|
17
17
|
import { useScreenSize } from "./use_screen_size";
|
|
18
18
|
import { useAutoGrowHeight } from "./use_auto_grow_height";
|
|
19
19
|
import { useFormField } from "./form_field";
|
|
@@ -250,7 +250,7 @@ const styles = StyleSheet.create({
|
|
|
250
250
|
paddingVertical: INPUT_PADDING_Y,
|
|
251
251
|
paddingHorizontal: 8,
|
|
252
252
|
fontFamily: fontFamilyRegular,
|
|
253
|
-
letterSpacing:
|
|
253
|
+
letterSpacing: INPUT_LETTER_SPACING,
|
|
254
254
|
},
|
|
255
255
|
// The HOST paints the surface AND owns the text inset. Height and vertical
|
|
256
256
|
// padding stay (they hold the field's size); the horizontal padding goes,
|
package/src/text_utils.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Platform, TextStyle } from "react-native";
|
|
2
2
|
import { colors } from "./colors";
|
|
3
3
|
import { multilineInputHeight } from "./input_metrics";
|
|
4
|
+
import { TYPE_RAMP_DESKTOP, TYPE_RAMP_MOBILE } from "./type_ramp";
|
|
4
5
|
|
|
5
6
|
export type TextColor =
|
|
6
7
|
| "default"
|
|
@@ -51,10 +52,44 @@ export function getTextColor(color?: TextColor): string {
|
|
|
51
52
|
// Re-exported here so every existing consumer keeps one import.
|
|
52
53
|
export { fontFamilyRegular, fontFamilyMedium, fontFamilySemiBold } from "./font_family";
|
|
53
54
|
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Tracking for input text — the `sm` rung of Inter's curve (`TYPE_TRACKING.sm`,
|
|
57
|
+
* -0.006em) as px, because React Native's `letterSpacing` has no em unit.
|
|
58
|
+
*
|
|
59
|
+
* Six inputs each hard-coded their own value before this; there is one of it now,
|
|
60
|
+
* next to the line height, because they are the same contract — an input is
|
|
61
|
+
* running text and sits at the body rhythm on both axes.
|
|
62
|
+
*
|
|
63
|
+
* NOT derived, deliberately, unlike the line heights beside it. An input is 14px
|
|
64
|
+
* on desktop and 16px on mobile, so no single px value is the em at both sizes —
|
|
65
|
+
* computing one from a rung would pick a size and call the result exact. Written
|
|
66
|
+
* out, it stays honestly an approximation: right at 14px, a hair light at 16px.
|
|
67
|
+
* `type_ramp.test.ts` pins it to within a hundredth of the `sm` rung so it cannot
|
|
68
|
+
* drift away from the curve it belongs to; the exact form (an `em` string through
|
|
69
|
+
* RN's style types) is not portable.
|
|
70
|
+
*/
|
|
71
|
+
export const INPUT_LETTER_SPACING = -0.08;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Line height for mobile web — the input is 16px there (to defeat iOS-Safari
|
|
75
|
+
* focus-zoom), so it takes the `md` rung's leading. DERIVED, because an input is
|
|
76
|
+
* running text and sits at the `Text` rhythm by definition rather than by
|
|
77
|
+
* coincidence.
|
|
78
|
+
*
|
|
79
|
+
* `--input-line-height` in `index.css` is a second copy of this that cannot be
|
|
80
|
+
* avoided — the variable is what actually RENDERS, this constant is what the
|
|
81
|
+
* auto-grow and compact-input math MEASURES with — so `type_ramp.test.ts` asserts
|
|
82
|
+
* the two agree. Disagreeing, they give a field a box computed for a rhythm it
|
|
83
|
+
* does not render.
|
|
84
|
+
*/
|
|
85
|
+
export const INPUT_LINE_HEIGHT_MOBILE = TYPE_RAMP_MOBILE.md.leading;
|
|
86
|
+
/**
|
|
87
|
+
* Line height for desktop web and native — 14px text, so the `sm` rung: PROSE
|
|
88
|
+
* leading, the same 24px `Text` and `.ui-markdown` use. A note typed into a field
|
|
89
|
+
* and the same note rendered as markdown have to sit at one rhythm; at 20 the
|
|
90
|
+
* typed version was four pixels tighter per line.
|
|
91
|
+
*/
|
|
92
|
+
export const INPUT_LINE_HEIGHT_DESKTOP = TYPE_RAMP_DESKTOP.sm.leading;
|
|
58
93
|
|
|
59
94
|
/**
|
|
60
95
|
* Input text style for HTML elements on web using CSS variables.
|
|
@@ -99,8 +134,13 @@ export const inputTextStyleRN = inputTextStyleWeb as unknown as TextStyle;
|
|
|
99
134
|
* Native doesn't have Safari iOS auto-zoom issue, so we use 14px.
|
|
100
135
|
*/
|
|
101
136
|
const inputTextStyleNative: TextStyle = {
|
|
102
|
-
fontSize:
|
|
103
|
-
lineHeight:
|
|
137
|
+
fontSize: TYPE_RAMP_DESKTOP.sm.size,
|
|
138
|
+
lineHeight: INPUT_LINE_HEIGHT_DESKTOP,
|
|
139
|
+
// Native was the one input surface carrying NO tracking at all, so a value
|
|
140
|
+
// typed on a phone sat looser than the same value rendered as `Text` a row
|
|
141
|
+
// above it — the only place in the product where an input and a label
|
|
142
|
+
// disagreed on the axis rather than on the amount.
|
|
143
|
+
letterSpacing: INPUT_LETTER_SPACING,
|
|
104
144
|
// Same ink as the web contract above — an input's text is body ink, not black.
|
|
105
145
|
color: getTextColor(),
|
|
106
146
|
};
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type LoticsTheme } from "./theme_vars";
|
|
2
|
+
|
|
3
|
+
export { THEME_VARS, deriveAccentWash, type LoticsTheme } from "./theme_vars";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The NATIVE half — a no-op, and honestly so.
|
|
7
|
+
*
|
|
8
|
+
* There are no CSS custom properties off the web, and `colors.ts` is a literal
|
|
9
|
+
* table rather than a set of `var()` reads, so there is nothing a theme could
|
|
10
|
+
* change. Accepting the call and doing nothing keeps ONE call site compiling on
|
|
11
|
+
* both platforms; pretending to theme would be worse than not offering it.
|
|
12
|
+
*/
|
|
13
|
+
export function applyLoticsTheme(_theme: LoticsTheme): void {}
|