@lockin90/ui-native 1.0.1 → 2.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 (155) hide show
  1. package/lib/commonjs/components/AuroraBackground.js +63 -35
  2. package/lib/commonjs/components/AuroraBackground.js.map +1 -1
  3. package/lib/commonjs/components/Avatar.js +2 -1
  4. package/lib/commonjs/components/Avatar.js.map +1 -1
  5. package/lib/commonjs/components/BrandMark.js +7 -2
  6. package/lib/commonjs/components/BrandMark.js.map +1 -1
  7. package/lib/commonjs/components/FocalPointPicker.js +2 -1
  8. package/lib/commonjs/components/FocalPointPicker.js.map +1 -1
  9. package/lib/commonjs/components/Gauge.js +2 -1
  10. package/lib/commonjs/components/Gauge.js.map +1 -1
  11. package/lib/commonjs/components/Icon.js +1 -0
  12. package/lib/commonjs/components/Icon.js.map +1 -1
  13. package/lib/commonjs/components/Menu.js +2 -1
  14. package/lib/commonjs/components/Menu.js.map +1 -1
  15. package/lib/commonjs/components/Modal.js +2 -1
  16. package/lib/commonjs/components/Modal.js.map +1 -1
  17. package/lib/commonjs/components/PasswordInput.js +3 -3
  18. package/lib/commonjs/components/PasswordInput.js.map +1 -1
  19. package/lib/commonjs/components/PhaseWheel.js +2 -1
  20. package/lib/commonjs/components/PhaseWheel.js.map +1 -1
  21. package/lib/commonjs/components/PkChart.js +1 -1
  22. package/lib/commonjs/components/PkChart.js.map +1 -1
  23. package/lib/commonjs/components/Popover.js +2 -1
  24. package/lib/commonjs/components/Popover.js.map +1 -1
  25. package/lib/commonjs/components/ProGate.js +2 -1
  26. package/lib/commonjs/components/ProGate.js.map +1 -1
  27. package/lib/commonjs/components/RestTimer.js +2 -1
  28. package/lib/commonjs/components/RestTimer.js.map +1 -1
  29. package/lib/commonjs/components/Ring.js +2 -1
  30. package/lib/commonjs/components/Ring.js.map +1 -1
  31. package/lib/commonjs/components/SegmentedTabs.js +2 -1
  32. package/lib/commonjs/components/SegmentedTabs.js.map +1 -1
  33. package/lib/commonjs/components/Select.js +7 -1
  34. package/lib/commonjs/components/Select.js.map +1 -1
  35. package/lib/commonjs/components/ShareCard.js +2 -1
  36. package/lib/commonjs/components/ShareCard.js.map +1 -1
  37. package/lib/commonjs/components/Sheet.js +2 -1
  38. package/lib/commonjs/components/Sheet.js.map +1 -1
  39. package/lib/commonjs/components/TabBar.js +2 -1
  40. package/lib/commonjs/components/TabBar.js.map +1 -1
  41. package/lib/commonjs/components/TagInput.js +86 -42
  42. package/lib/commonjs/components/TagInput.js.map +1 -1
  43. package/lib/commonjs/components/Toggle.js +2 -1
  44. package/lib/commonjs/components/Toggle.js.map +1 -1
  45. package/lib/commonjs/components/Tooltip.js +2 -1
  46. package/lib/commonjs/components/Tooltip.js.map +1 -1
  47. package/lib/commonjs/internal/absoluteFill.js +18 -0
  48. package/lib/commonjs/internal/absoluteFill.js.map +1 -0
  49. package/lib/module/components/AuroraBackground.js +64 -36
  50. package/lib/module/components/AuroraBackground.js.map +1 -1
  51. package/lib/module/components/Avatar.js +2 -1
  52. package/lib/module/components/Avatar.js.map +1 -1
  53. package/lib/module/components/BrandMark.js +7 -2
  54. package/lib/module/components/BrandMark.js.map +1 -1
  55. package/lib/module/components/FocalPointPicker.js +2 -1
  56. package/lib/module/components/FocalPointPicker.js.map +1 -1
  57. package/lib/module/components/Gauge.js +2 -1
  58. package/lib/module/components/Gauge.js.map +1 -1
  59. package/lib/module/components/Icon.js +2 -1
  60. package/lib/module/components/Icon.js.map +1 -1
  61. package/lib/module/components/Menu.js +2 -1
  62. package/lib/module/components/Menu.js.map +1 -1
  63. package/lib/module/components/Modal.js +2 -1
  64. package/lib/module/components/Modal.js.map +1 -1
  65. package/lib/module/components/PasswordInput.js +3 -3
  66. package/lib/module/components/PasswordInput.js.map +1 -1
  67. package/lib/module/components/PhaseWheel.js +2 -1
  68. package/lib/module/components/PhaseWheel.js.map +1 -1
  69. package/lib/module/components/PkChart.js +1 -1
  70. package/lib/module/components/PkChart.js.map +1 -1
  71. package/lib/module/components/Popover.js +2 -1
  72. package/lib/module/components/Popover.js.map +1 -1
  73. package/lib/module/components/ProGate.js +2 -1
  74. package/lib/module/components/ProGate.js.map +1 -1
  75. package/lib/module/components/RestTimer.js +2 -1
  76. package/lib/module/components/RestTimer.js.map +1 -1
  77. package/lib/module/components/Ring.js +2 -1
  78. package/lib/module/components/Ring.js.map +1 -1
  79. package/lib/module/components/SegmentedTabs.js +2 -1
  80. package/lib/module/components/SegmentedTabs.js.map +1 -1
  81. package/lib/module/components/Select.js +7 -1
  82. package/lib/module/components/Select.js.map +1 -1
  83. package/lib/module/components/ShareCard.js +2 -1
  84. package/lib/module/components/ShareCard.js.map +1 -1
  85. package/lib/module/components/Sheet.js +2 -1
  86. package/lib/module/components/Sheet.js.map +1 -1
  87. package/lib/module/components/TabBar.js +2 -1
  88. package/lib/module/components/TabBar.js.map +1 -1
  89. package/lib/module/components/TagInput.js +82 -41
  90. package/lib/module/components/TagInput.js.map +1 -1
  91. package/lib/module/components/Toggle.js +2 -1
  92. package/lib/module/components/Toggle.js.map +1 -1
  93. package/lib/module/components/Tooltip.js +2 -1
  94. package/lib/module/components/Tooltip.js.map +1 -1
  95. package/lib/module/internal/absoluteFill.js +14 -0
  96. package/lib/module/internal/absoluteFill.js.map +1 -0
  97. package/lib/typescript/components/AuroraBackground.d.ts.map +1 -1
  98. package/lib/typescript/components/Avatar.d.ts.map +1 -1
  99. package/lib/typescript/components/BrandMark.d.ts +4 -1
  100. package/lib/typescript/components/BrandMark.d.ts.map +1 -1
  101. package/lib/typescript/components/FocalPointPicker.d.ts.map +1 -1
  102. package/lib/typescript/components/Gauge.d.ts.map +1 -1
  103. package/lib/typescript/components/Icon.d.ts +1 -1
  104. package/lib/typescript/components/Icon.d.ts.map +1 -1
  105. package/lib/typescript/components/Menu.d.ts.map +1 -1
  106. package/lib/typescript/components/Modal.d.ts.map +1 -1
  107. package/lib/typescript/components/PasswordInput.d.ts +7 -4
  108. package/lib/typescript/components/PasswordInput.d.ts.map +1 -1
  109. package/lib/typescript/components/PhaseWheel.d.ts.map +1 -1
  110. package/lib/typescript/components/PkChart.d.ts.map +1 -1
  111. package/lib/typescript/components/Popover.d.ts.map +1 -1
  112. package/lib/typescript/components/ProGate.d.ts.map +1 -1
  113. package/lib/typescript/components/RestTimer.d.ts.map +1 -1
  114. package/lib/typescript/components/Ring.d.ts.map +1 -1
  115. package/lib/typescript/components/SegmentedTabs.d.ts.map +1 -1
  116. package/lib/typescript/components/Select.d.ts +6 -1
  117. package/lib/typescript/components/Select.d.ts.map +1 -1
  118. package/lib/typescript/components/ShareCard.d.ts.map +1 -1
  119. package/lib/typescript/components/Sheet.d.ts.map +1 -1
  120. package/lib/typescript/components/TabBar.d.ts.map +1 -1
  121. package/lib/typescript/components/TagInput.d.ts +21 -8
  122. package/lib/typescript/components/TagInput.d.ts.map +1 -1
  123. package/lib/typescript/components/Toggle.d.ts.map +1 -1
  124. package/lib/typescript/components/Tooltip.d.ts.map +1 -1
  125. package/lib/typescript/internal/absoluteFill.d.ts +8 -0
  126. package/lib/typescript/internal/absoluteFill.d.ts.map +1 -0
  127. package/package.json +1 -1
  128. package/src/components/AuroraBackground.tsx +39 -18
  129. package/src/components/Avatar.tsx +2 -1
  130. package/src/components/BrandMark.tsx +10 -4
  131. package/src/components/FocalPointPicker.tsx +2 -1
  132. package/src/components/Gauge.tsx +2 -1
  133. package/src/components/Icon.tsx +3 -0
  134. package/src/components/Menu.tsx +2 -1
  135. package/src/components/Modal.tsx +2 -1
  136. package/src/components/PasswordInput.tsx +9 -6
  137. package/src/components/PhaseWheel.tsx +2 -1
  138. package/src/components/PkChart.tsx +2 -1
  139. package/src/components/Popover.tsx +2 -1
  140. package/src/components/ProGate.tsx +2 -1
  141. package/src/components/RestTimer.tsx +2 -1
  142. package/src/components/Ring.tsx +2 -1
  143. package/src/components/SegmentedTabs.tsx +2 -1
  144. package/src/components/Select.tsx +10 -1
  145. package/src/components/ShareCard.tsx +2 -1
  146. package/src/components/Sheet.tsx +2 -1
  147. package/src/components/TabBar.tsx +2 -1
  148. package/src/components/TagInput.tsx +84 -44
  149. package/src/components/Toggle.tsx +2 -1
  150. package/src/components/Tooltip.tsx +2 -1
  151. package/src/components/__tests__/BrandMark.test.tsx +17 -0
  152. package/src/components/__tests__/CodeInput.test.tsx +81 -25
  153. package/src/components/__tests__/PasswordInput.test.tsx +19 -0
  154. package/src/components/__tests__/Select.test.tsx +22 -0
  155. package/src/internal/absoluteFill.ts +11 -0
@@ -71,6 +71,7 @@ import {
71
71
  Pill,
72
72
  Play,
73
73
  Plus,
74
+ QrCode,
74
75
  Quote,
75
76
  RefreshCcw,
76
77
  Ruler,
@@ -231,6 +232,7 @@ export type IconName =
231
232
  | 'drop'
232
233
  | 'utensil'
233
234
  | 'barcode-scan'
235
+ | 'qr-code'
234
236
  | 'pill'
235
237
  | 'flask'
236
238
  | 'syringe'
@@ -422,6 +424,7 @@ const LUCIDE: Record<Exclude<IconName, CustomIconName>, LucideIcon> = {
422
424
  drop: Droplet,
423
425
  utensil: Utensils,
424
426
  'barcode-scan': ScanBarcode,
427
+ 'qr-code': QrCode,
425
428
  pill: Pill,
426
429
  flask: FlaskConical,
427
430
  syringe: Syringe,
@@ -2,6 +2,7 @@ import { useRef, useState, type ReactNode } from 'react';
2
2
  import { View, Text, Pressable, StyleSheet, Modal, Dimensions } from 'react-native';
3
3
  import { colors, radii, fonts, nativeShadows } from '@lockin90/tokens';
4
4
  import { Icon } from './Icon';
5
+ import { absoluteFill } from '../internal/absoluteFill';
5
6
 
6
7
  /** A single row in a {@link Menu}. A `divider` row renders a hairline
7
8
  * separator instead of an action (its `label` is ignored). */
@@ -129,7 +130,7 @@ const styles = StyleSheet.create({
129
130
  alignItems: 'center',
130
131
  justifyContent: 'center',
131
132
  },
132
- backdrop: { ...StyleSheet.absoluteFillObject },
133
+ backdrop: { ...absoluteFill },
133
134
  panel: {
134
135
  position: 'absolute',
135
136
  minWidth: 160,
@@ -2,6 +2,7 @@ import { type ReactNode } from 'react';
2
2
  import { Modal as RNModal, View, Text, Pressable, StyleSheet } from 'react-native';
3
3
  import Svg, { Path } from 'react-native-svg';
4
4
  import { colors, radii, fonts } from '@lockin90/tokens';
5
+ import { absoluteFill } from '../internal/absoluteFill';
5
6
 
6
7
  export interface ModalProps {
7
8
  /** Show the modal. When false, nothing renders. */
@@ -61,7 +62,7 @@ export function Modal({ open = true, onClose, title, children, footer, size = 'm
61
62
 
62
63
  const styles = StyleSheet.create({
63
64
  scrim: {
64
- ...StyleSheet.absoluteFillObject,
65
+ ...absoluteFill,
65
66
  flex: 1,
66
67
  alignItems: 'center',
67
68
  justifyContent: 'center',
@@ -14,9 +14,12 @@ export interface PasswordInputProps extends Omit<TextInputProps, 'secureTextEntr
14
14
  leading?: ReactNode;
15
15
  /** start with the password revealed @default false */
16
16
  defaultVisible?: boolean;
17
- /** accessibility label for the show/hide toggle
18
- * (default "Show password" / "Hide password") */
19
- toggleLabel?: string;
17
+ /** accessibility label of the toggle while masked (pass a localized string)
18
+ * @default 'Show password' */
19
+ showLabel?: string;
20
+ /** accessibility label of the toggle while revealed (pass a localized string)
21
+ * @default 'Hide password' */
22
+ hideLabel?: string;
20
23
  }
21
24
 
22
25
  /**
@@ -30,7 +33,8 @@ export function PasswordInput({
30
33
  inputSize = 'md',
31
34
  leading,
32
35
  defaultVisible = false,
33
- toggleLabel,
36
+ showLabel = 'Show password',
37
+ hideLabel = 'Hide password',
34
38
  style,
35
39
  onFocus,
36
40
  onBlur,
@@ -40,7 +44,6 @@ export function PasswordInput({
40
44
  const [visible, setVisible] = useState(defaultVisible);
41
45
  const sm = inputSize === 'sm';
42
46
  const borderColor = invalid ? colors.zoneLow : focused ? colors.accent : colors.border2;
43
- const aria = toggleLabel ?? (visible ? 'Hide password' : 'Show password');
44
47
 
45
48
  return (
46
49
  <View style={styles.wrap}>
@@ -63,7 +66,7 @@ export function PasswordInput({
63
66
  />
64
67
  <Pressable
65
68
  accessibilityRole="button"
66
- accessibilityLabel={aria}
69
+ accessibilityLabel={visible ? hideLabel : showLabel}
67
70
  hitSlop={8}
68
71
  onPress={() => setVisible((v) => !v)}
69
72
  style={styles.trail}
@@ -2,6 +2,7 @@ import { useId, type ReactNode } from 'react';
2
2
  import { View, Text, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
3
  import Svg, { Circle, Defs, FeGaussianBlur, Filter, G, Line, Path, Rect, Text as SvgText } from 'react-native-svg';
4
4
  import { colors, fonts, radii } from '@lockin90/tokens';
5
+ import { absoluteFill } from '../internal/absoluteFill';
5
6
 
6
7
  /** A colored phase band on the 24h ring, drawn CLOCKWISE from `from` to `to`
7
8
  * (hours 0..24). When `from > to` the band wraps through midnight (e.g. 23→7). */
@@ -694,7 +695,7 @@ export function PhaseWheel({
694
695
  const styles = StyleSheet.create({
695
696
  root: { position: 'relative', alignItems: 'center', justifyContent: 'center' },
696
697
  center: {
697
- ...StyleSheet.absoluteFillObject,
698
+ ...absoluteFill,
698
699
  flexDirection: 'column',
699
700
  alignItems: 'center',
700
701
  justifyContent: 'center',
@@ -12,6 +12,7 @@ import {
12
12
  } from 'react-native';
13
13
  import Svg, { Path, Line, Rect, Defs, LinearGradient, Stop, ClipPath } from 'react-native-svg';
14
14
  import { colors, fonts, radii } from '@lockin90/tokens';
15
+ import { absoluteFill } from '../internal/absoluteFill';
15
16
 
16
17
  /** A single dose taken at `at` hours from t0, of `mg` of substance. */
17
18
  export interface PkDose {
@@ -326,7 +327,7 @@ export function PkChart({
326
327
  {beatAnim && line && canvasW > 0 ? (
327
328
  <Animated.View
328
329
  pointerEvents="none"
329
- style={[StyleSheet.absoluteFillObject, { opacity: beatAnim.glowOpacity }]}
330
+ style={[StyleSheet.absoluteFill, { opacity: beatAnim.glowOpacity }]}
330
331
  >
331
332
  <Svg width={canvasW} height={height} viewBox={`0 0 ${VB_W} ${height}`} preserveAspectRatio="none">
332
333
  <Path d={line} fill="none" stroke={color} strokeWidth={7} strokeLinecap="round" strokeLinejoin="round" />
@@ -3,6 +3,7 @@ import { View, Text, Pressable, StyleSheet, Modal, Dimensions } from 'react-nati
3
3
  import Svg, { Rect, Path } from 'react-native-svg';
4
4
  import { colors, radii, fonts, nativeShadows } from '@lockin90/tokens';
5
5
  import { Icon } from './Icon';
6
+ import { absoluteFill } from '../internal/absoluteFill';
6
7
 
7
8
  /* expo-clipboard is an optional dependency — not a hard dep of this kit.
8
9
  * Resolve it lazily and guard usage; if it's absent we keep the visual
@@ -112,7 +113,7 @@ const styles = StyleSheet.create({
112
113
  alignItems: 'center',
113
114
  justifyContent: 'center',
114
115
  },
115
- backdrop: { ...StyleSheet.absoluteFillObject },
116
+ backdrop: { ...absoluteFill },
116
117
  panel: {
117
118
  position: 'absolute',
118
119
  maxWidth: 260,
@@ -3,6 +3,7 @@ import { View, Text, Pressable, StyleSheet, type ViewProps } from 'react-native'
3
3
  import Svg, { Path, Rect } from 'react-native-svg';
4
4
  import { colors, radii, fonts } from '@lockin90/tokens';
5
5
  import { OptionalBlurView } from '../internal/optionalBlur';
6
+ import { absoluteFill } from '../internal/absoluteFill';
6
7
 
7
8
  export interface ProGateProps extends Omit<ViewProps, 'children'> {
8
9
  /** Lock-card heading. */
@@ -100,7 +101,7 @@ const styles = StyleSheet.create({
100
101
  opacity: 0.35,
101
102
  },
102
103
  frost: {
103
- ...StyleSheet.absoluteFillObject,
104
+ ...absoluteFill,
104
105
  },
105
106
  contentEmpty: {
106
107
  minHeight: 150,
@@ -3,6 +3,7 @@ import { View, Text, StyleSheet, type StyleProp, type ViewStyle } from 'react-na
3
3
  import Svg, { Circle, Defs, FeGaussianBlur, Filter, G } from 'react-native-svg';
4
4
  import { colors, fonts } from '@lockin90/tokens';
5
5
  import { Button } from './Button';
6
+ import { absoluteFill } from '../internal/absoluteFill';
6
7
 
7
8
  export interface RestTimerProps {
8
9
  /** Seconds left (drives the arc + the centered readout). */
@@ -159,7 +160,7 @@ const styles = StyleSheet.create({
159
160
  ring: { width: SIZE, height: SIZE },
160
161
  ringCompact: { width: SIZE_COMPACT, height: SIZE_COMPACT },
161
162
  center: {
162
- ...StyleSheet.absoluteFillObject,
163
+ ...absoluteFill,
163
164
  alignItems: 'center',
164
165
  justifyContent: 'center',
165
166
  gap: 2,
@@ -2,6 +2,7 @@ import { useId, type ReactNode } from 'react';
2
2
  import { View, Text, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
3
  import Svg, { Circle, Defs, FeGaussianBlur, Filter, G, LinearGradient, Stop } from 'react-native-svg';
4
4
  import { colors, fonts, radii } from '@lockin90/tokens';
5
+ import { absoluteFill } from '../internal/absoluteFill';
5
6
 
6
7
  /** Zone verdict color for a 0–100 value (same thresholds as the chart catalog). */
7
8
  function zoneColor(v: number): string {
@@ -238,7 +239,7 @@ export function Ring({
238
239
  const styles = StyleSheet.create({
239
240
  root: { position: 'relative', alignItems: 'center', justifyContent: 'center' },
240
241
  center: {
241
- ...StyleSheet.absoluteFillObject,
242
+ ...absoluteFill,
242
243
  alignItems: 'center',
243
244
  justifyContent: 'center',
244
245
  },
@@ -3,6 +3,7 @@ import { LinearGradient } from 'expo-linear-gradient';
3
3
  import { colors, radii, fonts, gradients, nativeShadows } from '@lockin90/tokens';
4
4
  import { hapticSelection } from "../internal/optionalHaptics";
5
5
  import { Icon, type IconName } from './Icon';
6
+ import { absoluteFill } from '../internal/absoluteFill';
6
7
 
7
8
  /**
8
9
  * Per-segment tone — mirrors the web `SegmentedTone`. Two families:
@@ -347,7 +348,7 @@ const styles = StyleSheet.create({
347
348
  // absolutely positioned behind the label) + glow, mirroring web
348
349
  // `[data-tone='mint'|'gradient'] .is-active`. The label paints dark-on-bright.
349
350
  tabActiveFill: {
350
- ...StyleSheet.absoluteFillObject,
351
+ ...absoluteFill,
351
352
  borderRadius: radii.pill,
352
353
  },
353
354
  glowMint: { ...nativeShadows.glowMint },
@@ -44,6 +44,11 @@ export interface SelectProps {
44
44
  searchPlaceholder?: string;
45
45
  /** title shown at the top of the option sheet (with a close button). */
46
46
  title?: string;
47
+ /** accessibility label of the sheet's close button (pass a localized string). @default 'Close' */
48
+ closeLabel?: string;
49
+ /** the field's name for screen readers when there is no visible `label`
50
+ * (falls back to `label`). Mirrors web `aria-label`. */
51
+ accessibilityLabel?: string;
47
52
  /**
48
53
  * Field scale. `'md'` (default) is the standard form field. `'sm'` is the
49
54
  * design settings compact glass pill (`.pref-select`): mono 11px value,
@@ -114,6 +119,8 @@ export function Select({
114
119
  searchable = false,
115
120
  searchPlaceholder = 'Search…',
116
121
  title,
122
+ closeLabel = 'Close',
123
+ accessibilityLabel,
117
124
  size = 'md',
118
125
  leading,
119
126
  }: SelectProps) {
@@ -147,6 +154,8 @@ export function Select({
147
154
  disabled && styles.fieldDisabled,
148
155
  ]}
149
156
  accessibilityRole="button"
157
+ accessibilityLabel={accessibilityLabel ?? label}
158
+ accessibilityValue={selected ? { text: selected.label } : undefined}
150
159
  accessibilityState={{ disabled, expanded: open }}
151
160
  disabled={disabled}
152
161
  onPress={() => { hapticSelection(); setOpen(true); }}
@@ -174,7 +183,7 @@ export function Select({
174
183
  <Text style={styles.title}>{title.toUpperCase()}</Text>
175
184
  <Pressable
176
185
  accessibilityRole="button"
177
- accessibilityLabel="Close"
186
+ accessibilityLabel={closeLabel}
178
187
  hitSlop={8}
179
188
  onPress={close}
180
189
  style={styles.closeBtn}
@@ -5,6 +5,7 @@ import { BrandMark } from './BrandMark';
5
5
  import { GradientText } from './GradientText';
6
6
  import { Ring } from './Ring';
7
7
  import { Stat } from './Stat';
8
+ import { absoluteFill } from '../internal/absoluteFill';
8
9
 
9
10
  export type ShareCardGradient = 'flux' | 'cta' | 'mint' | 'violet';
10
11
  export type ShareCardSize = 'sm' | 'md' | 'lg';
@@ -131,7 +132,7 @@ const styles = StyleSheet.create({
131
132
  },
132
133
  // aurora wash — a subtle token-gradient over the void surface (kept low so the
133
134
  // void dominates the center and light --text stays legible, ~matching the web mesh)
134
- wash: { ...StyleSheet.absoluteFillObject, opacity: 0.32 },
135
+ wash: { ...absoluteFill, opacity: 0.32 },
135
136
  top: { position: 'relative' },
136
137
  body: { flex: 1, position: 'relative', alignItems: 'center', justifyContent: 'center', gap: 10 },
137
138
  eyebrow: { fontFamily: fonts.mono, letterSpacing: 2, textTransform: 'uppercase', color: colors.textDim },
@@ -11,6 +11,7 @@ import {
11
11
  Platform,
12
12
  } from 'react-native';
13
13
  import { colors, radii, fonts } from '@lockin90/tokens';
14
+ import { absoluteFill } from '../internal/absoluteFill';
14
15
 
15
16
  export interface SheetProps {
16
17
  /** Show the sheet. When false, nothing renders. */
@@ -129,7 +130,7 @@ export function Sheet({ open = true, onClose, side = 'bottom', title, children }
129
130
 
130
131
  const styles = StyleSheet.create({
131
132
  scrim: {
132
- ...StyleSheet.absoluteFillObject,
133
+ ...absoluteFill,
133
134
  flex: 1,
134
135
  backgroundColor: 'rgba(0,0,0,0.55)',
135
136
  },
@@ -5,6 +5,7 @@ import { colors, radii, fonts, gradients, nativeShadows } from '@lockin90/tokens
5
5
  import { Icon, type IconName } from './Icon';
6
6
  import { OptionalBlurView } from '../internal/optionalBlur';
7
7
  import { hapticSelection } from '../internal/optionalHaptics';
8
+ import { absoluteFill } from '../internal/absoluteFill';
8
9
 
9
10
  /* ============================================================================
10
11
  Mobile atoms — TabBar (bottom navigation) + FAB (floating action button).
@@ -223,7 +224,7 @@ const styles = StyleSheet.create({
223
224
  // the frosted/translucent background, clipped to the capsule independently
224
225
  // of the content so the FAB overhang survives
225
226
  capsuleFill: {
226
- ...StyleSheet.absoluteFillObject,
227
+ ...absoluteFill,
227
228
  borderRadius: radii.pill,
228
229
  overflow: 'hidden',
229
230
  backgroundColor: 'rgba(12,15,26,0.82)',
@@ -129,38 +129,84 @@ export interface CodeInputProps extends ViewProps {
129
129
  /** Web-parity alias of `error` — the web `CodeInput` names this `invalid`, so
130
130
  * the same prop works on both platforms. Either prop turns the boxes red. */
131
131
  invalid?: boolean;
132
+ /** each box's a11y label, `position` from 1 — pass a translated one (the default is English, for stories).
133
+ * Box 0 also takes a pasted / autofilled whole code but keeps `digitLabel(1, length)`. */
134
+ digitLabel?: (position: number, length: number) => string;
132
135
  }
133
136
 
134
- /** Spread a multi-char entry (paste / iOS SMS autofill delivered into one box)
135
- * across the code boxes from `index` on: chars past the last box are dropped,
136
- * and focus lands after the last written char (clamped to the last box).
137
- * (Pure — extracted from `onBoxChange` so the paste math is unit-testable.) */
138
- export function spreadCodeAcrossBoxes(
139
- chars: readonly string[],
137
+ const defaultDigitLabel = (position: number, length: number) => `Digit ${position} of ${length}`;
138
+
139
+ /** The boxes for a code string: one digit each, non-digits dropped, padded with '' to `length`. */
140
+ export function codeToBoxes(value: string, length: number): string[] {
141
+ const digits = value.replace(/\D/g, '');
142
+ return Array.from({ length }, (_, i) => digits[i] ?? '');
143
+ }
144
+
145
+ /** Spread a multi-char entry (paste / iOS SMS autofill delivered into one box) across the boxes from
146
+ * `index` on: digits only, the ones past the last box dropped, earlier boxes kept. Focus lands after
147
+ * the last digit written (clamped). (Pure, so the paste math is unit-testable; mirrors the web.) */
148
+ export function spreadDigits(
149
+ boxes: readonly string[],
140
150
  index: number,
141
- digits: string,
142
- length: number,
143
- ): { code: string; focusIndex: number } {
144
- const next = chars.slice();
145
- for (let i = 0; i < digits.length && index + i < length; i++) {
146
- next[index + i] = digits[i];
147
- }
148
- return { code: next.join(''), focusIndex: Math.min(index + digits.length, length - 1) };
151
+ text: string,
152
+ ): { boxes: string[]; focusIndex: number } {
153
+ const digits = text.replace(/\D/g, '');
154
+ const next = boxes.slice();
155
+ for (let i = 0; i < digits.length && index + i < next.length; i++) next[index + i] = digits[i];
156
+ return { boxes: next, focusIndex: Math.min(index + digits.length, next.length - 1) };
157
+ }
158
+
159
+ /** What a box's new text does (its digit is always selected, so typing replaces it): one digit fills
160
+ * the box; more digits (paste, autofill) spread; no digit at all (a letter) is ignored (`null`);
161
+ * empty clears the box. Focus moves on after a written digit. */
162
+ export function typeIntoBox(
163
+ boxes: readonly string[],
164
+ index: number,
165
+ raw: string,
166
+ ): { boxes: string[]; focusIndex: number } | null {
167
+ const digits = raw.replace(/\D/g, '');
168
+ if (raw && !digits) return null;
169
+ if (digits.length > 1) return spreadDigits(boxes, index, digits);
170
+ const next = boxes.slice();
171
+ next[index] = digits;
172
+ return { boxes: next, focusIndex: digits ? Math.min(index + 1, next.length - 1) : index };
173
+ }
174
+
175
+ /** The boxes at another `length`: each keeps its place (a gap stays a gap), the extra ones dropped. */
176
+ export function resizeBoxes(boxes: readonly string[], length: number): string[] {
177
+ return Array.from({ length }, (_, i) => boxes[i] ?? '');
149
178
  }
150
179
 
151
- /** Segmented N-digit verification code — auto-advance + backspace. */
152
- export function CodeInput({ length = 6, value = '', onChange, error = false, invalid = false, style, ...rest }: CodeInputProps) {
180
+ /** Segmented N-digit verification code — auto-advance + backspace + paste / autofill into box 0. */
181
+ export function CodeInput({
182
+ length = 6,
183
+ value = '',
184
+ onChange,
185
+ error = false,
186
+ invalid = false,
187
+ digitLabel = defaultDigitLabel,
188
+ style,
189
+ ...rest
190
+ }: CodeInputProps) {
153
191
  const isInvalid = invalid || error;
154
- const [code, setCode] = useState<string>(value.slice(0, length));
192
+ // One entry per box, so clearing a middle box leaves a gap instead of shifting later digits.
193
+ const [boxes, setBoxes] = useState<string[]>(() => codeToBoxes(value, length));
194
+ // A new `value` from the app (e.g. cleared after a wrong code) replaces what's shown; the app echoing
195
+ // back what `onChange` gave it doesn't (it would close up a gap the user left).
196
+ // Another `length` (another kind of code) re-draws the boxes, each digit in its place; an app that
197
+ // changes it passes the matching `value` too, which wins.
198
+ const [prev, setPrev] = useState({ value, length });
199
+ if (value !== prev.value || length !== prev.length) {
200
+ setPrev({ value, length });
201
+ if (value !== prev.value && value !== boxes.join('')) setBoxes(codeToBoxes(value, length));
202
+ else if (length !== prev.length) setBoxes(resizeBoxes(boxes, length));
203
+ }
155
204
  const [focused, setFocused] = useState<number | null>(null);
156
205
  const refs = useRef<Array<TextInput | null>>([]);
157
206
 
158
- const chars = Array.from({ length }, (_, i) => code[i] ?? '');
159
-
160
- const commit = (next: string) => {
161
- const clamped = next.slice(0, length);
162
- setCode(clamped);
163
- onChange?.(clamped);
207
+ const commit = (next: string[]) => {
208
+ setBoxes(next);
209
+ onChange?.(next.join(''));
164
210
  };
165
211
 
166
212
  const focusBox = (index: number) => {
@@ -168,39 +214,30 @@ export function CodeInput({ length = 6, value = '', onChange, error = false, inv
168
214
  };
169
215
 
170
216
  const onBoxChange = (index: number, raw: string) => {
171
- // Paste or iOS SMS autofill can deliver the whole code into one box — spread
172
- // it across the remaining boxes from here (mirrors the web `onPaste`).
173
- const digits = raw.replace(/\s/g, '');
174
- if (digits.length > 1) {
175
- const spread = spreadCodeAcrossBoxes(chars, index, digits, length);
176
- commit(spread.code);
177
- focusBox(spread.focusIndex);
178
- return;
179
- }
180
- const ch = digits.slice(-1);
181
- const next = chars.slice();
182
- next[index] = ch;
183
- commit(next.join(''));
184
- if (ch) focusBox(index + 1);
217
+ const typed = typeIntoBox(boxes, index, raw);
218
+ // A letter: re-render, so the native field drops it and shows the box's digit again.
219
+ if (!typed) return setBoxes((current) => current.slice());
220
+ commit(typed.boxes);
221
+ if (typed.focusIndex !== index) focusBox(typed.focusIndex);
185
222
  };
186
223
 
187
224
  const onKeyPress = (index: number, e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
188
225
  if (e.nativeEvent.key !== 'Backspace') return;
189
- if (chars[index]) {
190
- const next = chars.slice();
226
+ if (boxes[index]) {
227
+ const next = boxes.slice();
191
228
  next[index] = '';
192
- commit(next.join(''));
229
+ commit(next);
193
230
  } else if (index > 0) {
194
- const next = chars.slice();
231
+ const next = boxes.slice();
195
232
  next[index - 1] = '';
196
- commit(next.join(''));
233
+ commit(next);
197
234
  focusBox(index - 1);
198
235
  }
199
236
  };
200
237
 
201
238
  return (
202
239
  <View style={[styles.code, style]} {...rest}>
203
- {chars.map((ch, i) => {
240
+ {boxes.map((ch, i) => {
204
241
  const filled = !!ch;
205
242
  const isFocused = focused === i;
206
243
  const borderColor = isFocused
@@ -230,7 +267,10 @@ export function CodeInput({ length = 6, value = '', onChange, error = false, inv
230
267
  // spreads across boxes; the rest stay single-char for clean typing.
231
268
  maxLength={i === 0 ? length : 1}
232
269
  value={ch}
233
- accessibilityLabel={`Digit ${i + 1}`}
270
+ // The digit stays selected, so typing replaces it (also after a tap on a focused box).
271
+ selectTextOnFocus
272
+ selection={{ start: 0, end: ch.length }}
273
+ accessibilityLabel={digitLabel(i + 1, length)}
234
274
  selectionColor={colors.accent}
235
275
  onChangeText={(t) => onBoxChange(i, t)}
236
276
  onKeyPress={(e) => onKeyPress(i, e)}
@@ -2,6 +2,7 @@ import { View, Text, Pressable, StyleSheet, type StyleProp, type ViewStyle } fro
2
2
  import { hapticSelection } from '../internal/optionalHaptics';
3
3
  import { LinearGradient } from 'expo-linear-gradient';
4
4
  import { colors, radii, fonts, gradients } from '@lockin90/tokens';
5
+ import { absoluteFill } from '../internal/absoluteFill';
5
6
 
6
7
  export type ToggleSize = 'sm' | 'md';
7
8
 
@@ -142,7 +143,7 @@ const styles = StyleSheet.create({
142
143
  boxShadow: '0 0 14px rgba(124,92,255,0.45)',
143
144
  },
144
145
  trackFill: {
145
- ...StyleSheet.absoluteFillObject,
146
+ ...absoluteFill,
146
147
  borderRadius: radii.pill,
147
148
  },
148
149
  knob: {
@@ -1,6 +1,7 @@
1
1
  import { useRef, useState, type ReactNode } from 'react';
2
2
  import { View, Text, Pressable, StyleSheet, Modal, Dimensions, type ViewStyle } from 'react-native';
3
3
  import { colors, radii, fonts, nativeShadows } from '@lockin90/tokens';
4
+ import { absoluteFill } from '../internal/absoluteFill';
4
5
 
5
6
  export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';
6
7
 
@@ -166,7 +167,7 @@ const styles = StyleSheet.create({
166
167
  justifyContent: 'center',
167
168
  },
168
169
  badgeText: { fontFamily: fonts.ui, fontSize: 11, fontWeight: '600', color: colors.textDim },
169
- backdrop: { ...StyleSheet.absoluteFillObject },
170
+ backdrop: { ...absoluteFill },
170
171
  bubble: {
171
172
  position: 'absolute',
172
173
  maxWidth: 220,
@@ -0,0 +1,17 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { BrandMark } from '../BrandMark';
3
+ import { render } from './markup';
4
+
5
+ /* The web logo is a 30px mark with 19px text: the lockup's wordmark can be
6
+ * sized apart from the mark, as on web. */
7
+ describe('BrandMark lockup wordmark', () => {
8
+ test('scales with the mark by default', () => {
9
+ expect(render(BrandMark, { variant: 'lockup', size: 30 })).toMatch(/font-size:32px/);
10
+ });
11
+
12
+ test('takes its own size from wordmarkSize', () => {
13
+ const html = render(BrandMark, { variant: 'lockup', size: 30, wordmarkSize: 19 });
14
+ expect(html).toMatch(/font-size:19px/);
15
+ expect(html).not.toMatch(/font-size:32px/);
16
+ });
17
+ });