@incodetech/prizma-core-screens 0.2.4 → 0.2.7

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 (116) hide show
  1. package/dist/CurpEnterScreen.d.ts +7 -0
  2. package/dist/CurpEnterScreen.js +17 -3
  3. package/dist/CurpGenerateScreen.d.ts +8 -0
  4. package/dist/CurpGenerateScreen.js +28 -13
  5. package/dist/CurpLayout.css +8 -34
  6. package/dist/CurpStrings.d.ts +16 -2
  7. package/dist/EkybAddressDetailsScreen.d.ts +7 -0
  8. package/dist/EkybAddressDetailsScreen.js +25 -7
  9. package/dist/EkybBusinessDetailsScreen.d.ts +6 -0
  10. package/dist/EkybBusinessDetailsScreen.js +23 -6
  11. package/dist/EkybCountryScreen.d.ts +13 -5
  12. package/dist/EkybCountryScreen.js +27 -9
  13. package/dist/EkybOwnersScreen.d.ts +8 -0
  14. package/dist/EkybOwnersScreen.js +26 -3
  15. package/dist/EkybStrings.d.ts +27 -10
  16. package/dist/EkycCountryCatalog.d.ts +4 -1
  17. package/dist/EkycCountryCatalog.js +5 -3
  18. package/dist/EkycCountryFormScreen.d.ts +12 -3
  19. package/dist/EkycCountryFormScreen.js +28 -10
  20. package/dist/EkycDetailsScreen.d.ts +7 -0
  21. package/dist/EkycDetailsScreen.js +21 -4
  22. package/dist/EkycStrings.d.ts +14 -2
  23. package/dist/ElectronicSignatureLayout.css +6 -0
  24. package/dist/EmailInputStrings.d.ts +11 -1
  25. package/dist/EmailInputVerifyScreen.d.ts +7 -0
  26. package/dist/EmailInputVerifyScreen.js +17 -3
  27. package/dist/FaceMatchComparisonScreen.d.ts +3 -3
  28. package/dist/FaceMatchLayout.css +21 -12
  29. package/dist/FormScreen.css +16 -0
  30. package/dist/FormsLongFormScreen.d.ts +8 -0
  31. package/dist/FormsLongFormScreen.js +34 -13
  32. package/dist/FormsStrings.d.ts +11 -1
  33. package/dist/GeolocationBrowserStepsScreen.d.ts +6 -2
  34. package/dist/GeolocationBrowserStepsScreen.js +7 -3
  35. package/dist/GeolocationStrings.d.ts +3 -0
  36. package/dist/IdCaptureLayout.css +40 -0
  37. package/dist/ListScreen.d.ts +2 -0
  38. package/dist/ListScreen.js +1 -1
  39. package/dist/PhoneInputLayout.css +10 -9
  40. package/dist/PhoneInputStrings.d.ts +11 -1
  41. package/dist/PhoneInputVerifyScreen.d.ts +8 -0
  42. package/dist/PhoneInputVerifyScreen.js +19 -6
  43. package/dist/QualifiedSignatureAcceptScreen.js +6 -1
  44. package/dist/QualifiedSignatureDocumentScreen.d.ts +13 -9
  45. package/dist/QualifiedSignatureDocumentScreen.js +18 -16
  46. package/dist/QualifiedSignatureLayout.css +76 -0
  47. package/dist/QualifiedSignatureStrings.d.ts +10 -0
  48. package/dist/ScreenNav.css +8 -0
  49. package/dist/ScreenNav.d.ts +15 -2
  50. package/dist/ScreenNav.js +10 -3
  51. package/dist/VideoSelfieIdCaptureScreen.d.ts +10 -3
  52. package/dist/VideoSelfieIdCaptureScreen.js +44 -13
  53. package/dist/VideoSelfieLayout.css +164 -33
  54. package/dist/VideoSelfieSelfieCaptureScreen.d.ts +44 -27
  55. package/dist/VideoSelfieSelfieCaptureScreen.js +65 -39
  56. package/dist/VideoSelfieSpeakScreen.js +18 -1
  57. package/dist/VideoSelfieStrings.d.ts +78 -12
  58. package/dist/VideoSelfieTurnHeadScreen.d.ts +51 -0
  59. package/dist/VideoSelfieTurnHeadScreen.js +63 -0
  60. package/dist/VideoSelfieTutorialScreen.js +13 -5
  61. package/dist/VideoSelfieWaveScreen.d.ts +53 -0
  62. package/dist/VideoSelfieWaveScreen.js +62 -0
  63. package/dist/WatchlistBusinessFormScreen.d.ts +11 -7
  64. package/dist/WatchlistBusinessFormScreen.js +27 -13
  65. package/dist/WatchlistBusinessStrings.d.ts +21 -7
  66. package/dist/index.d.ts +3 -1
  67. package/dist/index.js +2 -0
  68. package/dist/types.d.ts +3 -1
  69. package/package.json +14 -3
  70. package/samples/video-selfie-room.png +0 -0
  71. package/src/CurpEnterScreen.tsx +21 -4
  72. package/src/CurpGenerateScreen.tsx +46 -22
  73. package/src/CurpLayout.css +8 -34
  74. package/src/CurpStrings.ts +22 -2
  75. package/src/EkybAddressDetailsScreen.tsx +32 -4
  76. package/src/EkybBusinessDetailsScreen.tsx +23 -5
  77. package/src/EkybCountryScreen.tsx +28 -7
  78. package/src/EkybOwnersScreen.tsx +34 -3
  79. package/src/EkybStrings.ts +29 -10
  80. package/src/EkycCountryCatalog.ts +9 -4
  81. package/src/EkycCountryFormScreen.tsx +51 -12
  82. package/src/EkycDetailsScreen.tsx +22 -1
  83. package/src/EkycStrings.ts +14 -2
  84. package/src/ElectronicSignatureLayout.css +6 -0
  85. package/src/EmailInputStrings.ts +12 -1
  86. package/src/EmailInputVerifyScreen.tsx +21 -4
  87. package/src/FaceMatchComparisonScreen.tsx +3 -3
  88. package/src/FaceMatchLayout.css +21 -12
  89. package/src/FormScreen.css +16 -0
  90. package/src/FormsLongFormScreen.tsx +41 -13
  91. package/src/FormsStrings.ts +12 -1
  92. package/src/GeolocationBrowserStepsScreen.tsx +6 -3
  93. package/src/GeolocationStrings.ts +3 -0
  94. package/src/IdCaptureLayout.css +40 -0
  95. package/src/ListScreen.tsx +10 -1
  96. package/src/PhoneInputLayout.css +10 -9
  97. package/src/PhoneInputStrings.ts +12 -1
  98. package/src/PhoneInputVerifyScreen.tsx +21 -5
  99. package/src/QualifiedSignatureAcceptScreen.tsx +12 -1
  100. package/src/QualifiedSignatureDocumentScreen.tsx +27 -17
  101. package/src/QualifiedSignatureLayout.css +76 -0
  102. package/src/QualifiedSignatureStrings.ts +10 -0
  103. package/src/ScreenNav.css +8 -0
  104. package/src/ScreenNav.tsx +35 -5
  105. package/src/VideoSelfieIdCaptureScreen.tsx +116 -30
  106. package/src/VideoSelfieLayout.css +164 -33
  107. package/src/VideoSelfieSelfieCaptureScreen.tsx +104 -59
  108. package/src/VideoSelfieSpeakScreen.tsx +25 -14
  109. package/src/VideoSelfieStrings.ts +85 -12
  110. package/src/VideoSelfieTurnHeadScreen.tsx +151 -0
  111. package/src/VideoSelfieTutorialScreen.tsx +34 -23
  112. package/src/VideoSelfieWaveScreen.tsx +135 -0
  113. package/src/WatchlistBusinessFormScreen.tsx +35 -16
  114. package/src/WatchlistBusinessStrings.ts +21 -7
  115. package/src/index.ts +14 -0
  116. package/src/types.ts +3 -1
@@ -48,6 +48,13 @@ export interface EmailInputVerifyScreenProps
48
48
  * paints the DISABLED surface (owner definition 2026-07-12), which would grey
49
49
  * the four resting states Figma draws white. The field is uncontrolled instead
50
50
  * (DEVIATIONS #PI-6).
51
+ *
52
+ * `emptyError` is the empty-submit state (rule from Elena, 2026-09-30,
53
+ * GUIDELINES §6-E empty-submit rule): "Continue" was pressed with nothing
54
+ * typed. The field renders empty with no placeholder, in Core's error state
55
+ * with `requiredError`, and the primary CTA is disabled, following the drawn
56
+ * precedent in `FormsFieldScreen`. The resting `default` keeps the CTA enabled
57
+ * so the user can reach this state.
51
58
  */
52
59
  export const EmailInputVerifyScreen = forwardRef<HTMLDivElement, EmailInputVerifyScreenProps>(
53
60
  (
@@ -64,6 +71,9 @@ export const EmailInputVerifyScreen = forwardRef<HTMLDivElement, EmailInputVerif
64
71
  ) => {
65
72
  const busy = state === 'loading';
66
73
  const invalid = state === 'error';
74
+ // Empty submit: the field drops its placeholder and takes Core's error
75
+ // state with the shared "required" row; the CTA greys.
76
+ const emptyError = state === 'emptyError';
67
77
  const focused = state === 'typing' || state === 'filled';
68
78
 
69
79
  return (
@@ -74,6 +84,7 @@ export const EmailInputVerifyScreen = forwardRef<HTMLDivElement, EmailInputVerif
74
84
  ctaLabel={strings.verify.ctaLabel}
75
85
  /* Figma's `loading` section swaps the CTA label for Core's spinner. */
76
86
  ctaLoading={busy}
87
+ ctaDisabled={emptyError}
77
88
  ctaLoadingLabel={strings.verify.ctaLoadingLabel}
78
89
  verifiedLabel={strings.verifiedLabel}
79
90
  navLabel={strings.navLabel}
@@ -97,13 +108,19 @@ export const EmailInputVerifyScreen = forwardRef<HTMLDivElement, EmailInputVerif
97
108
  /* Figma's field has no visible label row — the placeholder carries
98
109
  the meaning — so the copy rides `aria-label` (DEVIATIONS #PI-4). */
99
110
  aria-label={strings.verify.fieldLabel}
100
- placeholder={strings.verify.placeholder}
111
+ placeholder={emptyError ? undefined : strings.verify.placeholder}
101
112
  /* Uncontrolled: a screen is one static spec state, so no `onChange`
102
113
  is needed and no warning is raised. */
103
- defaultValue={value ?? ''}
114
+ defaultValue={emptyError ? '' : value ?? ''}
104
115
  disabled={busy}
105
- error={invalid}
106
- errorText={invalid ? strings.verify.errorText : undefined}
116
+ error={invalid || emptyError}
117
+ errorText={
118
+ invalid
119
+ ? strings.verify.errorText
120
+ : emptyError
121
+ ? strings.requiredError
122
+ : undefined
123
+ }
107
124
  /* The clear ✕ is drawn on the `error` section only. */
108
125
  clearable={invalid}
109
126
  clearLabel={strings.verify.clearLabel}
@@ -17,9 +17,9 @@ import './FaceMatchLayout.css';
17
17
  export type FaceMatchPhotoRole = 'selfie' | 'id' | 'nfc' | 'secondId';
18
18
 
19
19
  /** Host-supplied portraits, one per role the chosen variant asks for. A role
20
- * left out renders the empty state: a Brand/50 disc, marked with
21
- * `.face-match-cluster__photo--empty`. Interim treatment (2026-09-25) while
22
- * the designed no-photo state is drawn; see FaceMatchLayout.css. */
20
+ * left out renders the empty state (capture missing or not retrievable): a
21
+ * grey Surface/Neutral/100 disc under its usual label tooltip, marked with
22
+ * `.face-match-cluster__photo--empty`. See FaceMatchLayout.css. */
23
23
  export type FaceMatchComparisonPhotos = Partial<Record<FaceMatchPhotoRole, ReactNode>>;
24
24
 
25
25
  /**
@@ -57,8 +57,14 @@
57
57
 
58
58
  /* The disc's diameter, named once. `--matching`'s rosette and `--animated`'s
59
59
  * convergence both have to know it, and a second literal is a second thing to
60
- * forget. Still a design literal, still DEVIATIONS #FM-22 — just not repeated. */
61
- --face-match-photo: 160px;
60
+ * forget. Still a design literal, still DEVIATIONS #FM-22 — just not repeated.
61
+ *
62
+ * 160 at most, and smaller when two discs and their gap do not fit the
63
+ * content column: on a 320 phone the column is 272, so each disc takes
64
+ * (272 − 16) / 2 = 128 (Camila, 2026-09-28). `100cqi` is the screen shell's
65
+ * width (ScreenShell.css, `container-type: size`), less its two Spacing/24
66
+ * gutters. At 390 and up this resolves to 160, unchanged. */
67
+ --face-match-photo: min(160px, calc((100cqi - 2 * var(--prizma-spacing-24) - var(--prizma-spacing-16)) / 2));
62
68
  /* Half the distance between two neighbouring discs: what one of them has to
63
69
  * travel to reach the pair's centre line. */
64
70
  --face-match-half-step: calc((var(--face-match-photo, 160px) + var(--prizma-spacing-16)) / 2);
@@ -118,16 +124,16 @@
118
124
  background: var(--prizma-surface-neutral-100);
119
125
  }
120
126
 
121
- /* No photo supplied for this role.
127
+ /* No photo for this role: the capture was never taken, or could not be
128
+ * retrieved.
122
129
  *
123
- * INTERIM (2026-09-25, owner call): colour is the whole state for now, so the
124
- * slot reads as a portrait that belongs here rather than an undesigned hole.
125
- * Brand/50 is the same disc PrizmaIllustrationAvatar sits on, which is where
126
- * the designed state is most likely to land. The real treatment (bust,
127
- * outline, or copy) replaces this rule; the modifier and its class name stay,
128
- * so swapping it in is a one-rule change and nothing downstream moves. */
130
+ * Owner call 2026-09-28 (replaces the 2026-09-25 Brand/50 interim): a plain
131
+ * grey disc, Surface/Neutral/100, the same neutral Core puts behind an image
132
+ * that is not there yet (CaptureDigitalId.css, list.css). The callout above it
133
+ * still renders, so the tooltip names which capture is missing. The modifier
134
+ * stays a named state so a richer treatment is still a one-rule change. */
129
135
  .face-match-cluster__photo--empty {
130
- background: var(--prizma-surface-brand-50);
136
+ background: var(--prizma-surface-neutral-100);
131
137
  }
132
138
 
133
139
  .face-match-cluster__photo > img,
@@ -153,11 +159,14 @@
153
159
  }
154
160
 
155
161
  .face-match-cluster--matching .face-match-cluster__face + .face-match-cluster__face {
156
- margin-left: -44px; /* hardcoded — Figma converged overlap; no token (verified absent) */
162
+ /* Figma converged overlap, 44 on the 160 disc; no token (verified absent).
163
+ * Kept as that ratio so the rosette holds its shape on a smaller disc. */
164
+ margin-left: calc(var(--face-match-photo, 160px) * -44 / 160);
157
165
  }
158
166
 
159
167
  .face-match-cluster--matching .face-match-cluster__row + .face-match-cluster__row {
160
- margin-top: calc(-2 * var(--prizma-spacing-40));
168
+ /* Half a disc: 80 on the 160 disc (`2 × Spacing/40`), proportional below it. */
169
+ margin-top: calc(var(--face-match-photo, 160px) / -2);
161
170
  }
162
171
 
163
172
  /* ── The comparison, moving (`phase` on FaceMatchComparisonScreen) ───────────
@@ -129,6 +129,22 @@
129
129
  flex: none;
130
130
  width: calc(50% - var(--prizma-spacing-16) / 2);
131
131
  min-width: 0;
132
+ position: relative;
133
+ }
134
+ /* An error or helper line under ONE field of a pair made that field taller,
135
+ * and the bottom alignment above lifted its input above its neighbour's
136
+ * (Elena, 2026-10-02: "estos inputs tienen que estar alineados"). The line
137
+ * hangs below its input instead, out of the alignment, and the row reserves
138
+ * its height so the next row does not ride over it. */
139
+ .prizma-form-screen--responsive .prizma-form-screen__row > * > [class$="__error"],
140
+ .prizma-form-screen--responsive .prizma-form-screen__row > * > [class$="__helper"] {
141
+ position: absolute;
142
+ top: 100%;
143
+ left: 0;
144
+ margin-top: var(--prizma-spacing-8);
145
+ }
146
+ .prizma-form-screen--responsive .prizma-form-screen__row:has(> * > [class$="__error"], > * > [class$="__helper"]) {
147
+ padding-bottom: calc(var(--prizma-spacing-8) + var(--prizma-body-m-regular-line-height));
132
148
  }
133
149
 
134
150
  /* Continue and Back share a row: Back on the left, Continue on the right, as
@@ -51,7 +51,9 @@ const PRESET_ROWS: Record<FormsLongFormPreset, FormsLongFormRow[]> = {
51
51
  { key: 'idNumber', kind: 'text', requirement: 'required' },
52
52
  { key: 'email', kind: 'email', requirement: 'required' },
53
53
  { key: 'residence', kind: 'country', requirement: 'required' },
54
- { key: 'dob', kind: 'date', requirement: 'required' },
54
+ // Date of birth carries NO asterisk (owner Elena, 2026-09-30), so the
55
+ // `emptyError` state flags the other three rows and leaves it at rest.
56
+ { key: 'dob', kind: 'date', requirement: 'none' },
55
57
  ],
56
58
  };
57
59
 
@@ -131,6 +133,14 @@ export interface FormsLongFormScreenProps
131
133
  * into the CTA band — the half a module stylesheet kept losing. It is a no-op
132
134
  * on the short `name` preset, whose rows never overflow. DEVIATIONS #DSC-2,
133
135
  * restating #CURP-1.
136
+ *
137
+ * `emptyError` is the GUIDELINES §6-E empty-submit rule (Elena 2026-09-30):
138
+ * the user pressed the CTA with the form still empty. Every REQUIRED row
139
+ * renders empty, with no placeholder, in Core's own error state carrying
140
+ * `strings.requiredError`; optional and unmarked rows stay on their resting
141
+ * surface with no error. The CTA greys there and ONLY there: the resting
142
+ * `default` keeps it enabled, so the user can press it to reach the error —
143
+ * the `FormsFieldScreen` `emptyError` precedent applied to a stack.
134
144
  */
135
145
  export const FormsLongFormScreen = forwardRef<HTMLDivElement, FormsLongFormScreenProps>(
136
146
  (
@@ -152,6 +162,7 @@ export const FormsLongFormScreen = forwardRef<HTMLDivElement, FormsLongFormScree
152
162
  ) => {
153
163
  const copy = strings.longForm[preset];
154
164
  const busy = state === 'loading';
165
+ const emptyError = state === 'emptyError';
155
166
  const countryList = countries ?? [];
156
167
 
157
168
  // The Optional / Required pair documents the eKYC form specifically; no
@@ -174,8 +185,15 @@ export const FormsLongFormScreen = forwardRef<HTMLDivElement, FormsLongFormScree
174
185
  const fieldCopy = copy.fields[row.key];
175
186
  const label = labelFor(row);
176
187
  const required = row.requirement === 'required';
177
- const value = values?.[row.key] ?? '';
178
- const focused = !busy && focusedField === row.key;
188
+ // `emptyError` is an EMPTY submit: every row renders empty, whatever
189
+ // values the host passed.
190
+ const value = emptyError ? '' : (values?.[row.key] ?? '');
191
+ const focused = !busy && !emptyError && focusedField === row.key;
192
+ // Only REQUIRED rows flag the empty submit. Their placeholder goes, as
193
+ // on the drawn `forms.IDnumber.error`; optional rows stay at rest.
194
+ const invalid = emptyError && required;
195
+ const errorText = invalid ? strings.requiredError : undefined;
196
+ const placeholder = invalid ? undefined : fieldCopy?.placeholder;
179
197
 
180
198
  switch (row.kind) {
181
199
  case 'country':
@@ -185,12 +203,14 @@ export const FormsLongFormScreen = forwardRef<HTMLDivElement, FormsLongFormScree
185
203
  label={label}
186
204
  required={required}
187
205
  requiredHint={strings.requiredHint}
188
- placeholder={fieldCopy?.placeholder}
206
+ placeholder={placeholder}
189
207
  options={countryList.map((c) => ({ value: c.code, label: c.name }))}
190
208
  value={value || null}
191
209
  searchable
192
210
  searchPlaceholder={copy.searchPlaceholder}
193
211
  disabled={busy}
212
+ error={invalid}
213
+ errorText={errorText}
194
214
  forceState={focused ? 'focused' : undefined}
195
215
  renderLeading={(option) => {
196
216
  const match = countryList.find((c) => c.code === option.value);
@@ -205,10 +225,12 @@ export const FormsLongFormScreen = forwardRef<HTMLDivElement, FormsLongFormScree
205
225
  label={label}
206
226
  required={required}
207
227
  requiredHint={strings.requiredHint}
208
- placeholder={fieldCopy?.placeholder}
228
+ placeholder={placeholder}
209
229
  options={options?.[row.key] ?? []}
210
230
  value={value || null}
211
231
  disabled={busy}
232
+ error={invalid}
233
+ errorText={errorText}
212
234
  forceState={focused ? 'focused' : undefined}
213
235
  />
214
236
  );
@@ -220,9 +242,13 @@ export const FormsLongFormScreen = forwardRef<HTMLDivElement, FormsLongFormScree
220
242
  label={label}
221
243
  required={required}
222
244
  requiredHint={strings.requiredHint}
223
- placeholder={fieldCopy?.placeholder}
245
+ /* Core falls back to its DD/MM/YYYY mask on an undefined
246
+ placeholder, so the error row passes an explicit empty one. */
247
+ placeholder={invalid ? '' : fieldCopy?.placeholder}
224
248
  defaultValue={value}
225
249
  disabled={busy}
250
+ error={invalid}
251
+ errorText={errorText}
226
252
  calendarLabel={copy.calendarLabel}
227
253
  />
228
254
  );
@@ -234,7 +260,7 @@ export const FormsLongFormScreen = forwardRef<HTMLDivElement, FormsLongFormScree
234
260
  label={label}
235
261
  required={required}
236
262
  requiredHint={strings.requiredHint}
237
- placeholder={fieldCopy?.placeholder}
263
+ placeholder={placeholder}
238
264
  defaultValue={value}
239
265
  type={row.kind === 'email' ? 'email' : 'text'}
240
266
  inputMode={row.kind === 'email' ? 'email' : undefined}
@@ -242,6 +268,8 @@ export const FormsLongFormScreen = forwardRef<HTMLDivElement, FormsLongFormScree
242
268
  autoCapitalize={row.kind === 'email' ? 'none' : undefined}
243
269
  spellCheck={false}
244
270
  disabled={busy}
271
+ error={invalid}
272
+ errorText={errorText}
245
273
  /* Figma draws the clear ✕ only on the focused row of
246
274
  `longform.focused`; every other row draws none. */
247
275
  clearable={focused}
@@ -266,12 +294,12 @@ export const FormsLongFormScreen = forwardRef<HTMLDivElement, FormsLongFormScree
266
294
  title={copy.title}
267
295
  subtitle={copy.subtitle}
268
296
  ctaLabel={copy.ctaLabel}
269
- /* `longform.default` and `longform.customizable`'s default section grey
270
- the CTA until something is entered; `forms.name` draws it ENABLED on
271
- the same empty form. Figma contradicts its own "optimistic rule"
272
- sticky here, so each section is followed literally — DEVIATIONS
273
- #FDE-3. */
274
- ctaDisabled={state === 'default' && preset !== 'name'}
297
+ /* GUIDELINES §6-E empty-submit rule (Elena 2026-09-30): the CTA stays
298
+ ENABLED on the resting empty form, every preset alike, so the user
299
+ can press it; it greys only on `emptyError`, the state that press
300
+ leads to. This supersedes the per-section greying #FDE-3 followed
301
+ on `longform.default` and `longform.customizable`. */
302
+ ctaDisabled={emptyError}
275
303
  ctaLoading={busy}
276
304
  ctaLoadingLabel={copy.ctaLoadingLabel}
277
305
  /* The four- and five-row stacks are taller than the 844 frame; the
@@ -127,13 +127,18 @@ export type FormsLongFormPreset = 'name' | 'profile' | 'eKyc';
127
127
  * - `loading` — `longform.loading`: the whole form greyed, spinner in the CTA
128
128
  * - `optional` — the "Optional / Required" documentation pair: the same form
129
129
  * with mixed required and optional markers
130
+ * - `emptyError` — the user pressed the CTA with the form still empty: every
131
+ * REQUIRED row renders empty in Core's error state with
132
+ * `requiredError`, optional and unmarked rows stay clean, CTA
133
+ * greyed (GUIDELINES §6-E empty-submit rule, Elena 2026-09-30)
130
134
  */
131
135
  export type FormsLongFormState =
132
136
  | 'default'
133
137
  | 'focused'
134
138
  | 'filled'
135
139
  | 'loading'
136
- | 'optional';
140
+ | 'optional'
141
+ | 'emptyError';
137
142
 
138
143
  /** Copy for one single-field section. */
139
144
  export interface FormsFieldCopy {
@@ -218,6 +223,12 @@ export interface FormsStrings {
218
223
  * the label string by the screen. See DEVIATIONS #FDE-4.
219
224
  */
220
225
  optionalHint: string;
226
+ /**
227
+ * The error row every REQUIRED field shows on the `emptyError` long-form
228
+ * state ("This field is required"). Maps to the SDK's
229
+ * `dynamicForms.fieldRequired` key. GUIDELINES §6-E empty-submit rule.
230
+ */
231
+ requiredError: string;
221
232
 
222
233
  /** The single-field sections. */
223
234
  fields: Record<FormsFieldKey, FormsFieldCopy>;
@@ -97,9 +97,13 @@ export interface GeolocationBrowserStepsScreenProps
97
97
  * The rows are Core's `PrizmaCardListCommonIssues` verbatim; only the leading
98
98
  * glyph is chosen here, from the canonical bank (see `STEP_ICONS`).
99
99
  *
100
+ * No subtitle: the browser name (`label`) is NOT drawn under the title (owner
101
+ * call, Elena 2026-09-30). The label stays in the strings as the section's
102
+ * identifier for hosts and catalogs; the screen itself shows title + steps.
103
+ *
100
104
  * The title block is LEFT-aligned for this whole family. Every
101
- * `Geolocation.allowLocation.<browser>` section starts the two-line headline —
102
- * and the browser label under it — flush at the gutter, over left-led step
105
+ * `Geolocation.allowLocation.<browser>` section starts the two-line headline
106
+ * flush at the gutter, over left-led step
103
107
  * rows; a centred headline above a left-aligned list reads as a mistake. The
104
108
  * screen therefore DEFAULTS the archetype's own `align` override to `left`
105
109
  * rather than declaring any alignment of its own, and a host that passes
@@ -147,7 +151,6 @@ export const GeolocationBrowserStepsScreen = forwardRef<
147
151
  <PrizmaListScreen
148
152
  ref={ref}
149
153
  title={strings.browserSteps.title}
150
- subtitle={slice?.label}
151
154
  ctaLabel={strings.browserSteps.ctaLabel}
152
155
  secondaryCtaLabel={showSkip ? strings.browserSteps.skipLabel : undefined}
153
156
  verifiedLabel={strings.verifiedLabel}
@@ -123,6 +123,9 @@ export interface GeolocationBrowserSteps {
123
123
  * rows and would render as one screen three times. Every browser therefore
124
124
  * SHOULD carry a label, extending the precedent Figma itself sets on Safari.
125
125
  * Still optional so a host that ships its own logo artwork can drop it.
126
+ *
127
+ * 2026-09-30 (Elena): the screen no longer RENDERS it as a subtitle. It is
128
+ * kept as the section's name for catalogs and hosts, not as on-screen copy.
126
129
  */
127
130
  label?: string;
128
131
  /** The steps, in order. */
@@ -59,6 +59,46 @@
59
59
  gap: var(--prizma-spacing-8); /* Title → instruction, GUIDELINES §5 */
60
60
  }
61
61
 
62
+ /* ── Short screens: the vertical frame gives up height, never the title ──────
63
+ * The vertical ID (288:456) and passport (306:420) frames are tall. On a
64
+ * 320 × 693 phone the column is 272 wide, so the frame alone is ~431 tall and
65
+ * the stage ran 168px past the bottom, under the shutter strip (Camila,
66
+ * 2026-09-28). Here the frame is the ONE part of the column that shrinks: it
67
+ * starts at its full-width height (the column less its two Spacing/24
68
+ * gutters, over the aspect), and when the screen is too short the flex column
69
+ * takes the shortfall out of its height; the aspect ratio then narrows its
70
+ * width to match, so it stays an ID-shaped frame, centred. The title band
71
+ * keeps its content height. With room to spare (390 × 844) the frame never
72
+ * needs to shrink, so it sits exactly where it did.
73
+ *
74
+ * Scoped to ID + passport with `:not(--poa)`: Document Capture renders the
75
+ * same stage as `type="poa" isVertical`, so it carries `--vertical` too, but
76
+ * the PoA frame has NO aspect ratio (it fills the host's height). `width:
77
+ * auto` there collapsed it to a 0-wide sliver, a grey vertical bar down the
78
+ * camera screen (Elena, 2026-09-30). Only showed where this file and Document
79
+ * Capture's CSS ship in the same bundle, i.e. the production build. */
80
+ .prizma-viewfinder__content:has(> .prizma-id-capture--vertical:not(.prizma-id-capture--poa)) {
81
+ min-height: 0;
82
+ }
83
+ .prizma-viewfinder__content:has(> .prizma-id-capture--vertical:not(.prizma-id-capture--poa)) > .id-capture-band--top {
84
+ flex-shrink: 0;
85
+ min-height: min-content;
86
+ }
87
+ .prizma-viewfinder__content > .prizma-id-capture--vertical:not(.prizma-id-capture--poa) {
88
+ flex: 0 1 auto;
89
+ min-height: 0;
90
+ }
91
+ .prizma-viewfinder__content > .prizma-id-capture--vertical:not(.prizma-id-capture--poa) .prizma-id-capture__frame {
92
+ flex: 0 1 auto;
93
+ min-height: 0;
94
+ width: auto;
95
+ max-width: 100%;
96
+ flex-basis: calc((100cqw - 2 * var(--prizma-spacing-24)) * 456 / 288);
97
+ }
98
+ .prizma-viewfinder__content > .prizma-id-capture--passport.prizma-id-capture--vertical .prizma-id-capture__frame {
99
+ flex-basis: calc((100cqw - 2 * var(--prizma-spacing-24)) * 420 / 306);
100
+ }
101
+
62
102
  /* ── Camera stage (dark) ───────────────────────────────────────────────────── */
63
103
 
64
104
  /* The stage's nav row carries only the close X (no logo — the camera owns the
@@ -77,6 +77,8 @@ export interface PrizmaListScreenProps
77
77
  /** "verified by" attribution copy for the footer tag. */
78
78
  verifiedLabel: string;
79
79
  navLabel?: string;
80
+ /** Opt-in back chevron in the nav (its accessible name). Emits `back`. */
81
+ backLabel?: string;
80
82
  screenId?: string;
81
83
  overrides?: PrizmaScreenOverrides;
82
84
  onAction?: PrizmaScreenActionHandler;
@@ -122,6 +124,7 @@ export const PrizmaListScreen = forwardRef<HTMLDivElement, PrizmaListScreenProps
122
124
  linkLabel,
123
125
  verifiedLabel,
124
126
  navLabel,
127
+ backLabel,
125
128
  screenId,
126
129
  showHeader = true,
127
130
  showFooter = true,
@@ -132,7 +135,13 @@ export const PrizmaListScreen = forwardRef<HTMLDivElement, PrizmaListScreenProps
132
135
  ref,
133
136
  ) => (
134
137
  <PrizmaScreenShell ref={ref} {...shell}>
135
- <PrizmaScreenNav reserved={!showHeader} logo={overrides?.logo} navLabel={navLabel} />
138
+ <PrizmaScreenNav
139
+ reserved={!showHeader}
140
+ logo={overrides?.logo}
141
+ navLabel={navLabel}
142
+ backLabel={backLabel}
143
+ onBack={() => onAction?.({ type: 'back', screenId })}
144
+ />
136
145
  {progress ? (
137
146
  <div className="prizma-screen-layout__progress">{progress}</div>
138
147
  ) : null}
@@ -21,6 +21,7 @@
21
21
  align-items: flex-start;
22
22
  gap: var(--prizma-spacing-8);
23
23
  width: 100%;
24
+ position: relative; /* the country menu's containing block — see below */
24
25
  }
25
26
 
26
27
  /* The dial-code dropdown hugs its content ("🇷🇸 +381"), as Figma draws it.
@@ -47,15 +48,15 @@
47
48
  min-width: 0;
48
49
  }
49
50
 
50
- /* The open menu must NOT inherit the hugged anchor's width: Core anchors the
51
- * menu to the trigger, so once the trigger hugs "🇷🇸 +381" the option rows
52
- * truncate ("+…"). The menu keeps its own readable width — it lists country
53
- * names and dial codes, which the closed field never shows. Sized to the
54
- * content and capped at the 342 column so it never escapes the screen.
51
+ /* The open menu spans the WHOLE row (dial code + number field), as Figma's
52
+ * `Input / Combined` draws it: Core anchors the menu overlay to the trigger's
53
+ * `__anchor` (`position: relative`, Dropdown.css), so once the trigger hugs
54
+ * "🇷🇸 +381" the menu came out as narrow as the dial code. Releasing the
55
+ * anchor's positioning hands the overlay's `left: 0; right: 0` to the row
56
+ * above (`position: relative`), so the menu matches the row's width exactly.
57
+ * Core's own overlay offset (`top: 100% + Spacing/8`) is left untouched.
55
58
  * DEVIATIONS #PI-1 (same root cause as the hug).
56
59
  */
57
- .phone-input-combined__country .prizma-dropdown__menu {
58
- width: max-content;
59
- min-width: 100%;
60
- max-width: 342px;
60
+ .phone-input-combined__country .prizma-dropdown__anchor {
61
+ position: static;
61
62
  }
@@ -20,13 +20,18 @@
20
20
  * - `typing` — partial value, focused field
21
21
  * - `filled` — complete value
22
22
  * - `loading` — CTA in its loading treatment while the code is requested
23
+ * - `emptyError` — "Continue" pressed with no number typed: the phone field
24
+ * stays empty (no placeholder) in Core's error state with `requiredError`,
25
+ * and the CTA is disabled. The dial code always carries a selection, so it
26
+ * takes no error. GUIDELINES §6-E empty-submit rule (Elena, 2026-09-30).
23
27
  */
24
28
  export type PhoneInputVerifyState =
25
29
  | 'default'
26
30
  | 'countryCode'
27
31
  | 'typing'
28
32
  | 'filled'
29
- | 'loading';
33
+ | 'loading'
34
+ | 'emptyError';
30
35
 
31
36
  /**
32
37
  * The `phoneNumber.OTP.*` sections — same layout, different field state:
@@ -63,6 +68,12 @@ export interface PhoneInputStrings {
63
68
  verifiedLabel: string;
64
69
  /** Accessible name for the logo nav row. */
65
70
  navLabel?: string;
71
+ /**
72
+ * Error row under the required field when the primary CTA is pressed with it
73
+ * still empty (the verify screen's `emptyError` state): "This field is
74
+ * required". SDK key `dynamicForms.fieldRequired`.
75
+ */
76
+ requiredError: string;
66
77
 
67
78
  /** phoneNumber.verify.* */
68
79
  verify: {
@@ -55,6 +55,14 @@ const DEFAULT_COUNTRIES: PhoneInputCountry[] = [
55
55
  * primitives (`PrizmaDropdown`, `PrizmaTextField`) at the designed 56px min
56
56
  * height and `Border/Radius/small`, so the module layer contributes only the
57
57
  * row that pairs them (`PhoneInputLayout.css`) — no primitive is re-implemented.
58
+ *
59
+ * `emptyError` is the empty-submit state (rule from Elena, 2026-09-30,
60
+ * GUIDELINES §6-E empty-submit rule): "Continue" was pressed with no number
61
+ * typed. The phone field renders empty with no placeholder, in Core's error
62
+ * state with `requiredError`, and the primary CTA is disabled, following the
63
+ * drawn precedent in `FormsFieldScreen`. The dial code always carries a
64
+ * selection, so it takes no error. The resting `default` keeps the CTA enabled
65
+ * so the user can reach this state.
58
66
  */
59
67
  export const PhoneInputVerifyScreen = forwardRef<HTMLDivElement, PhoneInputVerifyScreenProps>(
60
68
  (
@@ -74,9 +82,11 @@ export const PhoneInputVerifyScreen = forwardRef<HTMLDivElement, PhoneInputVerif
74
82
  const selected = country ?? countries[0]?.value;
75
83
  const options = countries.map((c) => ({
76
84
  value: c.value,
77
- // The closed field shows flag + dial code exactly as Figma draws it
78
- // ("🇷🇸 +381"); the open menu row adds the country name.
79
- label: [c.flag, c.dialCode].filter(Boolean).join(' '),
85
+ // The open menu row reads flag + dial code + country name
86
+ // ("🇷🇸 +381 Serbia"); the closed field keeps only flag + dial code
87
+ // ("🇷🇸 +381") through Core's `fieldLabel`, exactly as Figma draws it.
88
+ label: [c.flag, c.dialCode, c.label].filter(Boolean).join(' '),
89
+ fieldLabel: [c.flag, c.dialCode].filter(Boolean).join(' '),
80
90
  }));
81
91
 
82
92
  // Figma treats the states as three distinct field treatments, not one:
@@ -88,6 +98,9 @@ export const PhoneInputVerifyScreen = forwardRef<HTMLDivElement, PhoneInputVerif
88
98
  // for none of them — it would grey the resting states too (DEVIATIONS #PI-6).
89
99
  const busy = state === 'loading';
90
100
  const focused = state === 'typing';
101
+ // Empty submit: the number field drops its placeholder and takes Core's
102
+ // error state with the shared "required" row; the CTA greys.
103
+ const emptyError = state === 'emptyError';
91
104
 
92
105
  return (
93
106
  <PrizmaFormScreen
@@ -97,6 +110,7 @@ export const PhoneInputVerifyScreen = forwardRef<HTMLDivElement, PhoneInputVerif
97
110
  ctaLabel={strings.verify.ctaLabel}
98
111
  /* Figma's `loading` section swaps the CTA label for Core's spinner. */
99
112
  ctaLoading={busy}
113
+ ctaDisabled={emptyError}
100
114
  ctaLoadingLabel={strings.verify.ctaLoadingLabel}
101
115
  verifiedLabel={strings.verifiedLabel}
102
116
  navLabel={strings.navLabel}
@@ -134,12 +148,14 @@ export const PhoneInputVerifyScreen = forwardRef<HTMLDivElement, PhoneInputVerif
134
148
  type="tel"
135
149
  inputMode="tel"
136
150
  aria-label={strings.verify.fieldLabel}
137
- placeholder={strings.verify.placeholder}
151
+ placeholder={emptyError ? undefined : strings.verify.placeholder}
138
152
  /* Uncontrolled: a screen is one static spec state, and `value`
139
153
  without `onChange` would either warn or force `readOnly`, whose
140
154
  disabled surface is exactly the grey Figma does NOT draw here. */
141
- defaultValue={value ?? ''}
155
+ defaultValue={emptyError ? '' : value ?? ''}
142
156
  disabled={busy}
157
+ error={emptyError}
158
+ errorText={emptyError ? strings.requiredError : undefined}
143
159
  /* Figma draws the clear ✕ only while the field is focused. */
144
160
  clearable={focused}
145
161
  />
@@ -118,6 +118,7 @@ export const QualifiedSignatureAcceptScreen = forwardRef<
118
118
  // `loading` makes the whole form inert.
119
119
  const busy = state === 'loading';
120
120
  const ticked = state !== 'default';
121
+ const requiredHint = strings.accept.requiredHint;
121
122
 
122
123
  return (
123
124
  <PrizmaFormScreen
@@ -177,6 +178,12 @@ export const QualifiedSignatureAcceptScreen = forwardRef<
177
178
  role="group"
178
179
  aria-label={strings.accept.termsLabel}
179
180
  >
181
+ {/* The legend LEADS the group (Elena, 2026-09-30, picked from four
182
+ placements): it sits where the markers are and, on the five-clause
183
+ reading that scrolls, is still on screen on arrival. */}
184
+ {requiredHint && strings.accept.requiredLegend ? (
185
+ <p className="qualified-signature-terms__legend">{strings.accept.requiredLegend}</p>
186
+ ) : null}
180
187
  {terms.map((term) => (
181
188
  <PrizmaCheckbox
182
189
  key={term.id}
@@ -185,7 +192,11 @@ export const QualifiedSignatureAcceptScreen = forwardRef<
185
192
  value={term.id}
186
193
  checked={ticked}
187
194
  disabled={busy}
188
- segments={term.segments}
195
+ required={Boolean(requiredHint)}
196
+ /* The `*` rides the END of the clause copy, the Data Sharing
197
+ Consent idiom — the L card paints no marker of its own beside
198
+ the box (owner, 2026-09-02). */
199
+ segments={requiredHint ? [...term.segments, ` ${requiredHint}`] : term.segments}
189
200
  />
190
201
  ))}
191
202
  </div>