@lotics/ui 42.4.0 → 43.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/MIGRATION.md +74 -0
  2. package/docs/catalog.md +38 -6
  3. package/docs/composition.md +45 -3
  4. package/docs/data_entry.md +9 -4
  5. package/package.json +12 -2
  6. package/src/back_button.tsx +4 -1
  7. package/src/badge.tsx +10 -2
  8. package/src/button.tsx +69 -8
  9. package/src/check_circle.tsx +1 -2
  10. package/src/checkbox_input.tsx +2 -2
  11. package/src/choice_list.tsx +2 -2
  12. package/src/color_tokens.ts +27 -3
  13. package/src/colors.web.ts +4 -2
  14. package/src/comments_button.tsx +3 -1
  15. package/src/control_surface.ts +27 -0
  16. package/src/data_grid.tsx +1 -1
  17. package/src/date_calendar.tsx +210 -58
  18. package/src/date_filter.tsx +1 -5
  19. package/src/date_picker.tsx +2 -0
  20. package/src/date_range_selection.ts +18 -0
  21. package/src/date_segments.ts +15 -1
  22. package/src/display_font.ts +27 -0
  23. package/src/display_font.web.ts +31 -0
  24. package/src/file_dropzone.tsx +2 -1
  25. package/src/file_row.tsx +2 -2
  26. package/src/file_thumbnail.tsx +4 -1
  27. package/src/filter_chip.tsx +12 -2
  28. package/src/focus_ring_pressable.tsx +6 -1
  29. package/src/font_family.ts +26 -0
  30. package/src/font_family.web.ts +29 -0
  31. package/src/icon_button.tsx +3 -1
  32. package/src/image_gallery.tsx +1 -1
  33. package/src/index.css +0 -2
  34. package/src/inline_button.tsx +3 -1
  35. package/src/inline_time_picker.tsx +97 -48
  36. package/src/list_item.tsx +109 -11
  37. package/src/locale.tsx +2 -2
  38. package/src/menu_button.tsx +19 -0
  39. package/src/option_list.tsx +8 -0
  40. package/src/pressable_highlight.tsx +20 -8
  41. package/src/pressable_row.tsx +7 -3
  42. package/src/scroll_to_bottom.tsx +3 -0
  43. package/src/slider.tsx +2 -2
  44. package/src/summary.tsx +28 -4
  45. package/src/switch.tsx +3 -1
  46. package/src/table.tsx +46 -4
  47. package/src/text.tsx +32 -11
  48. package/src/text_utils.ts +3 -11
  49. package/src/theme.web.tsx +16 -1
  50. package/src/theme_context.ts +63 -1
  51. package/src/time_columns.tsx +225 -0
  52. package/src/time_options.ts +138 -0
  53. package/src/time_picker.tsx +102 -64
  54. package/src/use_option_list.ts +19 -0
package/src/data_grid.tsx CHANGED
@@ -185,7 +185,7 @@ const styles = StyleSheet.create({
185
185
  // Padded for the hover pill but zero-width in flow (negative margin), so the label
186
186
  // sits on its cell's edge instead of 8px inside it.
187
187
  sortLabel: { flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 6, marginHorizontal: -6, paddingVertical: 2, borderRadius: 6 },
188
- sortLabelPressable: { cursor: "pointer" },
188
+ sortLabelPressable: { cursor: "auto" },
189
189
  sortLabelHover: { backgroundColor: colors.zinc[50] },
190
190
  groupSep: { borderTopWidth: 1, borderTopColor: colors.zinc[100], marginTop: 8, paddingTop: 8 },
191
191
  section: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 8, marginHorizontal: -8, paddingVertical: 7, borderRadius: 8 },
@@ -5,10 +5,10 @@ import { colors } from "./colors";
5
5
  import { useScreenSize } from "./use_screen_size";
6
6
  import { Picker, PickerOption } from "./picker";
7
7
  import { IconButton } from "./icon_button";
8
- import { FOCUS_RING } from "./control_surface";
8
+ import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
9
9
  import { useFocusRing } from "./use_focus_ring";
10
10
  import { useLoticsLocale, useLocaleTag } from "./locale";
11
- import { nextRangeSelection } from "./date_range_selection";
11
+ import { nextRangeSelection, previewEndFor } from "./date_range_selection";
12
12
 
13
13
  /** Accessible names for the calendar's month-navigation arrows. Backs the
14
14
  * `calendar` locale slice. (The month/weekday NAMES come from `Intl` + the
@@ -18,13 +18,29 @@ export interface CalendarLabels {
18
18
  nextMonth: string;
19
19
  }
20
20
 
21
+ /**
22
+ * One day: a full-width SLOT that carries the range bar, and a circle inside it
23
+ * that carries selection.
24
+ *
25
+ * Two layers because the two states have different shapes. A range is a
26
+ * CONTINUOUS band across the days it covers — cells drawn as separate rounded
27
+ * pills read as a row of unrelated chips, not as one span — while a selected day
28
+ * is a disc. So the slot paints the band edge to edge and rounds only where the
29
+ * span actually ends (`capStart` / `capEnd`, which the row computes from its
30
+ * neighbours, so a span wrapping to the next week caps at the row edge and
31
+ * resumes on the line below).
32
+ */
21
33
  function DayCell(props: {
22
34
  day: number;
23
35
  label: string;
24
36
  isActive: boolean;
25
- inRange: boolean;
37
+ inSpan: boolean;
38
+ capStart: boolean;
39
+ capEnd: boolean;
26
40
  isToday: boolean;
27
41
  onPress: () => void;
42
+ onHoverIn?: () => void;
43
+ onHoverOut?: () => void;
28
44
  }) {
29
45
  const { focusVisible, focusProps } = useFocusRing();
30
46
  return (
@@ -35,24 +51,38 @@ function DayCell(props: {
35
51
  // build, and `selected` has no meaning on a `button` role regardless.
36
52
  aria-pressed={props.isActive}
37
53
  {...focusProps}
38
- style={({ hovered }) => [
39
- styles.dayCell,
40
- props.inRange && styles.dayCellInRange,
41
- props.isActive && styles.dayCellSelected,
42
- hovered && !props.isActive && styles.dayCellHovered,
43
- focusVisible && { boxShadow: FOCUS_RING },
54
+ style={[
55
+ styles.daySlot,
56
+ props.inSpan && styles.spanFill,
57
+ props.inSpan && props.capStart && styles.spanCapStart,
58
+ props.inSpan && props.capEnd && styles.spanCapEnd,
44
59
  ]}
45
60
  onPress={props.onPress}
61
+ onHoverIn={props.onHoverIn}
62
+ onHoverOut={props.onHoverOut}
46
63
  >
47
- <Text
48
- size="sm"
49
- color={props.isActive ? "inverted" : "default"}
50
- userSelect="none"
51
- weight={props.isToday ? "semibold" : "regular"}
52
- >
53
- {props.day}
54
- </Text>
55
- {props.isToday && <View style={[styles.todayDot, props.isActive && styles.todayDotInverted]} />}
64
+ {({ hovered }: { hovered?: boolean }) => (
65
+ <View
66
+ style={[
67
+ styles.dayCircle,
68
+ props.isActive && styles.dayCircleSelected,
69
+ // A day already in the span keeps the band rather than fading under
70
+ // the pointer — the wash is lighter than the fill it would replace.
71
+ hovered && !props.isActive && !props.inSpan && styles.dayCircleHovered,
72
+ focusVisible && { boxShadow: FOCUS_RING },
73
+ ]}
74
+ >
75
+ <Text
76
+ size="sm"
77
+ color={props.isActive ? "inverted" : "default"}
78
+ userSelect="none"
79
+ weight={props.isToday ? "semibold" : "regular"}
80
+ >
81
+ {props.day}
82
+ </Text>
83
+ {props.isToday && <View style={[styles.todayDot, props.isActive && styles.todayDotInverted]} />}
84
+ </View>
85
+ )}
56
86
  </Pressable>
57
87
  );
58
88
  }
@@ -112,8 +142,18 @@ interface CalendarMonthProps {
112
142
  selectedDate: Date | null;
113
143
  rangeStart: Date | null;
114
144
  rangeEnd: Date | null;
145
+ /**
146
+ * The end a range WOULD have if the pointer stopped here — set while a range is
147
+ * open (a start, no end) and the pointer is over a day. It fills the span like
148
+ * a committed range without marking either end selected, so the feedback says
149
+ * "this is what you are about to pick", not "this is picked".
150
+ */
151
+ previewEnd?: Date | null;
115
152
  isRange: boolean;
116
153
  onDateSelect: (date: Date) => void;
154
+ /** The pointer entered (`true`) or left (`false`) a day. The OWNER decides what
155
+ * that means — the leave of a day you already moved off is not a clear. */
156
+ onDayPointer?: (date: Date, entering: boolean) => void;
117
157
  onMonthChange: (year: number, month: number) => void;
118
158
  showNavigation?: boolean;
119
159
  /** Show only left arrow (for range mode left calendar) */
@@ -212,6 +252,12 @@ function isSameDay(date1: Date | null, date2: Date | null): boolean {
212
252
  );
213
253
  }
214
254
 
255
+ /** A day's disc — one size in every state, so nothing grows under the pointer. */
256
+ const DAY_DISC = 36;
257
+ /** The narrowest a day's slot may be: the disc plus the air that keeps two
258
+ * neighbouring discs apart. Seven of these is the month's minimum width. */
259
+ const DAY_SLOT_MIN = DAY_DISC + 4;
260
+
215
261
  function isInRange(date: Date, start: Date | null, end: Date | null): boolean {
216
262
  if (!start || !end) return false;
217
263
  const time = date.getTime();
@@ -220,6 +266,18 @@ function isInRange(date: Date, start: Date | null, end: Date | null): boolean {
220
266
  return time > Math.min(startTime, endTime) && time < Math.max(startTime, endTime);
221
267
  }
222
268
 
269
+ /**
270
+ * Between `start` and `end` INCLUSIVE, in either order — the span a range covers
271
+ * on screen.
272
+ *
273
+ * A single day is not a span: with both bounds on the same date the band would be
274
+ * a capsule drawn behind one disc, which says nothing the disc does not.
275
+ */
276
+ function inClosedRange(date: Date, start: Date | null, end: Date | null | undefined): boolean {
277
+ if (!start || !end || isSameDay(start, end)) return false;
278
+ return isInRange(date, start, end) || isSameDay(date, start) || isSameDay(date, end);
279
+ }
280
+
223
281
  function isToday(date: Date): boolean {
224
282
  const today = new Date();
225
283
  return isSameDay(date, today);
@@ -236,8 +294,10 @@ function CalendarMonth(props: CalendarMonthProps) {
236
294
  selectedDate,
237
295
  rangeStart,
238
296
  rangeEnd,
297
+ previewEnd,
239
298
  isRange,
240
299
  onDateSelect,
300
+ onDayPointer,
241
301
  onMonthChange,
242
302
  showNavigation = true,
243
303
  showLeftArrow = false,
@@ -384,35 +444,64 @@ function CalendarMonth(props: CalendarMonthProps) {
384
444
  </View>
385
445
 
386
446
  {/* Calendar grid */}
387
- {weeks.map((week, weekIndex) => (
388
- <View key={weekIndex} style={styles.weekRow}>
389
- {week.map((day, dayIndex) => {
390
- if (day === null) {
391
- return <View key={dayIndex} style={styles.dayCell} />;
392
- }
393
-
394
- const date = new Date(year, month, day);
395
- const isSelected = isSameDay(date, selectedDate);
396
- const isRangeStart = isRange && isSameDay(date, rangeStart);
397
- const isRangeEnd = isRange && isSameDay(date, rangeEnd);
398
- const inRange = isRange && isInRange(date, rangeStart, rangeEnd);
399
- const isTodayDate = isToday(date);
400
- const isActive = isSelected || isRangeStart || isRangeEnd;
401
-
402
- return (
403
- <DayCell
404
- key={dayIndex}
405
- day={day}
406
- label={names.formatDayLabel(date)}
407
- isActive={isActive}
408
- inRange={inRange}
409
- isToday={isTodayDate}
410
- onPress={() => onDateSelect(date)}
411
- />
412
- );
413
- })}
414
- </View>
415
- ))}
447
+ {weeks.map((week, weekIndex) => {
448
+ // Whether each day of THIS row sits in the span — computed for the row
449
+ // before any cell renders, because a cell's rounded caps depend on what
450
+ // its neighbours are doing, not on itself.
451
+ //
452
+ // CLOSED at both ends: the band runs under the endpoint discs rather than
453
+ // starting after one and stopping before the other, which read as a
454
+ // selected day sitting outside the very span it bounds. (`isInRange` is
455
+ // strictly between, because it was written for a fill that the dark
456
+ // bounds capped.) It sorts its arguments, so hovering BACKWARDS from the
457
+ // start previews exactly as well as forwards.
458
+ const inSpans = week.map((day) => {
459
+ if (day === null || !isRange) return false;
460
+ const date = new Date(year, month, day);
461
+ return (
462
+ inClosedRange(date, rangeStart, rangeEnd) ||
463
+ inClosedRange(date, rangeStart, previewEnd)
464
+ );
465
+ });
466
+
467
+ return (
468
+ <View key={weekIndex} style={styles.weekRow}>
469
+ {week.map((day, dayIndex) => {
470
+ if (day === null) {
471
+ return <View key={dayIndex} style={styles.daySlot} />;
472
+ }
473
+
474
+ const date = new Date(year, month, day);
475
+ const isSelected = isSameDay(date, selectedDate);
476
+ const isRangeStart = isRange && isSameDay(date, rangeStart);
477
+ const isRangeEnd = isRange && isSameDay(date, rangeEnd);
478
+ const isTodayDate = isToday(date);
479
+ // The day under the pointer wears the SELECTED disc, because the
480
+ // preview answers "what do I get if I click here" and what you get
481
+ // is a selected end. A third, in-between shade would be a state the
482
+ // system does not otherwise have.
483
+ const isPreviewEnd = previewEnd != null && isSameDay(date, previewEnd);
484
+ const isActive = isSelected || isRangeStart || isRangeEnd || isPreviewEnd;
485
+
486
+ return (
487
+ <DayCell
488
+ key={dayIndex}
489
+ day={day}
490
+ label={names.formatDayLabel(date)}
491
+ isActive={isActive}
492
+ inSpan={inSpans[dayIndex]}
493
+ capStart={!inSpans[dayIndex - 1]}
494
+ capEnd={!inSpans[dayIndex + 1]}
495
+ isToday={isTodayDate}
496
+ onPress={() => onDateSelect(date)}
497
+ onHoverIn={onDayPointer ? () => onDayPointer(date, true) : undefined}
498
+ onHoverOut={onDayPointer ? () => onDayPointer(date, false) : undefined}
499
+ />
500
+ );
501
+ })}
502
+ </View>
503
+ );
504
+ })}
416
505
  </View>
417
506
  );
418
507
  }
@@ -542,6 +631,27 @@ function RangeCalendar(props: RangeCalendarInternalProps) {
542
631
  [value, onValueChange],
543
632
  );
544
633
 
634
+ // The day under the pointer, so an OPEN range can show the span it would close
635
+ // on. Held here rather than per month, because the two calendars are one grid
636
+ // to a member dragging across a month boundary.
637
+ const [hoveredDate, setHoveredDate] = useState<Date | null>(null);
638
+
639
+ const handleDayPointer = useCallback((date: Date, entering: boolean) => {
640
+ if (entering) {
641
+ setHoveredDate(date);
642
+ return;
643
+ }
644
+ // Moving between cells fires the old cell's LEAVE and the new cell's ENTER in
645
+ // no guaranteed order, so an unconditional clear would blank the preview on
646
+ // every step across the grid. Clear only while this day is still the one
647
+ // being reported — which also means leaving the grid entirely does clear it.
648
+ setHoveredDate((current) => (current && isSameDay(current, date) ? null : current));
649
+ }, []);
650
+
651
+ // Shares its open-ness test with `nextRangeSelection`, so the span shown can
652
+ // never disagree with the one the next click produces.
653
+ const previewEnd = previewEndFor(value, hoveredDate);
654
+
545
655
  const handleMonthChange = useCallback((year: number, month: number) => {
546
656
  setLeftYear(year);
547
657
  setLeftMonth(month);
@@ -577,8 +687,10 @@ function RangeCalendar(props: RangeCalendarInternalProps) {
577
687
  selectedDate={null}
578
688
  rangeStart={value.start}
579
689
  rangeEnd={value.end}
690
+ previewEnd={previewEnd}
580
691
  isRange={true}
581
692
  onDateSelect={handleDateSelect}
693
+ onDayPointer={handleDayPointer}
582
694
  onMonthChange={handleMonthChange}
583
695
  showNavigation={true}
584
696
  firstDayOfWeek={firstDayOfWeek}
@@ -597,8 +709,10 @@ function RangeCalendar(props: RangeCalendarInternalProps) {
597
709
  selectedDate={null}
598
710
  rangeStart={value.start}
599
711
  rangeEnd={value.end}
712
+ previewEnd={previewEnd}
600
713
  isRange={true}
601
714
  onDateSelect={handleDateSelect}
715
+ onDayPointer={handleDayPointer}
602
716
  onMonthChange={handleMonthChange}
603
717
  showNavigation={false}
604
718
  showLeftArrow={true}
@@ -612,8 +726,10 @@ function RangeCalendar(props: RangeCalendarInternalProps) {
612
726
  selectedDate={null}
613
727
  rangeStart={value.start}
614
728
  rangeEnd={value.end}
729
+ previewEnd={previewEnd}
615
730
  isRange={true}
616
731
  onDateSelect={handleDateSelect}
732
+ onDayPointer={handleDayPointer}
617
733
  onMonthChange={handleMonthChange}
618
734
  showNavigation={false}
619
735
  showRightArrow={true}
@@ -653,36 +769,72 @@ const styles = StyleSheet.create({
653
769
  },
654
770
  weekHeader: {
655
771
  flexDirection: "row",
656
- justifyContent: "space-around",
657
772
  borderBottomWidth: 1,
658
773
  borderBottomColor: colors.zinc["200"],
659
774
  paddingBottom: 8,
660
775
  marginBottom: 4,
661
776
  },
662
777
  dayHeaderCell: {
663
- width: 36,
778
+ // Shares the day slots' sizing so the column heads stay over their columns.
779
+ flex: 1,
780
+ minWidth: DAY_SLOT_MIN,
664
781
  alignItems: "center",
665
782
  justifyContent: "center",
666
783
  height: 32,
667
784
  },
668
785
  weekRow: {
669
786
  flexDirection: "row",
670
- justifyContent: "space-around",
787
+ // Rows are separated so one week's band cannot touch the next one's. Without
788
+ // it the bands meet edge to edge down the whole grid and a range that wraps
789
+ // reads as a single block rather than as a span that continues on the line
790
+ // below.
791
+ marginBottom: 2,
671
792
  },
672
- dayCell: {
673
- width: 36,
674
- height: 36,
793
+ // The band's carrier, and the reason the grid FILLS its month rather than
794
+ // leaving slack at the right edge: seven equal shares of whatever width the
795
+ // month has, floored so the disc inside always has room to breathe.
796
+ //
797
+ // Cells sit EDGE TO EDGE — spaced apart, a range would paint as separate pills
798
+ // with gaps between them rather than as one span.
799
+ daySlot: {
800
+ cursor: CURSOR_DEFAULT,
801
+ flex: 1,
802
+ minWidth: DAY_SLOT_MIN,
803
+ height: DAY_DISC,
804
+ alignItems: "center",
805
+ justifyContent: "center",
806
+ },
807
+ spanFill: {
808
+ backgroundColor: colors.zinc["100"],
809
+ },
810
+ // Rounded only where the span actually ends — including at a row's edge, so a
811
+ // span that wraps closes on one line and reopens on the next.
812
+ spanCapStart: {
813
+ borderTopLeftRadius: 999,
814
+ borderBottomLeftRadius: 999,
815
+ },
816
+ spanCapEnd: {
817
+ borderTopRightRadius: 999,
818
+ borderBottomRightRadius: 999,
819
+ },
820
+ // The SAME size in every state, and narrower than the slot that holds it.
821
+ //
822
+ // The slots are edge to edge — that is what makes the band continuous — so a
823
+ // disc as wide as its slot would touch the disc beside it, which is what had
824
+ // the hovered day and the selected one meeting as a single lozenge. The room
825
+ // comes from the SLOT being wider, not from shrinking the disc, so the days
826
+ // stay the size they were and spread further apart as the month gets wider.
827
+ dayCircle: {
828
+ width: DAY_DISC,
829
+ height: DAY_DISC,
675
830
  alignItems: "center",
676
831
  justifyContent: "center",
677
832
  borderRadius: 999,
678
833
  },
679
- dayCellSelected: {
834
+ dayCircleSelected: {
680
835
  backgroundColor: colors.zinc["800"],
681
836
  },
682
- dayCellInRange: {
683
- backgroundColor: colors.zinc["100"],
684
- },
685
- dayCellHovered: {
837
+ dayCircleHovered: {
686
838
  backgroundColor: colors.zinc["50"],
687
839
  },
688
840
  todayDot: {
@@ -91,10 +91,9 @@ function RangeBound(props: {
91
91
  bound: { date: Date | null; time: string | null };
92
92
  includeTime: boolean;
93
93
  localeTag: string;
94
- segmentLabels: SegmentLabels;
95
94
  onTimeChange: (time: string) => void;
96
95
  }) {
97
- const { label, placeholder, bound, includeTime, localeTag, segmentLabels, onTimeChange } = props;
96
+ const { label, placeholder, bound, includeTime, localeTag, onTimeChange } = props;
98
97
  return (
99
98
  <View style={styles.rangeItem}>
100
99
  <Text size="xs" color="zinc-500">
@@ -111,7 +110,6 @@ function RangeBound(props: {
111
110
  disabled={!bound.date}
112
111
  accessibilityLabel={label}
113
112
  locale={localeTag}
114
- segmentLabels={segmentLabels}
115
113
  />
116
114
  </View>
117
115
  )}
@@ -300,7 +298,6 @@ export function DateFilter(props: DateFilterProps) {
300
298
  bound={shown.start}
301
299
  includeTime={includeTime}
302
300
  localeTag={localeTag}
303
- segmentLabels={labels}
304
301
  onTimeChange={handleStartTimeChange}
305
302
  />
306
303
  <RangeBound
@@ -309,7 +306,6 @@ export function DateFilter(props: DateFilterProps) {
309
306
  bound={shown.end}
310
307
  includeTime={includeTime}
311
308
  localeTag={localeTag}
312
- segmentLabels={labels}
313
309
  onTimeChange={handleEndTimeChange}
314
310
  />
315
311
  </View>
@@ -42,6 +42,8 @@ export interface DatePickerLabels extends SegmentLabels {
42
42
  clear: string;
43
43
  /** Accessible name for the calendar button. */
44
44
  openCalendar: string;
45
+ /** Accessible name for the clock button that opens the time list. */
46
+ chooseTime: string;
45
47
  /** Accessible name for the time field (single datetime). */
46
48
  time: string;
47
49
  /** Accessible name for the start-time field (datetime range). */
@@ -30,3 +30,21 @@ export function nextRangeSelection(
30
30
  ? { start: date, end: current.start }
31
31
  : { start: current.start, end: date };
32
32
  }
33
+
34
+ /**
35
+ * The end a range would CLOSE on if the pointer stopped at `hovered` — what a
36
+ * calendar fills in to show the span before it is committed.
37
+ *
38
+ * `null` unless a range is actually open, and open is tested exactly the way
39
+ * {@link nextRangeSelection} tests it. That shared test is the point of putting
40
+ * this here: a preview that disagreed with the click would show a span the next
41
+ * click does not produce, and the two rules drifting apart is precisely what a
42
+ * second copy of "is a range open" would eventually do.
43
+ */
44
+ export function previewEndFor(
45
+ current: DateRangeSelection,
46
+ hovered: Date | null,
47
+ ): Date | null {
48
+ if (!current.start || current.end) return null;
49
+ return hovered;
50
+ }
@@ -57,6 +57,20 @@ export function emptyBuffer(): SegmentBuffer {
57
57
  // Distinct sample components (2023 / 01 / 02 / 13:45) so part mapping is reliable.
58
58
  const SAMPLE = new Date(2023, 0, 2, 13, 45, 0);
59
59
 
60
+ /**
61
+ * The Intl options a TIME reads under — 12-vs-24-hour follows from the locale's
62
+ * own resolution of these, never from a flag we set.
63
+ *
64
+ * Exported because a time is rendered in two places that must agree: these
65
+ * segments, and the picker list that fills them (`time_options`). Two literals
66
+ * would drift the first time either changed, and the mismatch would show as one
67
+ * control saying 01:45 PM while the other says 13:45.
68
+ */
69
+ export const TIME_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
70
+ hour: "2-digit",
71
+ minute: "2-digit",
72
+ };
73
+
60
74
  const FIELD_TYPE: Record<string, SegmentType | undefined> = {
61
75
  year: "year",
62
76
  month: "month",
@@ -123,7 +137,7 @@ export function getTimeLayout(locale: string): DateLayout {
123
137
  const cached = layoutCache.get(key);
124
138
  if (cached) return cached;
125
139
 
126
- const dtf = new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" });
140
+ const dtf = new Intl.DateTimeFormat(locale, TIME_FORMAT_OPTIONS);
127
141
  const hour12 = dtf.resolvedOptions().hour12 ?? false;
128
142
  const layout: DateLayout = {
129
143
  segments: partsToSegments(dtf.formatToParts(SAMPLE), hour12),
@@ -0,0 +1,27 @@
1
+ /**
2
+ * THE DISPLAY FACE — the family `<Text family="display">` renders in.
3
+ *
4
+ * A themeable typeface exists for the DISPLAY role and deliberately not for the
5
+ * body, and the asymmetry is the whole design. Swapping the face a headline
6
+ * wears is one value with no dependencies: it appears a handful of times, large,
7
+ * in one weight. Swapping the BODY face is four coupled things —
8
+ *
9
+ * 1. weight is a FAMILY here, not a `font-weight` (`Inter_400Regular` /
10
+ * `Inter_500Medium` / `Inter_600SemiBold`), so one variable cannot say it;
11
+ * 2. `text.css` hand-tunes letter-spacing per size FOR Inter's glyphs and for
12
+ * Vietnamese diacritics at 12px;
13
+ * 3. `font-feature-settings: "cv11","ss01","ss03"` are INTER's stylistic
14
+ * alternates and mean nothing — or something else — on another face;
15
+ * 4. the files are self-hosted, so a family name nobody `@font-face`d falls
16
+ * silently through the stack to system sans.
17
+ *
18
+ * — which is why there is no `bodyFont` role. A variable for it would not fail,
19
+ * it would render every screen in the product subtly miscalibrated, globally and
20
+ * invisibly to typecheck. Changing the body face is a KIT change: add the
21
+ * @font-face trio, re-tune the curve, re-check the features. Rare, deliberate,
22
+ * and done once for everybody.
23
+ */
24
+ import { fontFamilySemiBold } from "./font_family";
25
+
26
+ /** Native: a literal, because `var()` means nothing off the web. */
27
+ export const fontFamilyDisplay = fontFamilySemiBold;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * THE DISPLAY FACE — the family `<Text family="display">` renders in.
3
+ *
4
+ * A themeable typeface exists for the DISPLAY role and deliberately not for the
5
+ * body, and the asymmetry is the whole design. Swapping the face a headline
6
+ * wears is one value with no dependencies: it appears a handful of times, large,
7
+ * in one weight. Swapping the BODY face is four coupled things —
8
+ *
9
+ * 1. weight is a FAMILY here, not a `font-weight` (`Inter_400Regular` /
10
+ * `Inter_500Medium` / `Inter_600SemiBold`), so one variable cannot say it;
11
+ * 2. `text.css` hand-tunes letter-spacing per size FOR Inter's glyphs and for
12
+ * Vietnamese diacritics at 12px;
13
+ * 3. `font-feature-settings: "cv11","ss01","ss03"` are INTER's stylistic
14
+ * alternates and mean nothing — or something else — on another face;
15
+ * 4. the files are self-hosted, so a family name nobody `@font-face`d falls
16
+ * silently through the stack to system sans.
17
+ *
18
+ * — which is why there is no `bodyFont` role. A variable for it would not fail,
19
+ * it would render every screen in the product subtly miscalibrated, globally and
20
+ * invisibly to typecheck. Changing the body face is a KIT change: add the
21
+ * @font-face trio, re-tune the curve, re-check the features. Rare, deliberate,
22
+ * and done once for everybody.
23
+ */
24
+ import { fontFamilySemiBold } from "./font_family";
25
+
26
+ /**
27
+ * The default IS the kit's own semibold sans, so `family="display"` changes
28
+ * nothing until an app sets `displayFont` — the same opt-in contract the colour
29
+ * roles keep.
30
+ */
31
+ export const fontFamilyDisplay = `var(--lotics-display-font, ${fontFamilySemiBold})`;
@@ -5,7 +5,7 @@ import { Icon } from "./icon";
5
5
  import { Text } from "./text";
6
6
  import { pickFiles } from "./file_picker";
7
7
  import { FileDropTarget } from "./file_drop_target";
8
- import { FOCUS_RING } from "./control_surface";
8
+ import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
9
9
  import { useFocusRing } from "./use_focus_ring";
10
10
  import { useLoticsLocale } from "./locale";
11
11
 
@@ -88,6 +88,7 @@ export function FileDropzone(props: FileDropzoneProps) {
88
88
  onHoverIn={() => setHovered(true)}
89
89
  onHoverOut={() => setHovered(false)}
90
90
  style={[
91
+ { cursor: CURSOR_DEFAULT },
91
92
  styles.zone,
92
93
  { minHeight: height },
93
94
  hovered && !dragging ? styles.zoneHovered : null,
package/src/file_row.tsx CHANGED
@@ -3,7 +3,7 @@ import { Pressable, StyleSheet, View } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { colors } from "./colors";
5
5
  import { FileBadge } from "./file_badge";
6
- import { FOCUS_RING } from "./control_surface";
6
+ import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
7
7
  import { useFocusRing } from "./use_focus_ring";
8
8
  import { useLoticsLocale } from "./locale";
9
9
 
@@ -192,6 +192,6 @@ const styles = StyleSheet.create({
192
192
  rowHovered: { backgroundColor: colors.zinc["50"] },
193
193
  rowPressed: { backgroundColor: colors.zinc["100"] },
194
194
  // The accessible "Open" door — fills the row left of the trailing sibling.
195
- door: { flex: 1, flexDirection: "row", alignItems: "center", gap: 12 },
195
+ door: { flex: 1, flexDirection: "row", alignItems: "center", gap: 12, cursor: CURSOR_DEFAULT },
196
196
  text: { flex: 1, gap: 1 },
197
197
  });
@@ -7,7 +7,7 @@ import { IconButton } from "./icon_button";
7
7
  import { Checkbox } from "./checkbox";
8
8
  import { ActivityIndicator } from "./activity_indicator";
9
9
  import { colors } from "./colors";
10
- import { FOCUS_RING } from "./control_surface";
10
+ import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
11
11
  import { useFocusRing } from "./use_focus_ring";
12
12
  import { useLoticsLocale } from "./locale";
13
13
  import { useCallback, useState } from "react";
@@ -540,6 +540,7 @@ const styles = StyleSheet.create({
540
540
  // Fills the tile so the fade covers the whole body and nothing reflows.
541
541
  fadedBody: { width: "100%", height: "100%", opacity: 0.45 },
542
542
  documentCard: {
543
+ cursor: CURSOR_DEFAULT,
543
544
  borderRadius: 10,
544
545
  backgroundColor: colors.white,
545
546
  borderWidth: 1,
@@ -594,6 +595,7 @@ const styles = StyleSheet.create({
594
595
  alignItems: "center",
595
596
  },
596
597
  mediaCardCompact: {
598
+ cursor: CURSOR_DEFAULT,
597
599
  borderRadius: 7,
598
600
  alignItems: "center",
599
601
  justifyContent: "center",
@@ -626,6 +628,7 @@ const styles = StyleSheet.create({
626
628
  letterSpacing: 0.3,
627
629
  },
628
630
  imageThumbnail: {
631
+ cursor: CURSOR_DEFAULT,
629
632
  borderRadius: 10,
630
633
  overflow: "hidden",
631
634
  },
@@ -113,7 +113,10 @@ export function FilterChip(props: FilterChipProps) {
113
113
  <Chip onDismiss={showClear ? onClear : undefined} dismissTooltip={clearLabel}>
114
114
  {active && typeof summary !== "string" ? (
115
115
  <View style={styles.summaryRow}>
116
- <Text userSelect="none" size="sm" weight="medium" color="zinc-700">{`${label}:`}</Text>
116
+ {/* This branch only renders when ACTIVE, so it takes the accent
117
+ unconditionally — the two branches are one state seen twice, and
118
+ they were drifting the moment only one of them was themed. */}
119
+ <Text userSelect="none" size="sm" weight="medium" style={{ color: colors.accent }}>{`${label}:`}</Text>
117
120
  {summary}
118
121
  </View>
119
122
  ) : (
@@ -121,7 +124,14 @@ export function FilterChip(props: FilterChipProps) {
121
124
  userSelect="none"
122
125
  size="sm"
123
126
  weight="medium"
124
- color={active ? "zinc-900" : "zinc-700"}
127
+ // An APPLIED filter wears the accent; an empty one stays neutral.
128
+ // The pill already changed its words and grew a ×, but both are
129
+ // read only after you look AT it — the whole job of a filter bar is
130
+ // to answer "is anything on?" from across the screen, and ink is the
131
+ // only channel that carries at that distance. It is a state, not
132
+ // decoration, which is what makes it a legitimate place for brand.
133
+ color={active ? undefined : "zinc-700"}
134
+ style={active ? { color: colors.accent } : undefined}
125
135
  numberOfLines={1}
126
136
  >
127
137
  {active ? `${label}: ${summary}` : label}