@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
@@ -60,11 +60,13 @@ export interface QualifiedSignatureDocumentScreenProps
60
60
  * overflow constraint, declaring no spacing or size, the same idiom as CURP's
61
61
  * taller-than-844 form (DEVIATIONS #CURP-1).
62
62
  *
63
- * THREE deltas from the frames, all reported rather than worked around:
64
- * 1. Both sections draw a back chevron at the nav's left (and `terms` replaces
65
- * the logo row with a chevron + heading bar). GUIDELINES §3 stands: SDK
66
- * screens carry no dismiss or back affordance — the standing ruling of
67
- * DEVIATIONS #PI-9, which is not re-litigated per module.
63
+ * THREE deltas from the frames:
64
+ * 1. Both sections draw a back chevron at the nav's left. It is BUILT (owner
65
+ * call 2026-09-29): these are viewers the user opened from the accept
66
+ * screen, so they need a way back. It is Core's chevron through the opt-in
67
+ * `PrizmaScreenNav.backLabel`, emitting `back`; the logo stays centred.
68
+ * `terms` replacing the logo row with a chevron + heading bar is still not
69
+ * followed — the title stays the archetype's H2 below the nav.
68
70
  * 2. `viewdoc` draws NO heading. GUIDELINES §4 is explicit that every screen has
69
71
  * exactly one H2, and archetype G has no title-less path, so the screen
70
72
  * carries one and the Figma frame is the side to change. (An archetype
@@ -73,10 +75,12 @@ export interface QualifiedSignatureDocumentScreenProps
73
75
  * for attribution chrome (#PI-8, #F-30, #F-34), so both variants keep the
74
76
  * header and the footer and nothing shifts between states.
75
77
  *
76
- * `terms` is LEFT-aligned, as Figma draws it: a centred heading over a
77
- * left-aligned wall of legal text reads as a mistake. The alignment rides the
78
- * sanctioned `overrides.align` path (types.ts) — nothing is re-styled here — and
79
- * an explicit host override still wins. The #GEO-2 precedent.
78
+ * Both variants frame the page in the box Electronic Signature's review screen
79
+ * uses, so the title is CENTRED on both, like that screen (owner call
80
+ * 2026-09-29, "take reference from other screens"). Figma's `QSign.T&C` draws
81
+ * the title left over an unframed wall of text; once the text sits in its own
82
+ * left-aligned frame the centred title no longer reads as a mistake. An
83
+ * explicit `overrides.align` from the host still wins.
80
84
  */
81
85
  export const QualifiedSignatureDocumentScreen = forwardRef<
82
86
  HTMLDivElement,
@@ -96,12 +100,6 @@ export const QualifiedSignatureDocumentScreen = forwardRef<
96
100
  ) => {
97
101
  const copy = variant === 'terms' ? strings.document.terms : strings.document.document;
98
102
 
99
- // The legal wall is left-aligned in Figma; the contract page keeps the
100
- // archetype's centred default. An explicit `overrides.align` still wins.
101
- const alignedOverrides: PrizmaScreenOverrides = {
102
- ...overrides,
103
- align: overrides?.align ?? (variant === 'terms' ? 'left' : 'centered'),
104
- };
105
103
 
106
104
  return (
107
105
  <PrizmaListScreen
@@ -109,12 +107,24 @@ export const QualifiedSignatureDocumentScreen = forwardRef<
109
107
  title={copy.title}
110
108
  verifiedLabel={strings.verifiedLabel}
111
109
  navLabel={strings.navLabel}
110
+ backLabel={strings.document.backLabel}
112
111
  screenId={screenId}
113
- overrides={alignedOverrides}
112
+ overrides={overrides}
114
113
  onAction={onAction}
115
114
  {...shell}
116
115
  >
117
- <div className="qualified-signature-document">{body}</div>
116
+ <div
117
+ className={[
118
+ 'qualified-signature-document',
119
+ 'qualified-signature-document--framed',
120
+ `qualified-signature-document--${variant}`,
121
+ ].join(' ')}
122
+ role="region"
123
+ aria-label={copy.title}
124
+ tabIndex={0}
125
+ >
126
+ {body}
127
+ </div>
118
128
  </PrizmaListScreen>
119
129
  );
120
130
  },
@@ -57,6 +57,26 @@
57
57
  display: flex;
58
58
  flex-direction: column;
59
59
  gap: var(--prizma-spacing-4);
60
+ /* The clauses sit at the BOTTOM of the column, on the primary button, not
61
+ * under the document row (Elena, 2026-09-30). The fields are a flex column,
62
+ * so the auto margin takes the free space; when the clauses outgrow the frame
63
+ * (the five-clause reading) there is none and the stack scrolls as before. */
64
+ margin-top: auto;
65
+ }
66
+
67
+ /* The required legend leading the group. Core ships no caption block outside a
68
+ * field, so the line binds the clause copy's own Size-S tokens in the secondary
69
+ * ink — no new size, no new colour. Spacing/8 more below it than the clauses
70
+ * keep between themselves, so it reads as the group's header. */
71
+ .qualified-signature-terms__legend {
72
+ margin: 0 0 var(--prizma-spacing-8);
73
+ font-family: var(--prizma-checkbox-radio-family);
74
+ font-weight: var(--prizma-checkbox-radio-s-font-weight);
75
+ font-size: var(--prizma-checkbox-radio-s-font-size);
76
+ line-height: var(--prizma-checkbox-radio-s-line-height);
77
+ letter-spacing: var(--prizma-checkbox-radio-s-letter-spacing);
78
+ color: var(--prizma-text-body-500-secondary);
79
+ text-align: left;
60
80
  }
61
81
 
62
82
  /* ── (c) Uploaded-file preview card ──────────────────────────────────────────
@@ -181,6 +201,62 @@
181
201
  overscroll-behavior: contain;
182
202
  }
183
203
 
204
+ /* The document FRAME — the two full-surface document screens draw the page
205
+ * inside the same box Electronic Signature's review screen uses
206
+ * (`.electronic-signature-document`): `Border/Neutral/100` hairline,
207
+ * `Border/Radius/small`, `Surface/Neutral/0`, clipped so the page scrolls INSIDE
208
+ * it. Without it the contract image floated loose on the screen and the legal
209
+ * text ran edge to edge as one grey wall (owner-reported 2026-09-29: "take
210
+ * reference from other screens"). A modifier, not the base class, because the
211
+ * gated-form exploration reuses `.qualified-signature-document` inside its own
212
+ * popup, which already carries a frame.
213
+ *
214
+ * Spacing/16 bottom margin: the gap Electronic Signature's frame keeps above the
215
+ * CTA band, here kept above the anchored tag so the frame never touches it.
216
+ */
217
+ .qualified-signature-document--framed {
218
+ box-sizing: border-box;
219
+ background-color: var(--prizma-surface-neutral-0);
220
+ border: var(--prizma-border-width-regular) solid var(--prizma-border-neutral-100);
221
+ border-radius: var(--prizma-border-radius-small);
222
+ margin-bottom: var(--prizma-spacing-16);
223
+ }
224
+
225
+ /* `terms` — a TEXT agreement needs a gutter inside the frame (Spacing/16) and
226
+ * the paragraph rhythm Electronic Signature uses (Spacing/12 between blocks). */
227
+ .qualified-signature-document--terms {
228
+ padding: var(--prizma-spacing-16);
229
+ text-align: start;
230
+ }
231
+
232
+ .qualified-signature-document--terms > * {
233
+ margin: 0;
234
+ }
235
+
236
+ .qualified-signature-document.qualified-signature-document--terms > * + * {
237
+ /* Doubled class: `__prose` resets `margin: 0` later in this file at the
238
+ same specificity, which would otherwise swallow the rhythm. */
239
+ margin-top: var(--prizma-spacing-12);
240
+ }
241
+
242
+ /* Section headings inside the form — Headline/H5 on Text/Body/800, the same
243
+ * pair as `.electronic-signature-document h4`. */
244
+ .qualified-signature-document--terms h4 {
245
+ font-family: var(--prizma-headline-family);
246
+ font-weight: var(--prizma-headline-h5-font-weight);
247
+ font-size: var(--prizma-headline-h5-font-size);
248
+ line-height: var(--prizma-headline-h5-line-height);
249
+ letter-spacing: var(--prizma-headline-h5-letter-spacing);
250
+ color: var(--prizma-text-body-800-primary);
251
+ }
252
+
253
+ /* Inside the frame the prose drops to Text/Body/500, the secondary ink every
254
+ * block of Core prose uses (`CardList.css`, `Card.css`) — the heading carries
255
+ * the hierarchy, as in Electronic Signature. */
256
+ .qualified-signature-document--terms .qualified-signature-document__prose {
257
+ color: var(--prizma-text-body-500-secondary);
258
+ }
259
+
184
260
  /* The contract page is host-supplied media (an <img> / <canvas> of the rendered
185
261
  * PDF, GUIDELINES §10's `cameraSurface` idiom) — it fills the column width and
186
262
  * keeps its own aspect. */
@@ -169,6 +169,12 @@ export interface QualifiedSignatureStrings {
169
169
  viewLabel: string;
170
170
  /** Accessible name for the consent stack. */
171
171
  termsLabel?: string;
172
+ /** Suffix appended to every clause, all of them required ("*"). Localizable,
173
+ * never baked into the clause copy — Data Sharing Consent's `requiredHint`. */
174
+ requiredHint?: string;
175
+ /** The line that leads the clause group and explains the marker ("All terms
176
+ * marked with * are required."). Only drawn alongside `requiredHint`. */
177
+ requiredLegend?: string;
172
178
  /** The generic five-clause reading. */
173
179
  accept: QualifiedSignatureAcceptCopy;
174
180
  /** The partner two-clause reading. */
@@ -186,6 +192,10 @@ export interface QualifiedSignatureStrings {
186
192
  document: { title: string };
187
193
  /** QSign.T&C ("Certificate Application Form"), left-aligned in Figma. */
188
194
  terms: { title: string };
195
+ /** Accessible name of the nav's back chevron on both document screens
196
+ * ("Back") — they are opened from the accept screen, so they carry a way
197
+ * back to it. Emits `back`. Omit to render the logo-only nav. */
198
+ backLabel?: string;
189
199
  };
190
200
 
191
201
  /** The two in-flight screens. */
package/src/ScreenNav.css CHANGED
@@ -28,6 +28,14 @@
28
28
  width: 100%;
29
29
  }
30
30
 
31
+ /* With the opt-in back chevron the nav sits on the content column's 24px
32
+ * gutter, so the chevron lines up with the title and content below it. Core's
33
+ * nav already pads its row by Spacing/2, hence the subtraction. A logo-only nav
34
+ * is centred and needs no gutter, so nothing else moves. */
35
+ .prizma-screen-nav--back {
36
+ padding-inline: calc(var(--prizma-spacing-24) - var(--prizma-spacing-2));
37
+ }
38
+
31
39
  .prizma-screen-nav__custom-logo {
32
40
  display: flex;
33
41
  align-items: center;
package/src/ScreenNav.tsx CHANGED
@@ -1,4 +1,9 @@
1
- import React, { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';
1
+ import React, {
2
+ forwardRef,
3
+ type ComponentPropsWithoutRef,
4
+ type MouseEventHandler,
5
+ type ReactNode,
6
+ } from 'react';
2
7
  import { PrizmaNavigation } from '@incodetech/prizma-core-web/navigation';
3
8
  import './ScreenNav.css';
4
9
 
@@ -18,25 +23,50 @@ export interface PrizmaScreenNavProps extends ComponentPropsWithoutRef<'header'>
18
23
  * only the logo disappears, and the row leaves the a11y tree.
19
24
  */
20
25
  reserved?: boolean;
26
+ /**
27
+ * Opt-in back chevron at the nav's LEFT — Core's own
28
+ * (`PrizmaNavigation.showLeftIcon`, IconChevronLeft). Passing the label turns
29
+ * it on; it is the button's accessible name (i18n). Off by default, so every
30
+ * existing screen keeps the logo-only nav. For a screen the user OPENED from
31
+ * another one (Qualified Signature's document viewers, reached from "View"
32
+ * and from the application-form link) — a way back to where they came from,
33
+ * not a dismiss of the flow, which stays out (#PI-9). Owner call 2026-09-29.
34
+ */
35
+ backLabel?: string;
36
+ /** Click handler for the back chevron. */
37
+ onBack?: MouseEventHandler<HTMLButtonElement>;
21
38
  }
22
39
 
23
40
  /**
24
41
  * ScreenNav — the logo header of every SDK screen.
25
42
  * Sits 12px below the top of the shell (the host frame owns the OS status
26
- * bar), logo centered, no back/close buttons (GUIDELINES.md §3).
43
+ * bar), logo centered, no close button (GUIDELINES.md §3). A back chevron is
44
+ * opt-in through `backLabel`, for opened sub-screens only.
27
45
  */
28
46
  export const PrizmaScreenNav = forwardRef<HTMLElement, PrizmaScreenNavProps>(
29
- ({ logo, navLabel, reserved = false, className, ...rest }, ref) => (
47
+ ({ logo, navLabel, reserved = false, backLabel, onBack, className, ...rest }, ref) => (
30
48
  <header
31
49
  ref={ref}
32
50
  aria-hidden={reserved || undefined}
33
- className={['prizma-screen-nav', className ?? ''].filter(Boolean).join(' ')}
51
+ className={[
52
+ 'prizma-screen-nav',
53
+ backLabel && !reserved ? 'prizma-screen-nav--back' : '',
54
+ className ?? '',
55
+ ]
56
+ .filter(Boolean)
57
+ .join(' ')}
34
58
  {...rest}
35
59
  >
36
60
  {reserved ? null : logo ? (
37
61
  <span className="prizma-screen-nav__custom-logo">{logo}</span>
38
62
  ) : (
39
- <PrizmaNavigation showIncodeLogo aria-label={navLabel} />
63
+ <PrizmaNavigation
64
+ showIncodeLogo
65
+ aria-label={navLabel}
66
+ showLeftIcon={Boolean(backLabel)}
67
+ backLabel={backLabel}
68
+ onLeftIconClick={onBack}
69
+ />
40
70
  )}
41
71
  </header>
42
72
  ),
@@ -3,15 +3,18 @@ import {
3
3
  PrizmaIdCapture,
4
4
  type PrizmaIdCaptureState,
5
5
  } from '@incodetech/prizma-core-web/id-capture';
6
+ import { PrizmaCaptureFeedback } from '@incodetech/prizma-core-web/capture-feedback';
6
7
  import { PrizmaLoadingSpinner } from '@incodetech/prizma-core-web/loading-spinner';
7
- import { PrizmaSnackbar } from '@incodetech/prizma-core-web/snackbar';
8
8
  import { PrizmaStepper } from '@incodetech/prizma-core-web/stepper';
9
9
  import { PrizmaTag } from '@incodetech/prizma-core-web/tag';
10
+ import { PrizmaTitle } from '@incodetech/prizma-core-web/title';
10
11
  import { PrizmaScreenShell, type PrizmaScreenShellProps } from './ScreenShell';
11
12
  import type { PrizmaScreenActionHandler, PrizmaScreenOverrides } from './types';
12
13
  import type {
13
14
  VideoSelfieDocumentType,
14
- VideoSelfieIdErrorSeverity,
15
+ VideoSelfieIdCondition,
16
+ VideoSelfieIdErrorReason,
17
+ VideoSelfieIdErrorSeverity,
15
18
  VideoSelfieIdSide,
16
19
  VideoSelfieStrings,
17
20
  } from './VideoSelfieStrings';
@@ -19,13 +22,52 @@ import './ScreenLayout.css';
19
22
  import './ViewfinderScreen.css';
20
23
  import './VideoSelfieLayout.css';
21
24
 
22
- /** Which purple stage section to render (groups 3, 5 and 6). */
25
+ /** Which purple stage section to render (groups 3, 5 and 6), plus the live
26
+ * coaching beat the full-flow frames draw between them. */
23
27
  export type VideoSelfieIdCaptureState =
24
28
  | 'default'
25
29
  | 'shown'
26
30
  | 'processing'
27
31
  | 'allGood'
28
- | 'error';
32
+ | 'error'
33
+ | 'condition';
34
+
35
+ /**
36
+ * Condition → the Core treatment that already exists for it.
37
+ *
38
+ * Four are `PrizmaIdCapture`'s own error states. `blurry` is the plain error
39
+ * border: blur is a DETECTION result, not a framing mistake, and the owner's
40
+ * 2026-07-28 call is that those pair the plain border with the feedback
41
+ * bubble rather than inventing a semantic overlay (`IdCapture.tsx`, the note
42
+ * above `error`). Either way the message is the bubble's, never the frame's.
43
+ */
44
+ const CONDITION_STATE: Record<VideoSelfieIdCondition, PrizmaIdCaptureState> = {
45
+ wrongSide: 'error-wrong-side',
46
+ notAligned: 'error-not-aligned',
47
+ glare: 'error-glare',
48
+ tooDark: 'error-too-dark',
49
+ blurry: 'error',
50
+ };
51
+
52
+ /** Condition → the bubble's own variant. All six of Core's are used: the
53
+ * wrong-side bubble names the face the user SHOULD be showing, so it flips
54
+ * with the leg — `show-front` on the front, `show-back` on the back. */
55
+ const CONDITION_FEEDBACK: Record<
56
+ VideoSelfieIdCondition,
57
+ 'not-aligned' | 'blurry' | 'glare' | 'too-dark'
58
+ > = {
59
+ wrongSide: 'not-aligned', // unused — the wrong-side variant is side-keyed below
60
+ notAligned: 'not-aligned',
61
+ blurry: 'blurry',
62
+ glare: 'glare',
63
+ tooDark: 'too-dark',
64
+ };
65
+
66
+ /** The wrong-side bubble, per leg. */
67
+ const WRONG_SIDE_FEEDBACK: Record<VideoSelfieIdSide, 'show-front' | 'show-back'> = {
68
+ front: 'show-front',
69
+ back: 'show-back',
70
+ };
29
71
 
30
72
  /** Purple stage state → Core `PrizmaIdCapture` state. The frame border, the
31
73
  * analyzing sweep, the scrim and the encrypted row are ALL Core's. */
@@ -35,6 +77,8 @@ const CAPTURE_STATE: Record<VideoSelfieIdCaptureState, PrizmaIdCaptureState> = {
35
77
  processing: 'analyzing',
36
78
  allGood: 'success',
37
79
  error: 'error',
80
+ // Replaced per-condition below — the map needs an entry for every key.
81
+ condition: 'error',
38
82
  };
39
83
 
40
84
  /** Which beat of the seven-step journey each side occupies — front is step 2
@@ -56,6 +100,12 @@ export interface VideoSelfieIdCaptureScreenProps
56
100
  /** Whether the rejection invites a retry (`6.1`/`6.3`) or is terminal
57
101
  * (`6.2`/`6.4`). Read only when `state="error"`. */
58
102
  errorSeverity?: VideoSelfieIdErrorSeverity;
103
+ /** WHY the read was rejected — the `.1`–`.4` suffix of the group 6 section.
104
+ * Read only when `state="error"`. Default `scan`. */
105
+ errorReason?: VideoSelfieIdErrorReason;
106
+ /** Which live condition to coach the user out of. Read only when
107
+ * `state="condition"`. Default `notAligned`. */
108
+ condition?: VideoSelfieIdCondition;
59
109
  /** Live camera feed (<video>) or sample still (<img>) behind the frame. */
60
110
  camera?: ReactNode;
61
111
  /** The captured document still, for the states Core draws one in. */
@@ -118,6 +168,8 @@ export const VideoSelfieIdCaptureScreen = forwardRef<
118
168
  documentType = 'id',
119
169
  state = 'default',
120
170
  errorSeverity = 'tryAgain',
171
+ errorReason = 'scan',
172
+ condition = 'notAligned',
121
173
  camera,
122
174
  still,
123
175
  screenId = `video-selfie-id-capture-${side}-${state}`,
@@ -128,7 +180,15 @@ export const VideoSelfieIdCaptureScreen = forwardRef<
128
180
  },
129
181
  ref,
130
182
  ) => {
131
- const errorLabel = strings.idCapture.errors[errorSeverity];
183
+ const errorLabel = strings.idCapture.errors[errorReason][errorSeverity];
184
+ /* Only the wrong-side bubble is keyed by side — it is the one that names
185
+ the face to show. The other four describe the shot. */
186
+ const conditionCopy =
187
+ condition === 'wrongSide'
188
+ ? strings.idCapture.conditions.wrongSide[side]
189
+ : strings.idCapture.conditions[condition];
190
+ const conditionFeedback =
191
+ condition === 'wrongSide' ? WRONG_SIDE_FEEDBACK[side] : CONDITION_FEEDBACK[condition];
132
192
 
133
193
  return (
134
194
  <PrizmaScreenShell ref={ref} dark className={className} {...shell}>
@@ -149,50 +209,76 @@ export const VideoSelfieIdCaptureScreen = forwardRef<
149
209
  .filter(Boolean)
150
210
  .join(' ')}
151
211
  >
152
- <div className="video-selfie-band" />
212
+ {/* The frame is CENTRED in the air between the stepper and the tag,
213
+ not bottom-anchored: Figma seats `ID Capture Frame` at y 172–627
214
+ of a 91–708 content area, dead centre. One flexible band either
215
+ side is the whole geometry — no offset is declared. */}
216
+ {/* The coaching bubble sits in the band ABOVE the frame, which is
217
+ where the five condition frames draw it — Core's own
218
+ `PrizmaCaptureFeedback`, the same piece ID Capture's stage uses
219
+ for the same job (`IdCaptureStageScreen`). The frame underneath
220
+ takes the matching red treatment, so the two say one thing:
221
+ what is wrong, and what to do about it. */}
222
+ <div className="video-selfie-band">
223
+ {state === 'condition' ? (
224
+ <div className="video-selfie-feedback">
225
+ <PrizmaCaptureFeedback
226
+ feedback={conditionFeedback}
227
+ title={conditionCopy.title}
228
+ subtitle={conditionCopy.subtitle}
229
+ />
230
+ </div>
231
+ ) : null}
232
+ </div>
153
233
  <PrizmaIdCapture
234
+ className="video-selfie-stage"
154
235
  type={documentType}
155
- state={CAPTURE_STATE[state]}
236
+ state={state === 'condition' ? CONDITION_STATE[condition] : CAPTURE_STATE[state]}
156
237
  isVertical
157
238
  mode="overlay"
158
239
  scrim
159
240
  showEncryptedLabel={false}
160
241
  still={still}
161
242
  />
243
+ <div className="video-selfie-band" />
162
244
  {/* Centred ON the frame, not below it — `3.2`/`5.2` put the spinner
163
245
  and its caption over the document (DEVIATIONS #VS-7). */}
164
246
  {state === 'processing' ? (
165
247
  <div className="video-selfie-stage-status">
166
- <PrizmaLoadingSpinner
167
- showTitle
168
- titleText={strings.idCapture.processingLabel}
169
- loadingLabel={strings.idCapture.processingLabel}
170
- />
248
+ <div className="video-selfie-stage-status__card">
249
+ <PrizmaLoadingSpinner
250
+ showTitle
251
+ titleText={strings.idCapture.processingLabel}
252
+ loadingLabel={strings.idCapture.processingLabel}
253
+ />
254
+ </div>
171
255
  </div>
172
256
  ) : null}
173
257
  {/* `3.3`/`5.3` and every `6.x` draw the verdict in the SAME dark card,
174
258
  centred on the document, as `3.2`/`5.2` draw the spinner — three
175
259
  identical Figma treatments had become two different ones once the
176
- spinner moved (owner QA 2026-08-19). DEVIATIONS #VS-13. */}
260
+ spinner moved (owner QA 2026-08-19). DEVIATIONS #VS-13.
261
+
262
+ The card's CONTENT is Core's `PrizmaTitle` with its status icon,
263
+ not a Snackbar: Figma stacks the mark ABOVE the label, centred,
264
+ with the label free to wrap onto two or three lines ("ID photo
265
+ verification failed, try again"). A Snackbar is a one-line ROW —
266
+ icon beside text — so it printed the copy across the full column
267
+ and had to be told, by a prop, whether its own text would wrap
268
+ (#VS-9). The stacked status block is what Core already ships for
269
+ this shape, and it is the same piece the liveness ring uses on
270
+ the same beat. DEVIATIONS #VS-19. */}
177
271
  {state === 'allGood' || state === 'error' ? (
178
272
  <div className="video-selfie-stage-status">
179
- {state === 'allGood' ? (
180
- <PrizmaSnackbar state="success" role="status">
181
- {strings.idCapture.successLabel}
182
- </PrizmaSnackbar>
183
- ) : (
184
- /* NOT `isMultiline`: that modifier switches the row to
185
- `align-items: flex-start` so an icon anchors to the top of
186
- WRAPPED text. The rejection copy renders on ONE line, so it
187
- only pushed the icon 6.9px below the text centre
188
- (owner-reported 2026-08-19: "the text is not even aligned to
189
- the icon"). CORE REQUEST: have the Snackbar follow actual
190
- wrapping rather than a prop the caller has to predict.
191
- DEVIATIONS #VS-9. */
192
- <PrizmaSnackbar state="error" role="alert">
193
- {errorLabel}
194
- </PrizmaSnackbar>
195
- )}
273
+ <div
274
+ className="video-selfie-stage-status__card"
275
+ role={state === 'error' ? 'alert' : 'status'}
276
+ >
277
+ <PrizmaTitle
278
+ title={state === 'allGood' ? strings.idCapture.successLabel : errorLabel}
279
+ icon={{ type: 'status', status: state === 'allGood' ? 'success' : 'error' }}
280
+ />
281
+ </div>
196
282
  </div>
197
283
  ) : null}
198
284
  </div>