feng-x commited on
Commit
9cd8808
Β·
verified Β·
1 Parent(s): f21037d

Upload folder using huggingface_hub

Browse files
AGENTS.md CHANGED
@@ -21,8 +21,10 @@ For tasks of **reboot** from a new codex session:
21
  1. Read doc/v0/PRD.md, doc/v0/Plan.md, doc/v0/Progress.md for baseline implementation
22
  2. Read doc/v1/PRD.md, doc/v1/Plan.md, doc/v1/Progress.md for edge refinement (v1)
23
  3. Read doc/v4/PRD.md, doc/v4/Plan.md, doc/v4/Progress.md for SAM 2.1 integration (card + hand)
24
- 4. Assume this is a continuation of an existing project.
25
- 5. Summarize your understanding of the current state and propose the next concrete step without writing code yet.
 
 
26
 
27
  ## Project Overview
28
 
@@ -175,6 +177,23 @@ For outer fingers the ROI is shrunk and rotation is centered on the proximal pha
175
  ### Environment flags
176
 
177
  - `RING_DISABLE_SUPABASE=1` β€” opt out of Supabase persistence for local dev runs (the web demo otherwise persists each measurement off the request thread).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
 
179
  ---
180
 
 
21
  1. Read doc/v0/PRD.md, doc/v0/Plan.md, doc/v0/Progress.md for baseline implementation
22
  2. Read doc/v1/PRD.md, doc/v1/Plan.md, doc/v1/Progress.md for edge refinement (v1)
23
  3. Read doc/v4/PRD.md, doc/v4/Plan.md, doc/v4/Progress.md for SAM 2.1 integration (card + hand)
24
+ 4. Read doc/v5/PRD.md, doc/v5/Plan.md, doc/v5/Progress.md for the in-browser capture coach (distance + level gates)
25
+ 5. Read doc/v6/PRD.md, doc/v6/Plan.md, doc/v6/Progress.md for the mobile-native demo surface (`/m`)
26
+ 6. Assume this is a continuation of an existing project.
27
+ 7. Summarize your understanding of the current state and propose the next concrete step without writing code yet.
28
 
29
  ## Project Overview
30
 
 
177
  ### Environment flags
178
 
179
  - `RING_DISABLE_SUPABASE=1` β€” opt out of Supabase persistence for local dev runs (the web demo otherwise persists each measurement off the request thread).
180
+ - `RING_DEV_TLS_CERT` / `RING_DEV_TLS_KEY` β€” paths to a local mkcert pair. When both are set, `web_demo/app.py` runs Flask under HTTPS on `0.0.0.0:8000` so a phone on the same LAN can hit the dev server (`getUserMedia` requires a secure context). Unset on HF Space deploys (TLS terminates upstream).
181
+
182
+ ---
183
+
184
+ ## Web demo surfaces
185
+
186
+ `web_demo/app.py` serves two front-end shells off the same Flask app:
187
+
188
+ | Route | Template | Notes |
189
+ |---|---|---|
190
+ | `/` (desktop UA) | `templates/index.html` | Original single-page form + result demo. |
191
+ | `/` (mobile UA) | 302 β†’ `/m` | Coarse UA regex (`iphone|ipod|android.+mobile|...`); `?desktop=1` overrides. |
192
+ | `/m` | `templates/mobile.html` | v6 paginated six-step flow (intro β†’ form β†’ guide β†’ capture β†’ confirm β†’ result). |
193
+ | `/dev`, `/debug` | `templates/index.html` | Desktop with `dev_mode=True` (AI-explanation toggle visible). |
194
+ | `/admin` | `templates/admin.html` | Token-gated KOL dashboard. |
195
+
196
+ `/api/measure` is the single contract both surfaces speak. Any algorithm change in `measure_finger.py` improves both surfaces with zero front-end work. See `doc/v5/` for the in-browser capture coach (distance + level gates) and `doc/v6/` for the mobile flow.
197
 
198
  ---
199
 
CLAUDE.md CHANGED
@@ -21,8 +21,10 @@ For tasks of **reboot** from a new codex session:
21
  1. Read doc/v0/PRD.md, doc/v0/Plan.md, doc/v0/Progress.md for baseline implementation
22
  2. Read doc/v1/PRD.md, doc/v1/Plan.md, doc/v1/Progress.md for edge refinement (v1)
23
  3. Read doc/v4/PRD.md, doc/v4/Plan.md, doc/v4/Progress.md for SAM 2.1 integration (card + hand)
24
- 4. Assume this is a continuation of an existing project.
25
- 5. Summarize your understanding of the current state and propose the next concrete step without writing code yet.
 
 
26
 
27
  ## Project Overview
28
 
@@ -175,6 +177,23 @@ For outer fingers the ROI is shrunk and rotation is centered on the proximal pha
175
  ### Environment flags
176
 
177
  - `RING_DISABLE_SUPABASE=1` β€” opt out of Supabase persistence for local dev runs (the web demo otherwise persists each measurement off the request thread).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
 
179
  ---
180
 
 
21
  1. Read doc/v0/PRD.md, doc/v0/Plan.md, doc/v0/Progress.md for baseline implementation
22
  2. Read doc/v1/PRD.md, doc/v1/Plan.md, doc/v1/Progress.md for edge refinement (v1)
23
  3. Read doc/v4/PRD.md, doc/v4/Plan.md, doc/v4/Progress.md for SAM 2.1 integration (card + hand)
24
+ 4. Read doc/v5/PRD.md, doc/v5/Plan.md, doc/v5/Progress.md for the in-browser capture coach (distance + level gates)
25
+ 5. Read doc/v6/PRD.md, doc/v6/Plan.md, doc/v6/Progress.md for the mobile-native demo surface (`/m`)
26
+ 6. Assume this is a continuation of an existing project.
27
+ 7. Summarize your understanding of the current state and propose the next concrete step without writing code yet.
28
 
29
  ## Project Overview
30
 
 
177
  ### Environment flags
178
 
179
  - `RING_DISABLE_SUPABASE=1` β€” opt out of Supabase persistence for local dev runs (the web demo otherwise persists each measurement off the request thread).
180
+ - `RING_DEV_TLS_CERT` / `RING_DEV_TLS_KEY` β€” paths to a local mkcert pair. When both are set, `web_demo/app.py` runs Flask under HTTPS on `0.0.0.0:8000` so a phone on the same LAN can hit the dev server (`getUserMedia` requires a secure context). Unset on HF Space deploys (TLS terminates upstream).
181
+
182
+ ---
183
+
184
+ ## Web demo surfaces
185
+
186
+ `web_demo/app.py` serves two front-end shells off the same Flask app:
187
+
188
+ | Route | Template | Notes |
189
+ |---|---|---|
190
+ | `/` (desktop UA) | `templates/index.html` | Original single-page form + result demo. |
191
+ | `/` (mobile UA) | 302 β†’ `/m` | Coarse UA regex (`iphone|ipod|android.+mobile|...`); `?desktop=1` overrides. |
192
+ | `/m` | `templates/mobile.html` | v6 paginated six-step flow (intro β†’ form β†’ guide β†’ capture β†’ confirm β†’ result). |
193
+ | `/dev`, `/debug` | `templates/index.html` | Desktop with `dev_mode=True` (AI-explanation toggle visible). |
194
+ | `/admin` | `templates/admin.html` | Token-gated KOL dashboard. |
195
+
196
+ `/api/measure` is the single contract both surfaces speak. Any algorithm change in `measure_finger.py` improves both surfaces with zero front-end work. See `doc/v5/` for the in-browser capture coach (distance + level gates) and `doc/v6/` for the mobile flow.
197
 
198
  ---
199
 
measure_finger.py CHANGED
@@ -39,6 +39,7 @@ from src.confidence import (
39
  )
40
  from src.debug_observer import draw_comprehensive_edge_overlay, draw_hand_skeleton
41
  from src.visualization import draw_card_overlay
 
42
  from src.ring_size import recommend_ring_size, aggregate_ring_sizes, VALID_RING_MODELS, DEFAULT_RING_MODEL
43
  from src.image_quality import (
44
  check_card_in_frame,
@@ -328,8 +329,9 @@ def _overlay_sam_masks(
328
  ) -> np.ndarray:
329
  """Tint the SAM hand and card masks onto a debug image.
330
 
331
- Hand mask is rendered in cyan, card mask in green. Both are drawn as
332
- semi-transparent fills plus a solid contour so the pixel-accurate SAM
 
333
  silhouettes remain visible underneath downstream finger/edge overlays.
334
 
335
  If ``rotation_matrix`` is supplied (because the caller applied a precise
@@ -363,23 +365,14 @@ def _overlay_sam_masks(
363
  hand_u8 = _prepare(hand_mask)
364
  card_u8 = _prepare(card_mask)
365
 
366
- # Semi-transparent fills
367
- if hand_u8 is not None:
368
- tint = np.zeros_like(out)
369
- tint[hand_u8 > 0] = (255, 255, 0) # cyan in BGR
370
- out = cv2.addWeighted(out, 1.0, tint, 0.18, 0)
371
- if card_u8 is not None:
372
- tint = np.zeros_like(out)
373
- tint[card_u8 > 0] = (0, 255, 0) # green in BGR
374
- out = cv2.addWeighted(out, 1.0, tint, 0.22, 0)
375
-
376
- # Solid contours to emphasize the SAM-derived silhouette
377
  if hand_u8 is not None:
378
  contours, _ = cv2.findContours(hand_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
379
- cv2.drawContours(out, contours, -1, (255, 255, 0), 2, cv2.LINE_AA)
380
  if card_u8 is not None:
381
  contours, _ = cv2.findContours(card_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
382
- cv2.drawContours(out, contours, -1, (0, 255, 0), 2, cv2.LINE_AA)
383
 
384
  return out
385
 
@@ -1500,10 +1493,13 @@ def _draw_multi_finger_debug(
1500
  from src.visualization import draw_card_overlay
1501
  from src.viz_constants import Color
1502
 
 
 
 
1503
  FINGER_COLORS = {
1504
- "index": (255, 255, 0), # Cyan (BGR)
1505
- "middle": (0, 255, 0), # Green
1506
- "ring": (255, 0, 255), # Magenta
1507
  }
1508
 
1509
  vis = image_canonical.copy()
@@ -1581,14 +1577,18 @@ def _draw_multi_finger_debug(
1581
  left_can = to_canonical(np.array([lx, gy])).astype(np.int32)
1582
  right_can = to_canonical(np.array([rx, gy])).astype(np.int32)
1583
 
1584
- # Edge dots (small, using finger color)
1585
- cv2.circle(vis, tuple(left_can), 2, Color.BLUE, -1)
1586
- cv2.circle(vis, tuple(right_can), 2, Color.MAGENTA, -1)
 
1587
 
1588
- # Width measurement lines (every Nth row)
 
 
 
1589
  if count % line_spacing == 0:
1590
  cv2.line(vis, tuple(left_can), tuple(right_can),
1591
- Color.GREEN, 1, cv2.LINE_AA)
1592
  count += 1
1593
 
1594
  _save_debug_visualization(result_png_path, vis)
 
39
  )
40
  from src.debug_observer import draw_comprehensive_edge_overlay, draw_hand_skeleton
41
  from src.visualization import draw_card_overlay
42
+ from src.viz_constants import Color
43
  from src.ring_size import recommend_ring_size, aggregate_ring_sizes, VALID_RING_MODELS, DEFAULT_RING_MODEL
44
  from src.image_quality import (
45
  check_card_in_frame,
 
329
  ) -> np.ndarray:
330
  """Tint the SAM hand and card masks onto a debug image.
331
 
332
+ Both the hand and the card are rendered in Color.CONTOUR (the shared
333
+ default object-outline color, distinct from the per-finger band colors)
334
+ as semi-transparent fills plus a solid contour so the pixel-accurate SAM
335
  silhouettes remain visible underneath downstream finger/edge overlays.
336
 
337
  If ``rotation_matrix`` is supplied (because the caller applied a precise
 
365
  hand_u8 = _prepare(hand_mask)
366
  card_u8 = _prepare(card_mask)
367
 
368
+ # Solid contours only β€” no tinted fill, so the underlying photo stays
369
+ # at full brightness inside the SAM silhouettes.
 
 
 
 
 
 
 
 
 
370
  if hand_u8 is not None:
371
  contours, _ = cv2.findContours(hand_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
372
+ cv2.drawContours(out, contours, -1, Color.CONTOUR, 2, cv2.LINE_AA)
373
  if card_u8 is not None:
374
  contours, _ = cv2.findContours(card_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
375
+ cv2.drawContours(out, contours, -1, Color.CONTOUR, 2, cv2.LINE_AA)
376
 
377
  return out
378
 
 
1493
  from src.visualization import draw_card_overlay
1494
  from src.viz_constants import Color
1495
 
1496
+ # BGR values matched to the mobile result-card top-border colors in
1497
+ # web_demo/static/mobile/steps/result.js so each band on the overlay
1498
+ # visually links to its card: index #00dddd, middle #3b82f6, ring #dd44dd.
1499
  FINGER_COLORS = {
1500
+ "index": (221, 221, 0), # cyan
1501
+ "middle": (246, 130, 59), # blue
1502
+ "ring": (221, 68, 221), # magenta
1503
  }
1504
 
1505
  vis = image_canonical.copy()
 
1577
  left_can = to_canonical(np.array([lx, gy])).astype(np.int32)
1578
  right_can = to_canonical(np.array([rx, gy])).astype(np.int32)
1579
 
1580
+ # Edge dots in the per-finger color so left/right boundaries
1581
+ # match the band lines and the result card.
1582
+ cv2.circle(vis, tuple(left_can), 2, color, -1)
1583
+ cv2.circle(vis, tuple(right_can), 2, color, -1)
1584
 
1585
+ # Width measurement lines (every Nth row) in the per-finger
1586
+ # color so the band visually matches its result card. Drawn
1587
+ # at thickness 2 so they read as boldly as the dot-stacked
1588
+ # left/right boundary bars rather than fading into the skin.
1589
  if count % line_spacing == 0:
1590
  cv2.line(vis, tuple(left_can), tuple(right_can),
1591
+ color, 2, cv2.LINE_AA)
1592
  count += 1
1593
 
1594
  _save_debug_visualization(result_png_path, vis)
src/debug_observer.py CHANGED
@@ -187,11 +187,12 @@ def draw_hand_skeleton(image: np.ndarray, landmarks: np.ndarray) -> np.ndarray:
187
  """
188
  overlay = image.copy()
189
 
190
- # Draw connections
 
191
  for idx1, idx2 in HAND_CONNECTIONS:
192
  pt1 = (int(landmarks[idx1, 0]), int(landmarks[idx1, 1]))
193
  pt2 = (int(landmarks[idx2, 0]), int(landmarks[idx2, 1]))
194
- cv2.line(overlay, pt1, pt2, Color.CYAN, Size.LINE_THICK, cv2.LINE_AA)
195
 
196
  # Draw landmarks on top
197
  for i, (x, y) in enumerate(landmarks):
 
187
  """
188
  overlay = image.copy()
189
 
190
+ # Draw connections in the shared CONTOUR color so the skeleton matches
191
+ # the hand silhouette and stays distinct from the per-finger band colors.
192
  for idx1, idx2 in HAND_CONNECTIONS:
193
  pt1 = (int(landmarks[idx1, 0]), int(landmarks[idx1, 1]))
194
  pt2 = (int(landmarks[idx2, 0]), int(landmarks[idx2, 1]))
195
+ cv2.line(overlay, pt1, pt2, Color.CONTOUR, Size.LINE_THICK, cv2.LINE_AA)
196
 
197
  # Draw landmarks on top
198
  for i, (x, y) in enumerate(landmarks):
src/visualization.py CHANGED
@@ -138,14 +138,7 @@ def draw_card_overlay(
138
  corners = np.asarray(card_result["corners"], dtype=np.int32)
139
  params = get_scaled_font_params(image.shape[0])
140
 
141
- # Semi-transparent fill inside the corner quadrilateral. Avoids the
142
- # visual clutter of drawing the raw SAM mask (which often has stray
143
- # blobs outside the real card boundary).
144
- tint = np.zeros_like(image)
145
- cv2.fillConvexPoly(tint, corners, Color.CARD)
146
- cv2.addWeighted(image, 1.0, tint, 0.22, 0, dst=image)
147
-
148
- # Draw quadrilateral
149
  cv2.polylines(image, [corners], isClosed=True, color=Color.CARD,
150
  thickness=params["contour_thickness"])
151
 
 
138
  corners = np.asarray(card_result["corners"], dtype=np.int32)
139
  params = get_scaled_font_params(image.shape[0])
140
 
141
+ # Draw quadrilateral (no tinted fill β€” keeps the card photo unobscured).
 
 
 
 
 
 
 
142
  cv2.polylines(image, [corners], isClosed=True, color=Color.CARD,
143
  thickness=params["contour_thickness"])
144
 
src/viz_constants.py CHANGED
@@ -100,9 +100,13 @@ class Color:
100
  # Semantic Colors (what they represent in the system)
101
  # ========================================================================
102
 
103
- # Object colors
104
- CARD = GREEN # Credit card outline
105
- FINGER = MAGENTA # Finger contour
 
 
 
 
106
 
107
  # Axis/geometry colors
108
  AXIS_PALM = CYAN # Palm-side axis endpoint
 
100
  # Semantic Colors (what they represent in the system)
101
  # ========================================================================
102
 
103
+ # Object colors. CONTOUR is the shared default for tracing detected
104
+ # objects (card, hand silhouette, finger contour). Kept distinct from the
105
+ # per-finger ring-zone band colors (cyan/green/magenta) so the structural
106
+ # outlines don't compete with the measurement bands.
107
+ CONTOUR = GREEN # Default object contour
108
+ CARD = CONTOUR # Credit card outline
109
+ FINGER = CONTOUR # Finger contour
110
 
111
  # Axis/geometry colors
112
  AXIS_PALM = CYAN # Palm-side axis endpoint
web_demo/static/mobile/mobile.css CHANGED
@@ -1,9 +1,9 @@
1
  /* Mobile flow stylesheet. Pulls design tokens from shared/tokens.css
2
  so the palette matches the desktop. Visual treatments here mirror
3
- the desktop's hero copy, hero-card panel, file-drop, capture-tips,
4
- primary button, image-frame, finger-cards, and size-ref-table β€”
5
- the mobile flow is the desktop page paginated, not a different
6
- visual identity. */
7
 
8
  @import url("../shared/tokens.css");
9
 
@@ -247,47 +247,7 @@ body {
247
  font-weight: 600;
248
  }
249
 
250
- /* --- File drop + camera trigger + tips (matches desktop) -------- */
251
-
252
- .file-drop {
253
- display: flex;
254
- flex-direction: column;
255
- gap: 6px;
256
- padding: 22px;
257
- border: 1.5px dashed var(--accent);
258
- border-radius: 18px;
259
- background: var(--sand);
260
- cursor: pointer;
261
- margin-bottom: 12px;
262
- }
263
-
264
- .file-drop input {
265
- display: none;
266
- }
267
-
268
- .file-title {
269
- font-size: 1.05rem;
270
- font-weight: 600;
271
- color: var(--ink);
272
- }
273
-
274
- .file-hint {
275
- font-size: 0.9rem;
276
- color: var(--ink-soft);
277
- }
278
-
279
- .camera-trigger {
280
- width: 100%;
281
- margin-bottom: 14px;
282
- padding: 12px 14px;
283
- font-size: 0.95rem;
284
- font-weight: 600;
285
- color: var(--accent-dark);
286
- background: rgba(255, 255, 255, 0.85);
287
- border: 1px dashed var(--accent);
288
- border-radius: 14px;
289
- cursor: pointer;
290
- }
291
 
292
  .capture-tips {
293
  margin: 0;
 
1
  /* Mobile flow stylesheet. Pulls design tokens from shared/tokens.css
2
  so the palette matches the desktop. Visual treatments here mirror
3
+ the desktop's hero copy, hero-card panel, capture-tips, primary
4
+ button, image-frame, finger-cards, and size-ref-table β€” the mobile
5
+ flow is the desktop page paginated, not a different visual
6
+ identity. */
7
 
8
  @import url("../shared/tokens.css");
9
 
 
247
  font-weight: 600;
248
  }
249
 
250
+ /* --- Capture tips (matches desktop) ----------------------------- */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
  .capture-tips {
253
  margin: 0;
web_demo/static/mobile/session.js CHANGED
@@ -31,12 +31,7 @@ export const session = {
31
  // reloads this module and reinitializes `session` to the defaults.
32
  export function resetForRetake() {
33
  if (session.imageUrl && session.imageUrl.startsWith("blob:")) {
34
- try {
35
- URL.revokeObjectURL(session.imageUrl);
36
- } catch (err) {
37
- // URL.revokeObjectURL never throws on a valid blob URL; this is
38
- // pure defensive guarding for malformed values.
39
- }
40
  }
41
  session.imageBlob = null;
42
  session.imageUrl = "";
 
31
  // reloads this module and reinitializes `session` to the defaults.
32
  export function resetForRetake() {
33
  if (session.imageUrl && session.imageUrl.startsWith("blob:")) {
34
+ URL.revokeObjectURL(session.imageUrl);
 
 
 
 
 
35
  }
36
  session.imageBlob = null;
37
  session.imageUrl = "";
web_demo/static/mobile/steps/capture.js CHANGED
@@ -34,6 +34,12 @@ let levelDot = null;
34
  let shutterBtn = null;
35
  let flashBtn = null;
36
  let statusEl = null;
 
 
 
 
 
 
37
 
38
  function createTelemetry() {
39
  return {
@@ -447,10 +453,10 @@ export default {
447
  if (window.visualViewport) {
448
  window.visualViewport.addEventListener("resize", sync);
449
  window.visualViewport.addEventListener("scroll", sync);
 
450
  }
451
- // Stash the listener on the stage so unmount() can detach it.
452
- stage._syncInsetsListener = sync;
453
 
 
454
  (async () => {
455
  // Orientation permission must be requested in response to the
456
  // user gesture that triggered this step (the prep step's "Open
@@ -465,6 +471,10 @@ export default {
465
  } else {
466
  orientationStatus = "unsupported";
467
  }
 
 
 
 
468
 
469
  try {
470
  await window.CapturePreview.start(videoEl);
@@ -472,20 +482,33 @@ export default {
472
  setStatus(`Camera unavailable: ${err.message || err}`, true);
473
  return;
474
  }
 
 
 
 
475
  setStatus("Get both green to unlock the shutter β€” hold level, at the right distance.");
476
  await refreshFlash();
 
 
 
 
477
  await startGateLoop();
 
 
 
 
478
  })();
479
  },
480
 
481
  unmount() {
 
482
  stopGateLoop();
483
  if (window.CapturePreview) window.CapturePreview.stop();
484
- const stage = document.querySelector(".step-capture");
485
- if (stage && window.visualViewport && stage._syncInsetsListener) {
486
- window.visualViewport.removeEventListener("resize", stage._syncInsetsListener);
487
- window.visualViewport.removeEventListener("scroll", stage._syncInsetsListener);
488
  }
 
489
  videoEl = null;
490
  distanceChip = null;
491
  levelChip = null;
 
34
  let shutterBtn = null;
35
  let flashBtn = null;
36
  let statusEl = null;
37
+ let viewportListener = null;
38
+ // Bumped on every mount; the async startup IIFE compares its captured
39
+ // generation to this after each await to detect that unmount has fired
40
+ // while it was suspended. Without this, a quick Back tap can let the
41
+ // IIFE start a setInterval *after* unmount has run, leaking it forever.
42
+ let mountGeneration = 0;
43
 
44
  function createTelemetry() {
45
  return {
 
453
  if (window.visualViewport) {
454
  window.visualViewport.addEventListener("resize", sync);
455
  window.visualViewport.addEventListener("scroll", sync);
456
+ viewportListener = sync;
457
  }
 
 
458
 
459
+ const generation = ++mountGeneration;
460
  (async () => {
461
  // Orientation permission must be requested in response to the
462
  // user gesture that triggered this step (the prep step's "Open
 
471
  } else {
472
  orientationStatus = "unsupported";
473
  }
474
+ // Bail if unmount fired while we were suspended β€” without this
475
+ // we'd start the camera and gate-loop interval on a step that
476
+ // has already torn down, leaking both.
477
+ if (generation !== mountGeneration) return;
478
 
479
  try {
480
  await window.CapturePreview.start(videoEl);
 
482
  setStatus(`Camera unavailable: ${err.message || err}`, true);
483
  return;
484
  }
485
+ if (generation !== mountGeneration) {
486
+ window.CapturePreview.stop();
487
+ return;
488
+ }
489
  setStatus("Get both green to unlock the shutter β€” hold level, at the right distance.");
490
  await refreshFlash();
491
+ if (generation !== mountGeneration) {
492
+ window.CapturePreview.stop();
493
+ return;
494
+ }
495
  await startGateLoop();
496
+ if (generation !== mountGeneration) {
497
+ stopGateLoop();
498
+ window.CapturePreview.stop();
499
+ }
500
  })();
501
  },
502
 
503
  unmount() {
504
+ mountGeneration++;
505
  stopGateLoop();
506
  if (window.CapturePreview) window.CapturePreview.stop();
507
+ if (viewportListener && window.visualViewport) {
508
+ window.visualViewport.removeEventListener("resize", viewportListener);
509
+ window.visualViewport.removeEventListener("scroll", viewportListener);
 
510
  }
511
+ viewportListener = null;
512
  videoEl = null;
513
  distanceChip = null;
514
  levelChip = null;
web_demo/static/mobile/steps/result.js CHANGED
@@ -16,7 +16,7 @@ const FINGER_LABEL = {
16
  };
17
  const FINGER_COLOR = {
18
  index: "#00dddd",
19
- middle: "#00cc44",
20
  ring: "#dd44dd",
21
  };
22
  const RING_MODEL_LABELS = { gen: "Gen1/Gen2", air: "Air" };
 
16
  };
17
  const FINGER_COLOR = {
18
  index: "#00dddd",
19
+ middle: "#3b82f6",
20
  ring: "#dd44dd",
21
  };
22
  const RING_MODEL_LABELS = { gen: "Gen1/Gen2", air: "Air" };
web_demo/templates/mobile.html CHANGED
@@ -10,10 +10,9 @@
10
  <body>
11
  <!--
12
  Step controller paints into this single root container β€” each step
13
- fully owns the visible viewport, no nested scrolling. Phase 1 is
14
- Next/Back navigation between five placeholder steps; later phases
15
- fill them in with real content (intro copy, position guide,
16
- camera capture, results).
17
  -->
18
  <main id="mobileRoot"></main>
19
 
 
10
  <body>
11
  <!--
12
  Step controller paints into this single root container β€” each step
13
+ fully owns the visible viewport, no nested scrolling. The six steps
14
+ (intro / form / guide / capture / confirm / result) live under
15
+ static/mobile/steps/ and are wired up in mobile.js.
 
16
  -->
17
  <main id="mobileRoot"></main>
18