@lotics/ui 47.6.0 → 47.6.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 +22 -3
- package/MIGRATION.md +61 -62
- package/docs/ai_patterns.md +9 -2
- package/docs/catalog.md +37 -3
- package/docs/composition.md +148 -10
- package/docs/data_entry.md +8 -0
- package/docs/reviewing.md +51 -2
- package/examples/tpl_attendance.tsx +1 -1
- package/package.json +3 -1
- package/src/alert.tsx +1 -1
- package/src/avatar.tsx +1 -1
- package/src/avatar.web.tsx +1 -1
- package/src/avatar_group.tsx +1 -1
- package/src/bar_chart.tsx +10 -1
- package/src/board.tsx +1 -1
- package/src/button.tsx +4 -4
- package/src/cell_stack.tsx +140 -0
- package/src/column_filter.tsx +1 -1
- package/src/combobox.tsx +2 -2
- package/src/comments_thread.tsx +2 -2
- package/src/count.tsx +1 -1
- package/src/date_calendar.tsx +2 -2
- package/src/date_filter.tsx +1 -1
- package/src/diff_value.tsx +2 -2
- package/src/eyebrow.tsx +1 -1
- package/src/file_thumbnail.tsx +2 -2
- package/src/file_thumbnail_grid.tsx +1 -1
- package/src/filter_chip.tsx +5 -2
- package/src/finding.tsx +19 -2
- package/src/form_field.tsx +1 -1
- package/src/inline_date_picker.tsx +1 -1
- package/src/inline_edit.tsx +2 -1
- package/src/line_chart.tsx +3 -1
- package/src/list_item.tsx +1 -1
- package/src/loading.tsx +1 -1
- package/src/matrix.tsx +1 -1
- package/src/member_chip.tsx +1 -1
- package/src/member_profile_card.tsx +1 -1
- package/src/menu_list_item.tsx +2 -2
- package/src/metric.tsx +1 -1
- package/src/option_list.tsx +1 -1
- package/src/page_header.tsx +26 -25
- package/src/pagination.tsx +1 -1
- package/src/pie_chart.tsx +4 -2
- package/src/radio_picker.tsx +3 -3
- package/src/section_heading.tsx +74 -5
- package/src/segmented_control.tsx +1 -1
- package/src/select.tsx +1 -1
- package/src/shortcut_badge.tsx +1 -1
- package/src/stacked_bar_chart.tsx +1 -1
- package/src/switcher.tsx +1 -1
- package/src/table.tsx +46 -8
- package/src/tabs.tsx +2 -2
- package/src/text.tsx +2 -7
- package/src/text_ink.ts +96 -0
- package/src/text_link.tsx +1 -1
- package/src/text_utils.ts +1 -46
- package/src/thumbnail_stack.tsx +1 -1
- package/src/time_picker.tsx +1 -1
- package/src/timeline.tsx +1 -1
- package/src/tokens.ts +28 -4
- package/src/tooltip.tsx +1 -1
- package/src/uploading_thumbnail.tsx +1 -1
package/src/section_heading.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Children } from "react";
|
|
1
2
|
import { View, type StyleProp, type ViewStyle } from "react-native";
|
|
2
3
|
import { Text, type HeadingLevel, type TextSize } from "./text";
|
|
3
4
|
import { Icon, type IconName } from "./icon";
|
|
@@ -143,7 +144,7 @@ export function SubsectionHeadingTitle(props: SubsectionHeadingTitleProps) {
|
|
|
143
144
|
{info ? <InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} /> : null}
|
|
144
145
|
</View>
|
|
145
146
|
{description ? (
|
|
146
|
-
<Text color="
|
|
147
|
+
<Text color="muted" size="sm">
|
|
147
148
|
{description}
|
|
148
149
|
</Text>
|
|
149
150
|
) : null}
|
|
@@ -156,10 +157,63 @@ export interface SectionHeadingProps {
|
|
|
156
157
|
style?: StyleProp<ViewStyle>;
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The heading's TRAILING slot — every child after the title, as ONE unit.
|
|
162
|
+
*
|
|
163
|
+
* Two things were wrong with laying them out as independent flex items of a
|
|
164
|
+
* wrapping row. With TWO verbs, one stayed on the title's row and the other
|
|
165
|
+
* dropped to the next at a different indent, so a single action group rendered
|
|
166
|
+
* as two unrelated controls; the group must move or stay together, and only the
|
|
167
|
+
* TITLE should ever break away from it. And a trailing child was centred against
|
|
168
|
+
* the title COLUMN — title plus description — so its height came from how many
|
|
169
|
+
* lines that description happened to wrap to, and it landed somewhere different
|
|
170
|
+
* in each section of one screen. It belongs on the title's FIRST line, which is
|
|
171
|
+
* the line box of whichever rung the altitude selected.
|
|
172
|
+
*
|
|
173
|
+
* One child keeps the old geometry exactly: with nothing to hold together there
|
|
174
|
+
* is no group, and the single verb still drops below a title too long to share
|
|
175
|
+
* the row.
|
|
176
|
+
*/
|
|
159
177
|
export function SectionHeading(props: SectionHeadingProps) {
|
|
178
|
+
const children = Children.toArray(props.children);
|
|
179
|
+
const [title, ...trailing] = children;
|
|
180
|
+
const grouped = trailing.length > 1;
|
|
181
|
+
|
|
160
182
|
return (
|
|
161
|
-
<View
|
|
162
|
-
{
|
|
183
|
+
<View
|
|
184
|
+
style={[
|
|
185
|
+
{ flexDirection: "row", flexWrap: "wrap", gap: 12 },
|
|
186
|
+
// Top-aligned once there is a group, so it rides the title's FIRST line
|
|
187
|
+
// instead of the centre of a column whose height is however many lines
|
|
188
|
+
// the description happened to wrap to. With a lone trailing child there
|
|
189
|
+
// is nothing to hold together and centring is still the right answer.
|
|
190
|
+
{ alignItems: grouped ? "flex-start" : "center" },
|
|
191
|
+
props.style,
|
|
192
|
+
]}
|
|
193
|
+
>
|
|
194
|
+
{grouped ? (
|
|
195
|
+
<>
|
|
196
|
+
{title}
|
|
197
|
+
{/* The group wraps INSIDE itself and shrinks; only its relationship to
|
|
198
|
+
the TITLE is rigid. `flexShrink: 0` here was wrong in the way RN-Web
|
|
199
|
+
punishes: a View is `flexShrink: 0` already, so a group wider than
|
|
200
|
+
the heading never shrinks, never wraps, and paints past the right
|
|
201
|
+
edge — visible to `checkVisibility()`, invisible to an overflow
|
|
202
|
+
check because the parent clips, and simply absent on a phone. The
|
|
203
|
+
`flexShrink: 1` + `minWidth: 0` PAIR is what actually lets it wrap;
|
|
204
|
+
shrink alone floors the box at its min-content width. */}
|
|
205
|
+
<View
|
|
206
|
+
style={{
|
|
207
|
+
flexDirection: "row", alignItems: "center", gap: 12,
|
|
208
|
+
flexWrap: "wrap", flexShrink: 1, minWidth: 0,
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
{trailing}
|
|
212
|
+
</View>
|
|
213
|
+
</>
|
|
214
|
+
) : (
|
|
215
|
+
props.children
|
|
216
|
+
)}
|
|
163
217
|
</View>
|
|
164
218
|
);
|
|
165
219
|
}
|
|
@@ -168,6 +222,20 @@ export interface SectionHeadingTitleProps {
|
|
|
168
222
|
children: React.ReactNode;
|
|
169
223
|
description?: string;
|
|
170
224
|
icon?: IconName;
|
|
225
|
+
/**
|
|
226
|
+
* A MARK for the thing the heading names — an `Avatar` for a company or person,
|
|
227
|
+
* a logo, a `FileThumbnail`. Rendered inside the title's own column, before the
|
|
228
|
+
* text, so the pair reads as one subject.
|
|
229
|
+
*
|
|
230
|
+
* Distinct from `icon`, which takes an `IconName` and means a CATEGORY: files,
|
|
231
|
+
* comments, a warning. An identity cannot be an icon name, and passing the mark
|
|
232
|
+
* as a sibling of this component instead makes it the heading's FIRST child —
|
|
233
|
+
* which `SectionHeading` reads as the title, so the real title joins the trailing
|
|
234
|
+
* group and the row stops pushing anything to its right edge. That failure is
|
|
235
|
+
* silent: everything renders, nothing clips, and the action simply sits in the
|
|
236
|
+
* middle of the row.
|
|
237
|
+
*/
|
|
238
|
+
leading?: React.ReactNode;
|
|
171
239
|
/**
|
|
172
240
|
* Heading rank, when the surface's own outline needs one the altitude does not
|
|
173
241
|
* give it. Leave it unset: the default is the rank of whichever rung the
|
|
@@ -221,7 +289,7 @@ const SECTION_RUNG = {
|
|
|
221
289
|
* Optional muted `description` line; grows to push any sibling actions to the
|
|
222
290
|
* right edge. */
|
|
223
291
|
export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
|
|
224
|
-
const { children, description, icon, level, weight = "semibold", info } = props;
|
|
292
|
+
const { children, description, icon, leading, level, weight = "semibold", info } = props;
|
|
225
293
|
// Hoisted — a hook inside the `info ?` ternary breaks hook ordering the
|
|
226
294
|
// render `info` appears or disappears.
|
|
227
295
|
const words = useLoticsLocale();
|
|
@@ -229,6 +297,7 @@ export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
|
|
|
229
297
|
return (
|
|
230
298
|
<View style={{ flex: 1, minWidth: HEADING_TITLE_MIN_WIDTH, gap: 2 }}>
|
|
231
299
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
300
|
+
{leading}
|
|
232
301
|
{icon ? <Icon name={icon} size={rung.icon} /> : null}
|
|
233
302
|
<Text level={level ?? rung.level} weight={weight} size={rung.size}>
|
|
234
303
|
{children}
|
|
@@ -236,7 +305,7 @@ export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
|
|
|
236
305
|
{info ? <InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} /> : null}
|
|
237
306
|
</View>
|
|
238
307
|
{description ? (
|
|
239
|
-
<Text color="
|
|
308
|
+
<Text color="muted" size="sm">
|
|
240
309
|
{description}
|
|
241
310
|
</Text>
|
|
242
311
|
) : null}
|
|
@@ -163,7 +163,7 @@ function Segment<T extends string>(props: SegmentProps<T>) {
|
|
|
163
163
|
<Text
|
|
164
164
|
size="sm"
|
|
165
165
|
weight={selected ? "medium" : "regular"}
|
|
166
|
-
color={selected || hovered ? "
|
|
166
|
+
color={selected || hovered ? "default" : "muted"}
|
|
167
167
|
numberOfLines={1}
|
|
168
168
|
userSelect="none"
|
|
169
169
|
>
|
package/src/select.tsx
CHANGED
|
@@ -295,7 +295,7 @@ function SelectTrigger<T extends string, D = unknown>({
|
|
|
295
295
|
<Text userSelect="none">{selectedItems.map((s) => s.label).join(", ")}</Text>
|
|
296
296
|
)
|
|
297
297
|
) : (
|
|
298
|
-
<Text color="
|
|
298
|
+
<Text color="muted" userSelect="none">
|
|
299
299
|
{placeholder}
|
|
300
300
|
</Text>
|
|
301
301
|
)}
|
package/src/shortcut_badge.tsx
CHANGED
|
@@ -18,7 +18,7 @@ export function ShortcutBadge(props: ShortcutBadgeProps) {
|
|
|
18
18
|
|
|
19
19
|
return (
|
|
20
20
|
<View style={styles.badge}>
|
|
21
|
-
<Text size="xs" color="
|
|
21
|
+
<Text size="xs" color="muted" weight="medium" style={{ color: colors.zinc[400] }}>{label}</Text>
|
|
22
22
|
</View>
|
|
23
23
|
);
|
|
24
24
|
}
|
|
@@ -6,7 +6,7 @@ import { useLoticsLocale } from "./locale";
|
|
|
6
6
|
import { SPACE } from "./spacing";
|
|
7
7
|
import { Text } from "./text";
|
|
8
8
|
import { TYPE_LEADING_TIGHT_DESKTOP, TYPE_LEADING_TIGHT_MOBILE } from "./type_ramp";
|
|
9
|
-
import type { TextColor } from "./
|
|
9
|
+
import type { TextColor } from "./text_ink";
|
|
10
10
|
|
|
11
11
|
export interface StackedBarSeries {
|
|
12
12
|
key: string;
|
package/src/switcher.tsx
CHANGED
|
@@ -70,7 +70,7 @@ export function Switcher(props: SwitcherProps) {
|
|
|
70
70
|
<Popover open={open} onOpenChange={setOpen} side={side} align={align}>
|
|
71
71
|
<PopoverTrigger>
|
|
72
72
|
<PressableHighlight focusRing style={[styles.trigger, { maxWidth: maxTriggerWidth }]}>
|
|
73
|
-
<Text size="sm" weight="medium" color="
|
|
73
|
+
<Text size="sm" weight="medium" color="muted" numberOfLines={1}>
|
|
74
74
|
{label}
|
|
75
75
|
</Text>
|
|
76
76
|
<Icon name="chevrons-up-down" size={14} color={colors.zinc["500"]} />
|
package/src/table.tsx
CHANGED
|
@@ -14,6 +14,7 @@ import { Animated, StyleSheet, View, type ViewStyle } from "react-native";
|
|
|
14
14
|
import { Text } from "./text";
|
|
15
15
|
import { colors, solid, type ColorName } from "./colors";
|
|
16
16
|
import { PressableRow } from "./pressable_row";
|
|
17
|
+
import { CONTROL_HEIGHT } from "./control_surface";
|
|
17
18
|
import { PressDoor } from "./press_door";
|
|
18
19
|
import { DetailRow } from "./detail_row";
|
|
19
20
|
import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
|
|
@@ -72,6 +73,7 @@ interface TableCtx {
|
|
|
72
73
|
/** Width of the ordinal gutter, 0 when the register is not counted. */
|
|
73
74
|
ordinal: number;
|
|
74
75
|
trailing: number;
|
|
76
|
+
align: "center" | "top";
|
|
75
77
|
visibleKeys: ReadonlySet<string>;
|
|
76
78
|
stacked: boolean;
|
|
77
79
|
}
|
|
@@ -182,6 +184,32 @@ export interface TableProps {
|
|
|
182
184
|
headerFill?: boolean;
|
|
183
185
|
/** Reserve a trailing gutter (px) for rows that render a `trailing` slot (a ⋯ / button). */
|
|
184
186
|
trailing?: number;
|
|
187
|
+
/**
|
|
188
|
+
* How a row's cells sit against each other VERTICALLY. Default `center`, which
|
|
189
|
+
* is right while every cell is one line — and wrong the moment one is not.
|
|
190
|
+
*
|
|
191
|
+
* Centring resolves PER CELL, so a cell that grows a second line moves its own
|
|
192
|
+
* content up relative to its single-line siblings, and the trailing control,
|
|
193
|
+
* centred against the tallest cell, drops below the field it acts on. The offset
|
|
194
|
+
* varies with the DATA, so the controls stop forming a column at all — measured
|
|
195
|
+
* at 10px on one row and 27px on another in the same register.
|
|
196
|
+
*
|
|
197
|
+
* `top` puts every cell and both gutters on one top edge. A single-line row is
|
|
198
|
+
* PIXEL-IDENTICAL either way, because the slack centring would have spent becomes
|
|
199
|
+
* padding — so this is safe to pass anywhere and shows only once a row is taller
|
|
200
|
+
* than one line.
|
|
201
|
+
*
|
|
202
|
+
* **Pass `top` on a register that has a TRAILING CONTROL beside multi-line cells.**
|
|
203
|
+
* That is the case it exists for: the control must line up with the field it acts
|
|
204
|
+
* on, and centring puts it wherever the tallest cell leaves it.
|
|
205
|
+
*
|
|
206
|
+
* WITHOUT a trailing control, prefer the default. Centring lets each cell's block
|
|
207
|
+
* sit in the middle of the row, so a one-line value beside a two-line neighbour
|
|
208
|
+
* reads as balanced rather than stranded at the top — and with nothing to line up
|
|
209
|
+
* against, top alignment buys nothing and costs that. Losing the control is a
|
|
210
|
+
* reason to drop `top` again, which is easy to miss because nothing breaks.
|
|
211
|
+
*/
|
|
212
|
+
align?: "center" | "top";
|
|
185
213
|
/** The `TableRow`s. */
|
|
186
214
|
children: ReactNode;
|
|
187
215
|
}
|
|
@@ -201,7 +229,7 @@ export interface TableProps {
|
|
|
201
229
|
* keeps its current order). See `computeTableFit`.
|
|
202
230
|
*/
|
|
203
231
|
export function Table(props: TableProps) {
|
|
204
|
-
const { columns, sort, onSort, sortLabels, selectAll, leading = 0, count, counted, headerFill, trailing = 0, children } = props;
|
|
232
|
+
const { columns, sort, onSort, sortLabels, selectAll, leading = 0, count, counted, headerFill, trailing = 0, align = "center", children } = props;
|
|
205
233
|
// Either prop reserves the gutter; only `count` puts a number in its header.
|
|
206
234
|
// `LeadGutter` already renders an empty box when it has no number — the same
|
|
207
235
|
// path a group band takes — so nothing new is needed to draw it.
|
|
@@ -222,7 +250,7 @@ export function Table(props: TableProps) {
|
|
|
222
250
|
const visibleColumns = columns.filter((c) => fit.visibleKeys.has(c.key));
|
|
223
251
|
|
|
224
252
|
return (
|
|
225
|
-
<TableContext.Provider value={{ columns, leading, ordinal, trailing, visibleKeys: fit.visibleKeys, stacked: fit.stacked }}>
|
|
253
|
+
<TableContext.Provider value={{ columns, leading, ordinal, trailing, align, visibleKeys: fit.visibleKeys, stacked: fit.stacked }}>
|
|
226
254
|
{/* ONE layout node: without this wrapper the header band + body land as two
|
|
227
255
|
direct flex children of the app's container, and a parent column `gap`
|
|
228
256
|
(the standard section spacing) opens a hole between the header and rows. */}
|
|
@@ -354,7 +382,7 @@ export function TableGroup(props: TableGroupProps) {
|
|
|
354
382
|
{label}
|
|
355
383
|
</Text>
|
|
356
384
|
{count != null ? (
|
|
357
|
-
<Text size="sm" color="
|
|
385
|
+
<Text size="sm" color="muted" tabular>
|
|
358
386
|
{count}
|
|
359
387
|
</Text>
|
|
360
388
|
) : null}
|
|
@@ -540,7 +568,12 @@ export function TableRow(props: TableRowProps) {
|
|
|
540
568
|
|
|
541
569
|
// ONE style for the column row, shared by the pressable and read-only paths
|
|
542
570
|
// below, which render near-identical Views.
|
|
543
|
-
const
|
|
571
|
+
const topAligned = ctx.align === "top";
|
|
572
|
+
const alignPad = (ROW_HEIGHT - CONTROL_HEIGHT) / 2;
|
|
573
|
+
const vAlign = topAligned
|
|
574
|
+
? ({ alignItems: "flex-start", paddingVertical: alignPad } as const)
|
|
575
|
+
: null;
|
|
576
|
+
const cellsStyle = [styles.cells, { minHeight: rowMinHeight }, vAlign];
|
|
544
577
|
|
|
545
578
|
const body = ctx.stacked ? (
|
|
546
579
|
<View style={styles.stackedBody}>
|
|
@@ -574,11 +607,11 @@ export function TableRow(props: TableRowProps) {
|
|
|
574
607
|
return <View style={styles.staticStackedRow}>{body}</View>;
|
|
575
608
|
}
|
|
576
609
|
return (
|
|
577
|
-
<View style={styles.staticRow}>
|
|
610
|
+
<View style={[styles.staticRow, vAlign]}>
|
|
578
611
|
<LeadGutter ordinal={ordinal}>{leading}</LeadGutter>
|
|
579
612
|
<View style={cellsStyle}>{cells}</View>
|
|
580
613
|
{ctx.trailing > 0 ? (
|
|
581
|
-
<View style={[styles.trailingSlot, { width: ctx.trailing }]}>
|
|
614
|
+
<View style={[styles.trailingSlot, { width: ctx.trailing }, vAlign]}>
|
|
582
615
|
{action}
|
|
583
616
|
{trailing}
|
|
584
617
|
</View>
|
|
@@ -588,7 +621,12 @@ export function TableRow(props: TableRowProps) {
|
|
|
588
621
|
}
|
|
589
622
|
|
|
590
623
|
const rowSurface = (
|
|
591
|
-
|
|
624
|
+
// The ROW must stop centring too, not just its cells: `PressableRow` centres the
|
|
625
|
+
// cells block and the trailing gutter as WHOLE blocks, so a taller cells block
|
|
626
|
+
// left the gutter — and the control in it — floating in the middle of the row.
|
|
627
|
+
// Styling the slot's children could never reach that; the row's own axis had to.
|
|
628
|
+
<PressableRow onPress={onPress} selected={selected || showDetail} marked={marked}
|
|
629
|
+
style={topAligned ? [styles.row, { alignItems: "flex-start" as const }] : styles.row}>
|
|
592
630
|
<ArrivalWash on={justArrived === true} />
|
|
593
631
|
{!ctx.stacked ? <LeadGutter ordinal={ordinal}>{leading}</LeadGutter> : null}
|
|
594
632
|
{/* The door hit-tests above in-flow content; the cells/slots lift back above it
|
|
@@ -601,7 +639,7 @@ export function TableRow(props: TableRowProps) {
|
|
|
601
639
|
<>
|
|
602
640
|
<View style={cellsStyle}>{cells}</View>
|
|
603
641
|
{ctx.trailing > 0 ? (
|
|
604
|
-
<View style={[styles.slot, styles.trailingSlot, { width: ctx.trailing }]}>
|
|
642
|
+
<View style={[styles.slot, styles.trailingSlot, { width: ctx.trailing }, vAlign]}>
|
|
605
643
|
{action}
|
|
606
644
|
{trailing}
|
|
607
645
|
</View>
|
package/src/tabs.tsx
CHANGED
|
@@ -121,13 +121,13 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
|
|
|
121
121
|
) : null}
|
|
122
122
|
<Text
|
|
123
123
|
weight={selected ? "medium" : "regular"}
|
|
124
|
-
color={selected ? "
|
|
124
|
+
color={selected ? "default" : "muted"}
|
|
125
125
|
userSelect="none"
|
|
126
126
|
>
|
|
127
127
|
{option.label}
|
|
128
128
|
</Text>
|
|
129
129
|
{option.count != null ? (
|
|
130
|
-
<Text color="
|
|
130
|
+
<Text color="muted" tabular userSelect="none">
|
|
131
131
|
{option.count}
|
|
132
132
|
</Text>
|
|
133
133
|
) : null}
|
package/src/text.tsx
CHANGED
|
@@ -3,13 +3,8 @@ 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
5
|
import { TYPE_LEADING_TIGHT_MOBILE, TYPE_RAMP_MOBILE, trackingPx, type TypeRungName } from "./type_ramp";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
fontFamilyRegular,
|
|
9
|
-
fontFamilySemiBold,
|
|
10
|
-
getTextColor,
|
|
11
|
-
TextColor,
|
|
12
|
-
} from "./text_utils";
|
|
6
|
+
import { fontFamilyMedium, fontFamilyRegular, fontFamilySemiBold } from "./text_utils";
|
|
7
|
+
import { getTextColor, TextColor } from "./text_ink";
|
|
13
8
|
|
|
14
9
|
export interface TextProps {
|
|
15
10
|
children?: RNTextProps["children"];
|
package/src/text_ink.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { colors } from "./colors";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TEXT INK, AS AN ALIAS TABLE — the semantic layer, addressable.
|
|
5
|
+
*
|
|
6
|
+
* Two layers, and only one of them is a decision:
|
|
7
|
+
*
|
|
8
|
+
* - The **primitive** layer is the palette (`colors.zinc[600]`). It holds values
|
|
9
|
+
* and owns nothing; it is the same Tailwind ramp anyone could paste.
|
|
10
|
+
* - The **semantic** layer is this table. It holds the actual decisions — "a line
|
|
11
|
+
* that supports the thing above it is one step lighter than body" — and every
|
|
12
|
+
* component addresses ink through it.
|
|
13
|
+
*
|
|
14
|
+
* This used to be a `switch` inside `getTextColor`, which made the semantic layer
|
|
15
|
+
* real but not REFERENCEABLE: the roles existed as a prop union on `Text` and
|
|
16
|
+
* nowhere else, so nothing outside a `.tsx` file could name them. That is why the
|
|
17
|
+
* palette leaked into call sites and into design: a designer picking `zinc-500` in
|
|
18
|
+
* Figma and an author typing `zinc-500` in code look like one source of truth, but
|
|
19
|
+
* the thing they agree on is a THIRD-PARTY RAMP. The decision that supporting text
|
|
20
|
+
* is zinc-600 was written in neither, so it was re-made at every call site — which
|
|
21
|
+
* is exactly how one role came to render in two inks.
|
|
22
|
+
*
|
|
23
|
+
* As a table it serializes: {@link textInkTokens} emits it in the W3C Design
|
|
24
|
+
* Tokens format with the alias intact (`{color.zinc.600}` rather than a flattened
|
|
25
|
+
* hex), which is the form Figma Variables import as ALIAS variables pointing at
|
|
26
|
+
* primitive ones. One file, both tools, and the alias survives the trip — so
|
|
27
|
+
* re-pointing `muted` moves Figma and the code together instead of starting a
|
|
28
|
+
* find-and-replace.
|
|
29
|
+
*
|
|
30
|
+
* RN-free on purpose, so `@lotics/ui/tokens` stays importable by an app that
|
|
31
|
+
* hand-rolls plain DOM without pulling react-native-web in behind it.
|
|
32
|
+
*/
|
|
33
|
+
export const TEXT_INK = {
|
|
34
|
+
/** Body copy and any value the reader is meant to land on. */
|
|
35
|
+
default: "zinc.900",
|
|
36
|
+
/** ALL supporting text — under a value, under a heading, anywhere. One role. */
|
|
37
|
+
muted: "zinc.600",
|
|
38
|
+
/** A disabled control's label, and a value that is not filled in ("Not set"). */
|
|
39
|
+
inactive: "zinc.400",
|
|
40
|
+
/** Requires a dark ground — illegal on the canvas. */
|
|
41
|
+
onInverse: "white",
|
|
42
|
+
danger: "red.900",
|
|
43
|
+
warning: "amber.700",
|
|
44
|
+
success: "emerald.700",
|
|
45
|
+
} as const satisfies Record<string, string>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Derived from the table, so anything iterating the vocabulary is exhaustive by
|
|
49
|
+
* construction. Declaring the union separately would let a new ink ship without
|
|
50
|
+
* a contrast case and keep every test green.
|
|
51
|
+
*/
|
|
52
|
+
export type TextColor = keyof typeof TEXT_INK;
|
|
53
|
+
|
|
54
|
+
export const TEXT_COLORS = Object.keys(TEXT_INK) as TextColor[];
|
|
55
|
+
|
|
56
|
+
/** Resolve a `family.shade` reference against the palette. */
|
|
57
|
+
function resolve(ref: string): string {
|
|
58
|
+
const [family, shade] = ref.split(".");
|
|
59
|
+
const table = colors as unknown as Record<string, unknown>;
|
|
60
|
+
if (shade === undefined) return table[family] as string;
|
|
61
|
+
return (table[family] as Record<string, string>)[shade];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getTextColor(color?: TextColor): string {
|
|
65
|
+
return resolve(TEXT_INK[color ?? "default"]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The vocabulary in the W3C Design Tokens Community Group format, primitives and
|
|
70
|
+
* aliases in one document — the file a Figma Variables import (or Tokens Studio)
|
|
71
|
+
* reads, so the design tool and the code resolve `muted` from the same place.
|
|
72
|
+
*
|
|
73
|
+
* The alias is emitted as a REFERENCE, never a resolved hex. A flattened export
|
|
74
|
+
* would hand Figma seven unrelated colours and lose the only thing worth syncing:
|
|
75
|
+
* that `muted` IS `zinc.600`, and moves when it moves.
|
|
76
|
+
*/
|
|
77
|
+
export function textInkTokens(): string {
|
|
78
|
+
const palette = colors as unknown as Record<string, unknown>;
|
|
79
|
+
const color: Record<string, unknown> = {};
|
|
80
|
+
for (const [family, value] of Object.entries(palette)) {
|
|
81
|
+
if (typeof value === "string") {
|
|
82
|
+
color[family] = { $type: "color", $value: value };
|
|
83
|
+
} else if (value && typeof value === "object") {
|
|
84
|
+
const scale: Record<string, unknown> = {};
|
|
85
|
+
for (const [shade, hex] of Object.entries(value as Record<string, string>)) {
|
|
86
|
+
scale[shade] = { $type: "color", $value: hex };
|
|
87
|
+
}
|
|
88
|
+
color[family] = scale;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const text: Record<string, unknown> = {};
|
|
92
|
+
for (const [role, ref] of Object.entries(TEXT_INK)) {
|
|
93
|
+
text[role] = { $type: "color", $value: `{color.${ref}}` };
|
|
94
|
+
}
|
|
95
|
+
return JSON.stringify({ color, text }, null, 2);
|
|
96
|
+
}
|
package/src/text_link.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Text, type TextProps } from "./text";
|
|
2
2
|
import { Icon, type IconName } from "./icon";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
|
-
import { getTextColor } from "./
|
|
4
|
+
import { getTextColor } from "./text_ink";
|
|
5
5
|
|
|
6
6
|
export interface TextLinkProps extends TextProps {
|
|
7
7
|
/** Optional leading icon. */
|
package/src/text_utils.ts
CHANGED
|
@@ -1,53 +1,8 @@
|
|
|
1
1
|
import { Platform, TextStyle } from "react-native";
|
|
2
|
-
import {
|
|
2
|
+
import { getTextColor } from "./text_ink";
|
|
3
3
|
import { multilineInputHeight } from "./input_metrics";
|
|
4
4
|
import { TYPE_RAMP_DESKTOP, TYPE_RAMP_MOBILE } from "./type_ramp";
|
|
5
5
|
|
|
6
|
-
export type TextColor =
|
|
7
|
-
| "default"
|
|
8
|
-
| "muted"
|
|
9
|
-
| "inverted"
|
|
10
|
-
| "danger"
|
|
11
|
-
| "warning"
|
|
12
|
-
| "success"
|
|
13
|
-
| "zinc-900"
|
|
14
|
-
| "zinc-700"
|
|
15
|
-
| "zinc-500"
|
|
16
|
-
| "zinc-400";
|
|
17
|
-
|
|
18
|
-
export function getTextColor(color?: TextColor): string {
|
|
19
|
-
switch (color) {
|
|
20
|
-
case "default":
|
|
21
|
-
return colors.zinc["900"];
|
|
22
|
-
case "muted":
|
|
23
|
-
return colors.zinc["600"];
|
|
24
|
-
case "zinc-900":
|
|
25
|
-
return colors.zinc["900"];
|
|
26
|
-
case "zinc-700":
|
|
27
|
-
return colors.zinc["700"];
|
|
28
|
-
case "zinc-500":
|
|
29
|
-
return colors.zinc["500"];
|
|
30
|
-
// The disabled ink — light enough that a control reads inert at a glance
|
|
31
|
-
// (muted/600 still reads as body ink on the control's resting surface).
|
|
32
|
-
case "zinc-400":
|
|
33
|
-
return colors.zinc["400"];
|
|
34
|
-
case "inverted":
|
|
35
|
-
return colors.white;
|
|
36
|
-
// Valence set — the lightest status weight: a state WORD whose meaning is in
|
|
37
|
-
// the word, colored for reinforcement (never color-only). Red sits darker at
|
|
38
|
-
// 900; green/amber use 700 to stay recognizably their hue while clearing AA on
|
|
39
|
-
// white (600 would fail).
|
|
40
|
-
case "danger":
|
|
41
|
-
return colors.red["900"];
|
|
42
|
-
case "warning":
|
|
43
|
-
return colors.amber["700"];
|
|
44
|
-
case "success":
|
|
45
|
-
return colors.emerald["700"];
|
|
46
|
-
default:
|
|
47
|
-
return colors.zinc[900];
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
6
|
// The three rungs live in their own platform-split module — see `font_family.ts`.
|
|
52
7
|
// Re-exported here so every existing consumer keeps one import.
|
|
53
8
|
export { fontFamilyRegular, fontFamilyMedium, fontFamilySemiBold } from "./font_family";
|
package/src/thumbnail_stack.tsx
CHANGED
|
@@ -156,7 +156,7 @@ export function ThumbnailStack(props: ThumbnailStackProps) {
|
|
|
156
156
|
>
|
|
157
157
|
{/* The rung's own type — a fixed size here is how a stack ends up
|
|
158
158
|
with a 14px count beside 12px marks. */}
|
|
159
|
-
<Text size={AVATAR_TEXT[size]} weight="medium" color="
|
|
159
|
+
<Text size={AVATAR_TEXT[size]} weight="medium" color="muted" aria-hidden>
|
|
160
160
|
{`+${hidden}`}
|
|
161
161
|
</Text>
|
|
162
162
|
</View>
|
package/src/time_picker.tsx
CHANGED
|
@@ -105,7 +105,7 @@ export function TimePicker(props: TimePickerProps) {
|
|
|
105
105
|
{formatTimeOfDay(value, localeTag)}
|
|
106
106
|
</Text>
|
|
107
107
|
) : (
|
|
108
|
-
<Text size="sm" color="
|
|
108
|
+
<Text size="sm" color="muted" userSelect="none">
|
|
109
109
|
{placeholder}
|
|
110
110
|
</Text>
|
|
111
111
|
)}
|
package/src/timeline.tsx
CHANGED
|
@@ -119,7 +119,7 @@ export function Timeline(props: TimelineProps) {
|
|
|
119
119
|
numberOfLines={expanded ? undefined : 2}
|
|
120
120
|
// The same ink an unfilled field draws (`Not set`, `No date set`),
|
|
121
121
|
// so an unwritten row reads as unwritten everywhere it appears.
|
|
122
|
-
color={item.placeholder ? "
|
|
122
|
+
color={item.placeholder ? "inactive" : undefined}
|
|
123
123
|
style={{ flex: 1 }}
|
|
124
124
|
>
|
|
125
125
|
{item.label}
|
package/src/tokens.ts
CHANGED
|
@@ -20,10 +20,24 @@
|
|
|
20
20
|
* colors.background → var(--lotics-background)
|
|
21
21
|
* colors.border → var(--lotics-border)
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* TWO LAYERS, and the second one is where the decisions live. The palette above
|
|
24
|
+
* is primitives: values, owned by nobody, the same ramp anyone could paste. The
|
|
25
|
+
* semantic layer is {@link TEXT_INK} — `muted` → `zinc.600` — and it holds the
|
|
26
|
+
* actual rules ("supporting text is one step lighter than body"). Components
|
|
27
|
+
* address ink only through it.
|
|
28
|
+
*
|
|
29
|
+
* This file used to claim the semantic names "don't exist in the parent's TS
|
|
30
|
+
* code", and that was wrong about its own codebase: the roles had always existed
|
|
31
|
+
* as `Text`'s `color` union, used hundreds of times. They were just not
|
|
32
|
+
* ADDRESSABLE — nothing outside a `.tsx` could name one — so the palette leaked
|
|
33
|
+
* into call sites and into design, and one role ended up rendering in two inks.
|
|
34
|
+
*
|
|
35
|
+
* Emitting only primitives is also what broke the Figma story. A designer picking
|
|
36
|
+
* `zinc-500` and an author typing `zinc-500` look like one source of truth, but
|
|
37
|
+
* what they agree on is a THIRD-PARTY RAMP; the decision itself is written in
|
|
38
|
+
* neither tool. {@link textInkTokens} emits both layers with the alias intact, in
|
|
39
|
+
* the W3C Design Tokens format Figma Variables import as alias variables — so the
|
|
40
|
+
* rule has one home and moving it moves both sides.
|
|
27
41
|
*
|
|
28
42
|
* Spacing / type / weight / radius are iframe-side conveniences with no
|
|
29
43
|
* parent equivalent (parent uses bare numeric pixel values inline).
|
|
@@ -33,6 +47,8 @@
|
|
|
33
47
|
|
|
34
48
|
export { colors } from "./colors";
|
|
35
49
|
import { colors } from "./colors";
|
|
50
|
+
export { TEXT_INK, TEXT_COLORS, getTextColor, textInkTokens, type TextColor } from "./text_ink";
|
|
51
|
+
import { TEXT_INK } from "./text_ink";
|
|
36
52
|
|
|
37
53
|
/**
|
|
38
54
|
* Spacing scale (4px base unit). Use for padding, margin, gap.
|
|
@@ -150,5 +166,13 @@ export function getCssVariables(): string {
|
|
|
150
166
|
lines.push(` --lotics-radius-${key}: ${value}px;`);
|
|
151
167
|
}
|
|
152
168
|
|
|
169
|
+
// TEXT INK — emitted as a var() pointing at the PRIMITIVE, not as a copied hex,
|
|
170
|
+
// so `--lotics-text-muted` and `colors.zinc[600]` cannot drift apart and a
|
|
171
|
+
// hand-rolled DOM surface can address the same role a component does.
|
|
172
|
+
for (const [role, ref] of Object.entries(TEXT_INK)) {
|
|
173
|
+
const primitive = ref.replace(".", "-");
|
|
174
|
+
lines.push(` --lotics-ink-${role.toLowerCase()}: var(--lotics-${primitive});`);
|
|
175
|
+
}
|
|
176
|
+
|
|
153
177
|
return `:root {\n${lines.join("\n")}\n}`;
|
|
154
178
|
}
|
package/src/tooltip.tsx
CHANGED
|
@@ -156,7 +156,7 @@ export function TooltipProvider({ children }: { children: React.ReactNode }) {
|
|
|
156
156
|
}),
|
|
157
157
|
}}
|
|
158
158
|
>
|
|
159
|
-
<Text numberOfLines={1} size="sm" color="
|
|
159
|
+
<Text numberOfLines={1} size="sm" color="onInverse">
|
|
160
160
|
{tooltip.text}
|
|
161
161
|
</Text>
|
|
162
162
|
</div>,
|
|
@@ -172,7 +172,7 @@ function OverlayStack(props: { tone?: "error"; compact: boolean; label: string;
|
|
|
172
172
|
|
|
173
173
|
function OverlayLabel({ children }: { children: string }) {
|
|
174
174
|
return (
|
|
175
|
-
<Text size="xs" weight="medium" color="
|
|
175
|
+
<Text size="xs" weight="medium" color="onInverse" numberOfLines={2} style={styles.overlayLabel} userSelect="none">
|
|
176
176
|
{children}
|
|
177
177
|
</Text>
|
|
178
178
|
);
|